Author SHA1 Message Date
AskaEth a0b4c941e4 fix(loop): keep cross-batch voice success and gate speak without dispatcher (M57)
两处 review 必修(review-m57 r1):

P2-1 跨批覆盖:applyBatchVoice 原先无条件用后一批的语音结论覆盖
前一批,于是「先成功合成 abc.wav、下一批换个参数再试却失败」会把
Result.Voice 退化成 tts_failed —— 音频已落盘,退回文本既无收益也
违背那次表达。改为守 captureVoice 的同一条优先级:已落盘的音频不被
后续批次的失败抹掉;先降级后成功仍然覆盖(后到的成功即最终语音)。

P2-2 nil-dispatcher 安全门:speak 由循环自己承载、无 dispatcher 也
能执行(invokeOne 短路),但 applyToolAdmissions 在 dispatcher 缺席
时整段跳过安全门,使「没配 dispatcher」成为绕过 design/11 §3 的
旁路。新增 applyDispatcherlessVoiceGate:与策略引擎对未登记工具的
口径一致(默认关闭、只有 owner 放行),拒绝走同一条 not_allowed
软结果与 tool.reject.not_allowed 审计通道;并改正该处过时注释。

两条回归测试都做过改前红/改后绿:
- TestApplyBatchVoice_KeepsCrossBatchSuccess:注释掉守卫即报
  「voice = ... FallbackReason:tts_failed, want abc.wav」
- TestApplyToolAdmissions_DispatcherlessSpeakGate:去掉 else 分支即报
  「env group_member run = true, want false」

验收:go build ./... / go vet ./... / go test ./... 全绿。
2026-10-27 22:00:18 +08:00
AskaEth c9da488749 refactor(loop): extract speak voice backfill into applyBatchVoice (M57) 2026-10-27 21:33:05 +08:00
AskaEth 39ae883a4e test(loop): cover the speak tool and Result.Voice (M57)
speak_tool_test.go pins the leaf behaviour: budget formula, extension
whitelist, argument validation, and the success/timeout/no-audio/store-failed
paths, plus the model-surface advertisement and append-once rule.

voice_result_test.go pins the outbound chain end to end: captureVoice's three
states and its last-success-wins rule, a full turn where speak is advertised,
executed through the injected synth/store and lands on Result.Voice, the
degraded turn that still answers in text, the unwired turn that keeps the
surface byte-identical, and the store adapter the wiring will use.
2026-10-27 21:22:20 +08:00
AskaEth 1347a77d33 feat(loop): wire speak tool into the loop (M57)
- speak_tool.go: the speak tool itself — rune-derived timeout budget, structured
  failure codes, persona/emotion stamped on the synthesis ctx, audio persisted
  through the store seam, structured result for the model.
- voice_deps.go: the wiring seam (VoiceSynth / VoiceStore + VoiceStoreFunc
  adapter), voiceEnabled, withSpeakSpec (appended after every schema shrink),
  and captureVoice (success beats failure, last one wins).
- options.go / loop.go: VoiceSynth, VoiceStore, SpeakBudget injection points.
- request.go: advertise speak on both toolSchemas return paths.
- tools.go / tools_invoke.go: carry the batch voice outcome; split invokeOne /
  emitToolExecution / spanStatusForToolPhase / invokeParallel into their own
  file (tools.go was at the 500-line ceiling) and let speak run without a
  dispatcher.
- run.go: keep the voice outcome across tool batches (a later batch without
  speak must not erase it).

Known, deliberate: speak bypasses the capacity allow-set and skill-round
narrowing (it belongs to no built-in toolset), so it is also advertised in
quiet/awake; execution still passes the tool security gate, where speak is not
registered and non-owner actors are denied fail-closed.
2026-10-27 21:22:14 +08:00
AskaEth f84a358e30 feat(loop): add OutboundVoice result type for speak tool 2026-10-27 20:22:12 +08:00
25 changed files with 1430 additions and 901 deletions

No files matched your search

+9
View File
@@ -143,6 +143,12 @@ type Loop struct {
// 逐字节一致。它由装配期一次性挂上,之后只读;每会话的状态在 SkillFace 内部 // 逐字节一致。它由装配期一次性挂上,之后只读;每会话的状态在 SkillFace 内部
// 按会话分桶并自带锁,因此循环的并发 Run 契约不变。 // 按会话分桶并自带锁,因此循环的并发 Run 契约不变。
skillFace *SkillFace skillFace *SkillFace
// voiceSynth / voiceStore 是 speak 的注入依赖(M57)。两者都非 nil 时
// 循环向模型广告 speak 并自行承载它的执行(见 voice_deps.go);
// speakBudget 是超时预算的替换公式,nil 用内置公式。
voiceSynth VoiceSynth
voiceStore VoiceStore
speakBudget func(int) int
log *slog.Logger log *slog.Logger
} }
@@ -216,6 +222,9 @@ func New(opts Options) *Loop {
imageResolver: opts.Vision, imageResolver: opts.Vision,
documentResolver: opts.DocumentResolver, documentResolver: opts.DocumentResolver,
documentInjectChars: docInjectChars, documentInjectChars: docInjectChars,
voiceSynth: opts.VoiceSynth,
voiceStore: opts.VoiceStore,
speakBudget: opts.SpeakBudget,
log: sourcedLog(opts.Log), log: sourcedLog(opts.Log),
} }
} }
+9
View File
@@ -214,6 +214,15 @@ type Options struct {
// 或重排模型所见的消息。它是循环中与 // 或重排模型所见的消息。它是循环中与
// OpenClaw 的 transformContext 等价之物(design §1.3)。为 nil = 不做投影。 // OpenClaw 的 transformContext 等价之物(design §1.3)。为 nil = 不做投影。
TransformContext func(ctx context.Context, messages []model.Message, round int) []model.Message TransformContext func(ctx context.Context, messages []model.Message, round int) []model.Message
// VoiceSynth 与 VoiceStore 一起接线时(**两个都非 nil**),循环向模型
// 广告 speak 工具(M57):她可以自己决定这一条回复用语音还是文本。
// 任一为 nil = 不广告、speak 不可调用,模型面与接线前逐字节一致。
VoiceSynth VoiceSynth
VoiceStore VoiceStore
// SpeakBudget 可替换 speak 的超时预算公式(文本 rune 数 → 毫秒)。
// 为 nil 或返回值非正时回落内置公式 speakBudgetMs。它是给测试与
// 部署调优用的注入点,不是能力开关(开关是上面两个依赖)。
SpeakBudget func(runes int) int
// Log 是基础 logger。为 nil 时替换为标记来源 // Log 是基础 logger。为 nil 时替换为标记来源
// "runtime/loop" 的丢弃 logger。 // "runtime/loop" 的丢弃 logger。
Log *slog.Logger Log *slog.Logger
+2 -2
View File
@@ -87,7 +87,7 @@ func (l *Loop) toolSchemas(ctx context.Context, messages []model.Message) []mode
} }
schemas := d.ToolSchemas() schemas := d.ToolSchemas()
if l.capacity == nil || !l.capacity.Enabled() { if l.capacity == nil || !l.capacity.Enabled() {
return l.narrowToolSchemas(ctx, l.restrictSchemas(ctx, d, schemas)) return l.withSpeakSpec(l.narrowToolSchemas(ctx, l.restrictSchemas(ctx, d, schemas)))
} }
names := make([]string, 0, len(schemas)) names := make([]string, 0, len(schemas))
@@ -124,7 +124,7 @@ func (l *Loop) toolSchemas(ctx context.Context, messages []model.Message) []mode
out = append(out, s) out = append(out, s)
} }
} }
return l.narrowToolSchemas(ctx, l.restrictSchemas(ctx, d, out)) return l.withSpeakSpec(l.narrowToolSchemas(ctx, l.restrictSchemas(ctx, d, out)))
} }
// capacityTexts 为 capacity 判定器提取近期对话文本: // capacityTexts 为 capacity 判定器提取近期对话文本:
+1
View File
@@ -187,6 +187,7 @@ func (l *Loop) driveTurn(ctx context.Context, sessionID string, msgs []model.Mes
} }
msgs = batch.messages msgs = batch.messages
res.ToolCallsExecuted += batch.executed res.ToolCallsExecuted += batch.executed
applyBatchVoice(res, &batch)
l.saveCheckpoint(ctx, sessionID, step, msgs, agent.StopReasonToolCalls) l.saveCheckpoint(ctx, sessionID, step, msgs, agent.StopReasonToolCalls)
// 关键工具循环安全网(或终止钩子)不等真正的最终答案 // 关键工具循环安全网(或终止钩子)不等真正的最终答案
+336
View File
@@ -0,0 +1,336 @@
package loop
// 本文件实现模型侧的 speak 工具(M57)。主人 2026-10-27 的口径是「他可以自己
// 选择某次消息是拿文字发 还是去请求 TTS 接口发音频」——**决策是她的**,因此这里
// 只提供一个可调用的工具,绝不替她决定什么时候用语音(那会变成幕后置规则)。
// 调了 speak = 这条想用语音;不调 = 发文本。
//
// 三条执行口径:
// - 超时预算按**文本 rune 数**推导(speakBudgetMs):短句拿最低预算,长文拿
// 上限,中间线性 —— 越长合成越慢,但绝不无限等;
// - 成功 = 音频副本**已落盘**(经 VoiceStore 接缝,接线方走 documents.SaveAudio
// 的内容寻址 0600 口径)+ 结构化结果(name/mime/size/durationMs/budgetMs/costMs);
// - 超时/失败 = 结构化错误(code + 预算 + 实际耗时),**回合绝不因此失败**,
// 她能在后续输出里看到「这次语音没成」,客户端也能据此特殊显示。
import (
"context"
"errors"
"fmt"
"log/slog"
"strings"
"time"
"unicode/utf8"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/agent"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/model"
mvoice "git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/multimodal/voice"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/tool"
voicetts "git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/voice/tts"
"git.yeij.top/AskaEth/Cyrene_v2/pkg/plugins/sdk"
)
const (
// speakToolName 是模型侧工具名。它同时是 Result.Voice 回填时的匹配键。
speakToolName = "speak"
// speakBudgetBaseMs 是任何一次合成的最低预算:即使一个字的请求也要给
// 上游留出建连 + 首包的时间,低于它的预算只会把"网络慢"误判成"内容长"。
speakBudgetBaseMs = 3000
// speakBudgetPerRuneMs 是每个 rune 追加的预算(毫秒)。25ms/rune 约等于
// 每秒 40 个字符的合成速度,比实测慢一截,留出余量。
speakBudgetPerRuneMs = 25
// speakBudgetMaxMs 是预算上限:再长的文本也等这么久,超了就退回文本,
// 绝不让她的一条消息把整个回合挂住。
speakBudgetMaxMs = 20000
// speakMaxRunes 是文本长度硬上限。按上面的公式,约 680 rune 就顶到预算
// 上限,再长的文本必然超时 —— 早拒绝能省一次上游调用与一次落盘。
speakMaxRunes = 1200
// speakAudioOrigName 是落盘时给 documents.SaveAudio 的原始名,扩展名
// 由上游实际格式拼接(白名单见 voiceref.MediaExtAllowed)。
speakAudioOrigName = "speak."
// 降级/失败原因码(Result.Voice.FallbackReason 与错误正文共用同一套取值,
// 客户端据此决定怎么显示「这条本来想用语音」)。
speakReasonTimeout = "tts_timeout"
speakReasonFailed = "tts_failed"
speakReasonNoAudio = "tts_no_audio"
speakReasonStoreFailed = "tts_store_failed"
speakReasonTooLong = "text_too_long"
speakReasonInvalidArgs = "invalid_args"
)
// speakTool 实现 sdk.Tool。依赖全部由循环注入(不自己 new),因此叶子测试
// 可以塞桩:合成桩决定成功/超时,落盘桩决定存储名,预算桩决定超时长短。
type speakTool struct {
synth VoiceSynth
store VoiceStore
// budget 把 rune 数映射为预算(毫秒);nil 用内置公式。
budget func(runes int) int
// persona 解析本回合的生效人格 id,供 TTS 的发音人解析链使用
// (发音人绑人格,链本身不动)。
persona func(context.Context) string
log *slog.Logger
}
// 编译期断言:speak 就是一个普通的模型侧工具,接线方式与其他工具一致。
var _ sdk.Tool = speakTool{}
// Name 返回工具标识。
func (speakTool) Name() string { return speakToolName }
// Description 说明**何时**用语音。它是能力描述,不是身份/权限判定 ——
// 谁能用语音仍由渠道层 actor 元数据 + 工具可达性标记决定。
func (speakTool) Description() string {
return "用语音(合成音频)把这段话念出来,让这一条回复变成声音。" +
"适合用语音:轻声细语、撒娇、唱歌、长一点的心里话或独白、需要语气温度的表达。" +
"适合用文本:代码、列表、链接、报错原文,以及需要被复制或搜索的内容。" +
"语音只改变这一条回复的呈现方式,不改变你的能力或身份。" +
"合成失败或超时会自动退回文本,调用一次就够,不要为同一句话反复重试。"
}
// Parameters 返回工具输入的 JSON Schema。
func (speakTool) Parameters() (map[string]any, error) {
return map[string]any{
"type": "object",
"properties": map[string]any{
"text": map[string]any{
"type": "string",
"description": "要念出来的话。写成可直接朗读的口语,不要带 Markdown 标记、代码块或链接。",
},
"emotion": map[string]any{
"type": "string",
"description": "可选语气微调(如「温柔」「轻声」「欢快」)。默认语气由你的人格决定," +
"这个字段只做额外微调,不会换掉发音人。",
},
},
"required": []string{"text"},
}, nil
}
// speakArgs 是解析后的调用参数。runes 只算一次,预算与日志共用。
type speakArgs struct {
text string
emotion string
runes int
}
// Execute 合成一段语音并落盘副本。成功返回结构化结果;失败返回 *speakError,
// 它同时是模型能读懂的错误正文与 Result.Voice 的降级依据。
func (t speakTool) Execute(ctx context.Context, args any) (any, error) {
in, err := parseSpeakArgs(args)
if err != nil {
return nil, err
}
if t.synth == nil || t.store == nil {
return nil, &speakError{Code: speakReasonFailed,
Err: errors.New("speak: voice synthesis is not configured")}
}
budget := t.budgetMs(in.runes)
synthCtx, cancel := context.WithTimeout(ctx, time.Duration(budget)*time.Millisecond)
defer cancel()
synthCtx = t.stampVoiceContext(synthCtx, ctx, in.emotion)
started := time.Now()
audio, provider, err := t.synth.TTSWithProvider(synthCtx, in.text)
cost := int(time.Since(started) / time.Millisecond)
if err != nil {
return nil, t.synthesisFailed(synthCtx, err, budget, cost)
}
ext, extOK := speakExt(audio.Format)
if len(audio.Data) == 0 || !extOK {
return nil, &speakError{Code: speakReasonNoAudio, BudgetMs: budget, CostMs: cost,
Err: fmt.Errorf("speak: unusable audio (format %q, %d bytes)", audio.Format, len(audio.Data))}
}
// 落盘用**父 ctx**:预算只约束上游合成,不该让已经合成好的音频因为预算
// 到点而丢掉(写盘是本地动作,毫秒级,且副本本身就是交付物)。
ref, err := t.store.SaveAudio(ctx, speakAudioOrigName+ext, audio.Data)
if err != nil {
return nil, &speakError{Code: speakReasonStoreFailed, BudgetMs: budget, CostMs: cost, Err: err}
}
t.logSynthesized(provider, in.runes, budget, cost, len(audio.Data))
// durationMs 恒为 0:本项目**绝不估算时长**(与 voice.Copy.DurationMs 同一
// 口径),而解析 WAV 头不在本切片范围内。0 表示「未解析」,不是零长度。
return map[string]any{
"name": ref.Name,
"mime": ref.MIME,
"size": ref.Size,
"durationMs": int64(0),
"budgetMs": budget,
"costMs": cost,
}, nil
}
// stampVoiceContext 把本回合的生效人格与可选语气挂到 ctx 上:TTS 侧按人格
// 解析发音人(链不动),emotion 只作为逐请求的语气指令透传到底层链。
func (t speakTool) stampVoiceContext(synthCtx, turnCtx context.Context, emotion string) context.Context {
if t.persona != nil {
if pid := t.persona(turnCtx); pid != "" {
synthCtx = voicetts.WithPersonaID(synthCtx, pid)
}
}
if emotion != "" {
synthCtx = mvoice.WithTTSInstruction(synthCtx, emotion)
}
return synthCtx
}
// budgetMs 返回本次合成的超时预算;注入值非正时回落内置公式。
func (t speakTool) budgetMs(runes int) int {
if t.budget != nil {
if ms := t.budget(runes); ms > 0 {
return ms
}
}
return speakBudgetMs(runes)
}
// synthesisFailed 把上游错误归一化成带原因码的结构化失败。超时判定看三处:
// 上游自己报的 deadline、我们给的预算 ctx 已过期、或实际耗时已经吃满预算
// (上游把超时包成自己的错误类型时只有最后一处能认出来)。
func (t speakTool) synthesisFailed(synthCtx context.Context, err error, budget, cost int) error {
code := speakReasonFailed
if errors.Is(err, context.DeadlineExceeded) ||
errors.Is(synthCtx.Err(), context.DeadlineExceeded) || cost >= budget {
code = speakReasonTimeout
}
if t.log != nil {
// 只记长度与耗时,**绝不记正文**(与网关出站 TTS 同一口径)。
t.log.Warn("speak degraded to text",
"reason", code, "budget_ms", budget, "cost_ms", cost, "err", err)
}
return &speakError{Code: code, BudgetMs: budget, CostMs: cost, Err: err}
}
// logSynthesized 记一条成功合成。只带来源、级别名、长度与耗时,绝不带正文。
func (t speakTool) logSynthesized(provider string, runes, budget, cost, size int) {
if t.log == nil {
return
}
t.log.Info("speak synthesized",
"provider", provider, "runes", runes, "budget_ms", budget, "cost_ms", cost, "bytes", size)
}
// speakError 是 speak 的结构化失败。Code 进 Result.Voice.FallbackReason,让
// 「想用语音但没成」与「本来就用文本」在调用方与客户端都能区分开;预算与实际
// 耗时让模型下一轮能判断是"该说短点"还是"上游坏了"。
type speakError struct {
Code string
BudgetMs int
CostMs int
Err error
}
// Error 渲染成一句可读正文,随 RoleTool 消息回灌给模型。
func (e *speakError) Error() string {
if e.Err == nil {
return fmt.Sprintf("speak failed: %s (budget_ms=%d cost_ms=%d)", e.Code, e.BudgetMs, e.CostMs)
}
return fmt.Sprintf("speak failed: %s (budget_ms=%d cost_ms=%d): %v", e.Code, e.BudgetMs, e.CostMs, e.Err)
}
// Unwrap 让 errors.Is 能穿透到原因(含 tool.ErrInvalidArgs 的参数校验分类)。
func (e *speakError) Unwrap() error { return e.Err }
// parseSpeakArgs 解析并校验参数。参数错误包 tool.ErrInvalidArgs,使循环把它
// 归到 argument-validation 而不是执行失败。
func parseSpeakArgs(args any) (speakArgs, error) {
m, err := speakArgMap(args)
if err != nil {
return speakArgs{}, err
}
text, _ := m["text"].(string)
text = strings.TrimSpace(text)
if text == "" {
return speakArgs{}, &speakError{Code: speakReasonInvalidArgs,
Err: fmt.Errorf("speak: text is required: %w", tool.ErrInvalidArgs)}
}
runes := utf8.RuneCountInString(text)
if runes > speakMaxRunes {
return speakArgs{}, &speakError{Code: speakReasonTooLong,
Err: fmt.Errorf("speak: text has %d runes, limit is %d: %w", runes, speakMaxRunes, tool.ErrInvalidArgs)}
}
emotion, _ := m["emotion"].(string)
return speakArgs{text: text, emotion: strings.TrimSpace(emotion), runes: runes}, nil
}
// speakArgMap 把调用参数归一化成 map;nil 参数视为空对象(缺 text 由调用方报)。
func speakArgMap(args any) (map[string]any, error) {
if args == nil {
return map[string]any{}, nil
}
m, ok := args.(map[string]any)
if !ok {
return nil, &speakError{Code: speakReasonInvalidArgs,
Err: fmt.Errorf("speak: arguments must be an object: %w", tool.ErrInvalidArgs)}
}
return m, nil
}
// speakBudgetMs 把文本长度(rune 数)映射为合成超时预算:
// clamp(speakBudgetBaseMs + speakBudgetPerRuneMs×runes, base, max)。
func speakBudgetMs(runes int) int {
if runes < 0 {
runes = 0
}
ms := speakBudgetBaseMs + speakBudgetPerRuneMs*runes
if ms > speakBudgetMaxMs {
return speakBudgetMaxMs
}
if ms < speakBudgetBaseMs {
return speakBudgetBaseMs
}
return ms
}
// speakExt 把上游实际格式映射成落盘扩展名。空格式按 wav 命名 —— TTS 上游
// 产物恒为 WAV(见 voice/tts/audio.go 的口径);白名单外的格式一律判不可用,
// 绝不拿一个读不出 Content-Type 的名字落盘。
func speakExt(format string) (string, bool) {
switch strings.ToLower(strings.TrimSpace(format)) {
case "":
return "wav", true
case "wav", "mp3", "ogg", "flac", "m4a", "webm", "amr":
return strings.ToLower(strings.TrimSpace(format)), true
default:
return "", false
}
}
// speakSpec 返回 speak 的模型面声明(工具未接线时为 nil)。
func (l *Loop) speakSpec() *model.ToolSpec {
if !l.voiceEnabled() {
return nil
}
t := l.speakTool()
params, err := t.Parameters()
if err != nil {
return nil
}
return &model.ToolSpec{Name: t.Name(), Description: t.Description(), Parameters: params}
}
// speakTool 用循环注入的依赖装配一个 speak 工具值(每次装配都是廉价的)。
func (l *Loop) speakTool() speakTool {
return speakTool{
synth: l.voiceSynth,
store: l.voiceStore,
budget: l.speakBudget,
persona: l.personaFor,
log: l.log,
}
}
// invokeTool 执行单个工具调用。speak 由循环自己承载(它需要本回合的人格与
// 预算口径,且未注册进能力登记表),其余一律交给 dispatcher —— 它的超时、
// panic 兜底与允许集软判对普通工具仍然生效。
func (l *Loop) invokeTool(ctx context.Context, d *tool.Dispatcher, tc agent.ToolCall) (any, error) {
if tc.Name == speakToolName && l.voiceEnabled() {
return l.speakTool().Execute(ctx, tc.Args)
}
if d == nil {
return nil, fmt.Errorf("no tool dispatcher configured for %q", tc.Name)
}
return d.Invoke(tool.WithLegID(ctx, ToolLegIDFrom(ctx)), tc.Name, tc.Args)
}
+334
View File
@@ -0,0 +1,334 @@
package loop
import (
"context"
"errors"
"strings"
"testing"
"time"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/model"
mvoice "git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/multimodal/voice"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/tool"
)
// 本文件是 speak 工具的叶子测试(M57):预算公式、扩展名白名单、
// 参数校验、成功/超时/无音频/落盘失败四条执行路径,以及模型面的
// 广告与追加。依赖全部用桩,绝不真连 TTS、绝不真写盘。
// stubSynth 是可配置的合成桩:delay 制造超时,err 制造失败,
// audio 决定成功时的产物,provider 供日志断言。
type stubSynth struct {
audio mvoice.Audio
provider string
err error
delay time.Duration
gotText string
}
func (s *stubSynth) TTSWithProvider(ctx context.Context, text string) (mvoice.Audio, string, error) {
s.gotText = text
if s.delay > 0 {
select {
case <-time.After(s.delay):
case <-ctx.Done():
return mvoice.Audio{}, "", ctx.Err()
}
}
if s.err != nil {
return mvoice.Audio{}, "", s.err
}
return s.audio, s.provider, nil
}
// stubStore 是可配置的落盘桩,同时记录收到的原始名与字节。
type stubStore struct {
ref VoiceRef
err error
gotName string
gotData []byte
}
func (s *stubStore) SaveAudio(_ context.Context, origName string, data []byte) (VoiceRef, error) {
s.gotName = origName
s.gotData = data
if s.err != nil {
return VoiceRef{}, s.err
}
return s.ref, nil
}
// newSpeakTool 装配一个注入了桩的 speak 工具。
func newSpeakTool(synth VoiceSynth, store VoiceStore) speakTool {
return speakTool{synth: synth, store: store}
}
// speakCodeOf 取出 speak 失败的结构化原因码。
func speakCodeOf(t *testing.T, err error) *speakError {
t.Helper()
var se *speakError
if !errors.As(err, &se) {
t.Fatalf("error %v is not *speakError", err)
}
return se
}
// TestSpeakBudgetMs 锁住预算公式的边界:短句拿最低预算、线性增长、
// 长文封顶,负数按 0 处理(绝不返回负预算)。
func TestSpeakBudgetMs(t *testing.T) {
t.Parallel()
cases := []struct {
runes int
want int
}{
{-5, speakBudgetBaseMs},
{0, speakBudgetBaseMs},
{1, speakBudgetBaseMs + speakBudgetPerRuneMs},
{100, speakBudgetBaseMs + 100*speakBudgetPerRuneMs},
{680, speakBudgetMaxMs},
{speakMaxRunes, speakBudgetMaxMs},
}
for _, c := range cases {
if got := speakBudgetMs(c.runes); got != c.want {
t.Errorf("speakBudgetMs(%d) = %d, want %d", c.runes, got, c.want)
}
}
}
// TestSpeakExt 锁住落盘扩展名:空格式按 wav(TTS 产物恒为 WAV)、
// 白名单内小写化、白名单外判不可用(绝不拿读不出 Content-Type 的
// 名字落盘)。
func TestSpeakExt(t *testing.T) {
t.Parallel()
cases := []struct {
format string
want string
ok bool
}{
{"", "wav", true},
{" ", "wav", true},
{"WAV", "wav", true},
{"mp3", "mp3", true},
{" Opus ", "", false},
{"exe", "", false},
}
for _, c := range cases {
got, ok := speakExt(c.format)
if got != c.want || ok != c.ok {
t.Errorf("speakExt(%q) = (%q,%v), want (%q,%v)", c.format, got, ok, c.want, c.ok)
}
}
}
// TestParseSpeakArgs_Validation 锁住参数校验:缺 text、非对象、超长
// 都归 argument-validation(errors.Is(tool.ErrInvalidArgs)),并且
// 长度按 **rune** 算而不是字节(多字节文本不会因字节数被误判超长)。
func TestParseSpeakArgs_Validation(t *testing.T) {
t.Parallel()
if _, err := parseSpeakArgs(nil); !errors.Is(err, tool.ErrInvalidArgs) {
t.Fatalf("nil args err = %v, want ErrInvalidArgs", err)
}
if _, err := parseSpeakArgs(map[string]any{"text": " "}); !errors.Is(err, tool.ErrInvalidArgs) {
t.Fatalf("blank text err = %v, want ErrInvalidArgs", err)
}
if _, err := parseSpeakArgs([]string{"a"}); !errors.Is(err, tool.ErrInvalidArgs) {
t.Fatalf("non-object args err = %v, want ErrInvalidArgs", err)
}
if _, err := parseSpeakArgs(map[string]any{"text": strings.Repeat("あ", speakMaxRunes+1)}); !errors.Is(err, tool.ErrInvalidArgs) {
t.Fatalf("too-long text err = %v, want ErrInvalidArgs", err)
} else if code := speakCodeOf(t, err).Code; code != speakReasonTooLong {
t.Fatalf("too-long code = %q, want %q", code, speakReasonTooLong)
}
// 恰好上限、且是多字节:按 rune 计数,应当放行。
in, err := parseSpeakArgs(map[string]any{
"text": strings.Repeat("あ", speakMaxRunes),
"emotion": " 温柔 ",
})
if err != nil {
t.Fatalf("at-limit runes rejected: %v", err)
}
if in.runes != speakMaxRunes {
t.Fatalf("runes = %d, want %d(必须按 rune 而非字节计数)", in.runes, speakMaxRunes)
}
if in.emotion != "温柔" {
t.Fatalf("emotion = %q, want trimmed 温柔", in.emotion)
}
}
// TestSpeakTool_ExecuteSuccess 锁住成功路径的交付物:音频落盘用
// 上游实际格式的扩展名,返回值带齐 name/mime/size/budgetMs/costMs,
// 且 durationMs 恒为 0(未解析,不是零长度)。
func TestSpeakTool_ExecuteSuccess(t *testing.T) {
t.Parallel()
synth := &stubSynth{
audio: mvoice.Audio{Format: "wav", Data: []byte{1, 2, 3}},
provider: "emotiontts",
}
store := &stubStore{ref: VoiceRef{Name: "abc123.wav", MIME: "audio/wav", Size: 3}}
tk := newSpeakTool(synth, store)
tk.budget = func(int) int { return 777 }
out, err := tk.Execute(context.Background(), map[string]any{"text": "晚安"})
if err != nil {
t.Fatalf("Execute: %v", err)
}
if synth.gotText != "晚安" {
t.Fatalf("synth text = %q, want 晚安", synth.gotText)
}
if store.gotName != "speak.wav" {
t.Fatalf("origName = %q, want speak.wav", store.gotName)
}
if len(store.gotData) != 3 {
t.Fatalf("stored bytes = %d, want 3", len(store.gotData))
}
m, ok := out.(map[string]any)
if !ok {
t.Fatalf("result type = %T, want map[string]any", out)
}
if m["name"] != "abc123.wav" || m["mime"] != "audio/wav" || m["size"] != int64(3) {
t.Fatalf("result = %v, want the stored ref's name/mime/size", m)
}
if m["durationMs"] != int64(0) {
t.Fatalf("durationMs = %v, want int64(0)(未解析)", m["durationMs"])
}
if m["budgetMs"] != 777 {
t.Fatalf("budgetMs = %v, want the injected 777", m["budgetMs"])
}
if _, ok := m["costMs"].(int); !ok {
t.Fatalf("costMs = %v, want int", m["costMs"])
}
}
// TestSpeakTool_ExecuteTimeout 锁住超时路径:预算到点即放弃合成,
// 原因码是 tts_timeout,且预算/实际耗时都回传给调用方(她下一轮
// 据此判断是「说短点」还是「上游坏了」)。这里用 1ms 预算 +
// 5ms 延迟制造真超时,不靠断言硬编码的时间差。
func TestSpeakTool_ExecuteTimeout(t *testing.T) {
t.Parallel()
tk := newSpeakTool(
&stubSynth{audio: mvoice.Audio{Format: "wav", Data: []byte{1}}, delay: 5 * time.Millisecond},
&stubStore{},
)
tk.budget = func(int) int { return 1 }
_, err := tk.Execute(context.Background(), map[string]any{"text": "晚安"})
se := speakCodeOf(t, err)
if se.Code != speakReasonTimeout {
t.Fatalf("code = %q, want %q", se.Code, speakReasonTimeout)
}
if se.BudgetMs != 1 {
t.Fatalf("BudgetMs = %d, want 1", se.BudgetMs)
}
if se.CostMs < 0 {
t.Fatalf("CostMs = %d, want >= 0", se.CostMs)
}
}
// TestSpeakTool_ExecuteNoAudio 锁住「上游说成功但没给可用音频」:
// 空字节与白名单外格式都归 tts_no_audio,绝不落一个空文件。
func TestSpeakTool_ExecuteNoAudio(t *testing.T) {
t.Parallel()
cases := []struct {
name string
audio mvoice.Audio
}{
{"empty bytes", mvoice.Audio{Format: "wav"}},
{"unknown format", mvoice.Audio{Format: "exe", Data: []byte{1}}},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
t.Parallel()
store := &stubStore{ref: VoiceRef{Name: "x.wav"}}
tk := newSpeakTool(&stubSynth{audio: c.audio, provider: "p"}, store)
_, err := tk.Execute(context.Background(), map[string]any{"text": "晚安"})
if code := speakCodeOf(t, err).Code; code != speakReasonNoAudio {
t.Fatalf("code = %q, want %q", code, speakReasonNoAudio)
}
if store.gotName != "" {
t.Fatalf("store was called with %q, want no write at all", store.gotName)
}
})
}
}
// TestSpeakTool_ExecuteStoreFailed 锁住落盘失败:合成成功但副本存不下
// 时不谎报成功 —— 原因是 tts_store_failed,音频字节不会被当作交付物。
func TestSpeakTool_ExecuteStoreFailed(t *testing.T) {
t.Parallel()
tk := newSpeakTool(
&stubSynth{audio: mvoice.Audio{Format: "wav", Data: []byte{1, 2}}, provider: "p"},
&stubStore{err: errors.New("disk full")},
)
_, err := tk.Execute(context.Background(), map[string]any{"text": "晚安"})
se := speakCodeOf(t, err)
if se.Code != speakReasonStoreFailed {
t.Fatalf("code = %q, want %q", se.Code, speakReasonStoreFailed)
}
if !errors.Is(err, se.Err) {
t.Fatalf("error chain lost the store error: %v", err)
}
}
// TestSpeakTool_NotConfigured 锁住「合成或落盘缺一个」:不 panic,
// 以结构化失败返回,使回合仍能继续。
func TestSpeakTool_NotConfigured(t *testing.T) {
t.Parallel()
for _, tk := range []speakTool{
newSpeakTool(nil, &stubStore{}),
newSpeakTool(&stubSynth{}, nil),
} {
_, err := tk.Execute(context.Background(), map[string]any{"text": "晚安"})
if code := speakCodeOf(t, err).Code; code != speakReasonFailed {
t.Fatalf("code = %q, want %q", code, speakReasonFailed)
}
}
}
// TestSpeakSpec 锁住模型面的接线判定:两个依赖缺任一都不广告
// (模型面与接线前逐字节一致),都在时声明带参数 schema 的 speak。
func TestSpeakSpec(t *testing.T) {
t.Parallel()
if spec := New(Options{}).speakSpec(); spec != nil {
t.Fatalf("unwired speakSpec = %+v, want nil", spec)
}
if spec := New(Options{VoiceSynth: &stubSynth{}}).speakSpec(); spec != nil {
t.Fatalf("half-wired speakSpec = %+v, want nil", spec)
}
spec := New(Options{VoiceSynth: &stubSynth{}, VoiceStore: &stubStore{}}).speakSpec()
if spec == nil {
t.Fatal("wired speakSpec = nil, want a spec")
}
if spec.Name != speakToolName {
t.Fatalf("spec name = %q, want %q", spec.Name, speakToolName)
}
if spec.Parameters == nil {
t.Fatal("spec parameters = nil, want the JSON schema")
}
}
// TestWithSpeakSpec 锁住追加语义:未接线原样返回、接线后追加到末尾
// (因此不受前面任何收缩影响)、已有同名不重复追加。
func TestWithSpeakSpec(t *testing.T) {
t.Parallel()
base := []model.ToolSpec{{Name: "terminal"}, {Name: "web-search"}}
unwired := New(Options{})
if got := unwired.withSpeakSpec(base); len(got) != len(base) {
t.Fatalf("unwired appended: %d tools, want %d", len(got), len(base))
}
wired := New(Options{VoiceSynth: &stubSynth{}, VoiceStore: &stubStore{}})
got := wired.withSpeakSpec(base)
if len(got) != len(base)+1 {
t.Fatalf("wired tools = %d, want %d", len(got), len(base)+1)
}
if last := got[len(got)-1].Name; last != speakToolName {
t.Fatalf("last tool = %q, want %q(speak 必须在末尾)", last, speakToolName)
}
again := wired.withSpeakSpec(got)
if len(again) != len(got) {
t.Fatalf("duplicate speak appended: %d tools, want %d", len(again), len(got))
}
}
+32
View File
@@ -3,6 +3,7 @@ package loop
import ( import (
"context" "context"
"git.yeij.top/AskaEth/Cyrene_v2/internal/capability/restrict"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/agent" "git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/agent"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/model" "git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/model"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/tool" "git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/tool"
@@ -128,6 +129,37 @@ func notAllowedReason(name string) string {
return "not_allowed: tool " + name + " is not allowed in a group/stranger conversation" return "not_allowed: tool " + name + " is not allowed in a group/stranger conversation"
} }
// applyDispatcherlessVoiceGate 是 dispatcher 缺席时对 speak 的兜底安全门
// (design/11 §3)。正常接线恒有 dispatcher,安全门由 applyToolSecurityGate
// 走策略引擎;但 speak 由循环自己承载,无 dispatcher 也能执行(invokeOne
// 的短路),若此处不拦,退化配置就会让群聊/陌生人(甚至模型幻觉出的)
// speak 调用真的去合成音频并落盘 —— 绕过身份判定,也白烧一次上游调用。
//
// 口径与策略引擎对未登记工具的处理一致:**默认关闭**,只有主人环境放行。
// 拒绝走与安全门相同的软结果与审计通道(not_allowed + auditToolRejectNotAllowed),
// 使模型看到的原因、前端小字与审计轨迹在不同配置下逐字同源。
func (l *Loop) applyDispatcherlessVoiceGate(ctx context.Context, calls []agent.ToolCall, actions []toolCallAction) {
if !l.voiceEnabled() {
return
}
env := sessionEnvFrom(ctx)
if env.ActorEnv == restrict.ActorOwner {
return
}
for i := range calls {
if !actions[i].run || calls[i].Name != speakToolName {
continue
}
reason := notAllowedReason(calls[i].Name)
actions[i] = toolCallAction{run: false, reason: reason}
l.emitAudit(AuditEvent{
Who: string(env.ActorEnv),
What: auditToolRejectNotAllowed,
Result: reason,
})
}
}
// emitAudit 把 AuditEvent 转发给配置的 OnAudit sink。未接线 // emitAudit 把 AuditEvent 转发给配置的 OnAudit sink。未接线
// sink 时为空操作,因此在未挂审计 sink 的叶子测试中 // sink 时为空操作,因此在未挂审计 sink 的叶子测试中
// 安全门仍能工作(软 not_allowed 结果)。 // 安全门仍能工作(软 not_allowed 结果)。
@@ -8,7 +8,9 @@ import (
"git.yeij.top/AskaEth/Cyrene_v2/internal/capability/registry" "git.yeij.top/AskaEth/Cyrene_v2/internal/capability/registry"
"git.yeij.top/AskaEth/Cyrene_v2/internal/capability/restrict" "git.yeij.top/AskaEth/Cyrene_v2/internal/capability/restrict"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/agent"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/model" "git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/model"
mvoice "git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/multimodal/voice"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/tool" "git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/tool"
) )
@@ -235,6 +237,75 @@ func TestRun_Stranger_NotAllowed_Blocked(t *testing.T) {
} }
} }
// TestApplyToolAdmissions_DispatcherlessSpeakGate 锁住退化配置下的同一道
// 安全门:speak 由循环自己承载,没有 dispatcher 也能执行(invokeOne 的
// 短路),所以「没配 dispatcher」绝不能变成绕过 design/11 §3 的旁路 ——
// 群聊/陌生人的 speak 必须在这里被拦成 not_allowed 软结果并留下审计,
// 主人环境照常放行。
func TestApplyToolAdmissions_DispatcherlessSpeakGate(t *testing.T) {
t.Parallel()
var mu sync.Mutex
var audited []AuditEvent
l := New(Options{
VoiceSynth: &stubSynth{audio: mvoice.Audio{Format: "wav", Data: []byte{1}}, provider: "p"},
VoiceStore: &stubStore{ref: VoiceRef{Name: "a.wav", MIME: "audio/wav", Size: 1}},
OnAudit: func(ev AuditEvent) {
mu.Lock()
audited = append(audited, ev)
mu.Unlock()
},
})
cases := []struct {
env restrict.ActorEnv
allow bool
}{
{restrict.ActorGroupMember, false},
{restrict.ActorStranger, false},
{restrict.ActorOwner, true},
}
for _, tc := range cases {
mu.Lock()
audited = nil
mu.Unlock()
calls := []agent.ToolCall{{Name: speakToolName, Args: map[string]any{"text": "晚安"}}}
actions := []toolCallAction{{run: true}}
l.applyToolAdmissions(WithSessionEnv(context.Background(), SessionEnv{ActorEnv: tc.env}), nil, calls, actions)
if actions[0].run != tc.allow {
t.Fatalf("env %s run = %v, want %v (reason %q)", tc.env, actions[0].run, tc.allow, actions[0].reason)
}
mu.Lock()
got := append([]AuditEvent(nil), audited...)
mu.Unlock()
if tc.allow {
if len(got) != 0 {
t.Fatalf("env %s audited %+v, want none", tc.env, got)
}
continue
}
if !strings.Contains(actions[0].reason, "not_allowed") {
t.Fatalf("env %s reason = %q, want a not_allowed soft result", tc.env, actions[0].reason)
}
if len(got) != 1 || got[0].What != auditToolRejectNotAllowed || got[0].Who != string(tc.env) {
t.Fatalf("env %s audited %+v, want one %s by %s", tc.env, got, auditToolRejectNotAllowed, tc.env)
}
}
// speak 未接线时没有可执行的旁路,门不该动任何调用。
actions := []toolCallAction{{run: true}}
New(Options{}).applyToolAdmissions(
WithSessionEnv(context.Background(), SessionEnv{ActorEnv: restrict.ActorGroupMember}),
nil,
[]agent.ToolCall{{Name: speakToolName}},
actions,
)
if !actions[0].run {
t.Fatalf("unwired speak blocked (reason %q), want untouched", actions[0].reason)
}
}
// mustSetPolicy 把 ToolPolicy 应用到具名工具,出错即让测试失败。 // mustSetPolicy 把 ToolPolicy 应用到具名工具,出错即让测试失败。
func mustSetPolicy(t *testing.T, reg *registry.Registry, name string, pol registry.ToolPolicy) { func mustSetPolicy(t *testing.T, reg *registry.Registry, name string, pol registry.ToolPolicy) {
t.Helper() t.Helper()
+17 -93
View File
@@ -5,12 +5,10 @@ import (
"encoding/json" "encoding/json"
"errors" "errors"
"fmt" "fmt"
"sync"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/agent" "git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/agent"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/model" "git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/model"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/tool" "git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/tool"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/trace"
) )
// Options.ToolExecution 接受的 ToolExecutionMode 取值。为空时选用 // Options.ToolExecution 接受的 ToolExecutionMode 取值。为空时选用
@@ -77,6 +75,9 @@ type toolRunResult struct {
messages []model.Message messages []model.Message
executed int executed int
terminate bool terminate bool
// voice 是本批 speak 调用的结论(M57):nil = 这一批没有要求语音。
// 由 runToolCalls 在结局钩子之后提取,供 run.go 回填 Result.Voice。
voice *OutboundVoice
} }
// toolLoopState 携带关键工具循环安全网的每次 run 状态。 // toolLoopState 携带关键工具循环安全网的每次 run 状态。
@@ -156,6 +157,10 @@ func (l *Loop) runToolCalls(ctx context.Context, messages []model.Message, calls
// 结果落定后的两类观察/改写钩子见 applyToolOutcomeHooks。 // 结果落定后的两类观察/改写钩子见 applyToolOutcomeHooks。
l.applyToolOutcomeHooks(ctx, calls, outcomes) l.applyToolOutcomeHooks(ctx, calls, outcomes)
// 语音结论必须在钩子之后提取:AfterOutcome 可以改写 speak 的结果,
// 出站结论要看**模型最终看到的那份**。
res.voice = l.captureVoice(calls, outcomes)
// 钩子可以把每个调用都标记为 terminate 来要求终止本轮 // 钩子可以把每个调用都标记为 terminate 来要求终止本轮
// (OpenClaw 的 shouldTerminateToolBatch)。 // (OpenClaw 的 shouldTerminateToolBatch)。
if !res.terminate && shouldTerminateBatch(outcomes) { if !res.terminate && shouldTerminateBatch(outcomes) {
@@ -231,11 +236,19 @@ func classifyToolBatch(st *toolLoopState, calls []agent.ToolCall) ([]toolCallAct
// 不被允许的调用绝不执行,以 not_allowed 软结果回灌并记录审计; // 不被允许的调用绝不执行,以 not_allowed 软结果回灌并记录审计;
// 它是该批的最终权威,后续钩子可以收紧但绝不重新放行。② // 它是该批的最终权威,后续钩子可以收紧但绝不重新放行。②
// BatchAdmit 批次钩子(审计 / 政治审查)拦截整批。③ CallAdmit // BatchAdmit 批次钩子(审计 / 政治审查)拦截整批。③ CallAdmit
// 逐调用否决。dispatcher 缺席时安全门跳过(runToolCalls 已保证 // 逐调用否决。
// 此时整批以错误回灌)。 //
// dispatcher 缺席时安全门走不了(它需要 dispatcher 的策略引擎),
// 其余工具由 runToolCalls 保证以「没有 dispatcher」错误回灌;但
// speak 由循环自己承载、无 dispatcher 也能执行(见 invokeOne 的
// 短路),因此这条退化配置下必须由 applyDispatcherlessVoiceGate
// 补上同一道硬门 —— 否则「没配 dispatcher」就成了绕过 design/11 §3
// 的旁路。
func (l *Loop) applyToolAdmissions(ctx context.Context, d *tool.Dispatcher, calls []agent.ToolCall, actions []toolCallAction) { func (l *Loop) applyToolAdmissions(ctx context.Context, d *tool.Dispatcher, calls []agent.ToolCall, actions []toolCallAction) {
if d != nil { if d != nil {
l.applyToolSecurityGate(ctx, d, calls, actions) l.applyToolSecurityGate(ctx, d, calls, actions)
} else {
l.applyDispatcherlessVoiceGate(ctx, calls, actions)
} }
if l.hooks.BatchAdmit != nil { if l.hooks.BatchAdmit != nil {
if ok, reason := l.hooks.BatchAdmit(ctx, calls); !ok { if ok, reason := l.hooks.BatchAdmit(ctx, calls); !ok {
@@ -287,95 +300,6 @@ func (l *Loop) applyToolOutcomeHooks(ctx context.Context, calls []agent.ToolCall
} }
} }
// invokeOne 在其每工具截止时间下执行单个工具调用,发出一个
// tool span、以 tool_execution.start/end 事件流式输出执行进度
// 并归一化结果。执行全程由单一 phase 决策驱动:start 帧带
// running,Invoke 返回后按成败取 done/error,同一取值既决定
// end 帧的 phase,也决定 span 终态(design/19 的 tool 节点
// running/done/error 状态即由此驱动)。
func (l *Loop) invokeOne(ctx context.Context, d *tool.Dispatcher, tc agent.ToolCall) ToolOutcome {
if d == nil {
return ToolOutcome{Err: fmt.Errorf("no tool dispatcher configured for %q", tc.Name), ErrKind: tool.KindExecution}
}
_, toolSpan := l.span(ctx, "tool."+tc.Name, trace.SpanKindTool)
l.emitToolExecution(ctx, tc, model.StreamKindToolExecutionStart, model.ToolPhaseRunning, "")
// 路标识桥(design/15 §2.5.7、主人裁决 3):网关在**每一条在途路**上把互斥
// 身份经 loop.WithToolLegID 挂在 ctx 上(普通回合 / 并行路 / 队首续跑路都挂,
// 取值 = 该路的 turnID),这里把它桥接成工具层的 tool.WithLegID —— 工具侧
// 凭 tool.LegIDFrom 读到「我是哪一路」做文件级互斥;未挂载 ⇒ 空串 ⇒ 完全
// 不做互斥,行为逐字不变(兼容硬要求)。
//
// 刻意**不**读 ParallelLegFrom:那条通道只有真并行路才有、且只决定 checkpoint
// 归属(checkpoint.go 的 checkpointAgentID),拿它当互斥身份会让普通回合在
// 「1 普通回合 + 1 并行路」时完全不受保护。
result, err := d.Invoke(tool.WithLegID(ctx, ToolLegIDFrom(ctx)), tc.Name, tc.Args)
phase := model.ToolPhaseDone
if err != nil {
phase = model.ToolPhaseError
}
l.emitToolExecution(ctx, tc, model.StreamKindToolExecutionEnd, phase, toolEndFrameSummary(result, err))
out := ToolOutcome{Result: result, Err: err, ErrKind: errorKind(err)}
l.spanProp(toolSpan, "phase", phase)
l.recordToolDone(toolSpan, tc, out)
if err != nil {
l.spanProp(toolSpan, "error", err.Error())
l.spanProp(toolSpan, "error_kind", out.ErrKind)
} else {
recordToolResult(toolSpan, result)
}
l.finishSpan(toolSpan, spanStatusForToolPhase(phase))
return out
}
// emitToolExecution 在循环处于流式路径(配置了 OnStream)时把
// tool_execution 三事件(start / update / end)转发给前端;否则为
// 空操作(被静音的内部流程也会跳过它)。phase 是结构化字段
// (running/done/error),绝不塞进 Delta;工具名走 Tool 字段,
// ID 复用 provider 的 tool call id,使执行帧能与其 tool_call.* 帧关联;
// resultSummary 只在 end 帧、且只在结果真正拿到时非空(口径见 tool_frame_summary.go)。
func (l *Loop) emitToolExecution(ctx context.Context, tc agent.ToolCall, kind model.StreamKind, phase, resultSummary string) {
if l.onStream == nil || isMutedBroadcast(ctx) {
return
}
ev := model.StreamEvent{Kind: kind, Phase: phase, Tool: tc.Name, ID: tc.ID, ResultSummary: resultSummary}
ev.SessionID = loopSession(ctx)
ev.TurnID = loopTurn(ctx)
l.onStream(ev)
}
// spanStatusForToolPhase 把工具执行的终态 phase 映射为 span 终态,
// 使 trace/流程图与 WS 帧从同一个 phase 取值得出一致结论。
// 非终态 phase(running)不会走到这里 —— span 只在执行结束时收尾。
func spanStatusForToolPhase(phase string) trace.SpanStatus {
if phase == model.ToolPhaseError {
return trace.SpanStatusError
}
return trace.SpanStatusOK
}
// invokeParallel 并发运行 idxs 处的调用,上限为循环的
// 并行度,并把每个结果写回 out 的对应调用索引处,使调用方
// 能按原始顺序渲染结果。信号量限制并发,在途
// goroutine 完成时释放其槽位,因此被取消的 context
// (它使每个 Invoke 迅速返回)不会泄露 goroutine。
func (l *Loop) invokeParallel(ctx context.Context, d *tool.Dispatcher, calls []agent.ToolCall, idxs []int, out []ToolOutcome) {
if len(idxs) == 0 {
return
}
sem := make(chan struct{}, l.toolParallelism)
var wg sync.WaitGroup
for _, i := range idxs {
wg.Add(1)
sem <- struct{}{}
go func(i int) {
defer wg.Done()
defer func() { <-sem }()
out[i] = l.invokeOne(ctx, d, calls[i])
}(i)
}
wg.Wait()
}
// 由 classifyToolBatch 为两种跳过原因返回。 // 由 classifyToolBatch 为两种跳过原因返回。
func duplicateCallReason() string { func duplicateCallReason() string {
return "duplicate call with identical arguments already issued in this batch" return "duplicate call with identical arguments already issued in this batch"
+107
View File
@@ -0,0 +1,107 @@
package loop
// 本文件承载**单个/一批工具调用的执行**(span、流式帧、结果归一化、
// 有界并行),与 tools.go 的分类、准入与回渲职责分开 —— 从 tools.go
// 抽出,使两边都留在 500 行以内(AGENTS.md §10)。
import (
"context"
"fmt"
"sync"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/agent"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/model"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/tool"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/trace"
)
// invokeOne 在其每工具截止时间下执行单个工具调用,发出一个
// tool span、以 tool_execution.start/end 事件流式输出执行进度
// 并归一化结果。执行全程由单一 phase 决策驱动:start 帧带
// running,Invoke 返回后按成败取 done/error,同一取值既决定
// end 帧的 phase,也决定 span 终态(design/19 的 tool 节点
// running/done/error 状态即由此驱动)。
func (l *Loop) invokeOne(ctx context.Context, d *tool.Dispatcher, tc agent.ToolCall) ToolOutcome {
// speak 由循环自己承载(见 invokeTool),没有 dispatcher 也能执行;其余
// 工具缺 dispatcher 时仍按原样短路,不为此多开 span 或流式帧。
if d == nil && !(l.voiceEnabled() && tc.Name == speakToolName) {
return ToolOutcome{Err: fmt.Errorf("no tool dispatcher configured for %q", tc.Name), ErrKind: tool.KindExecution}
}
_, toolSpan := l.span(ctx, "tool."+tc.Name, trace.SpanKindTool)
l.emitToolExecution(ctx, tc, model.StreamKindToolExecutionStart, model.ToolPhaseRunning, "")
// 分派见 invokeTool:路标识桥(tool.WithLegID)与 speak 的循环内承载都在
// 那里。路标识(design/15 §2.5.7、主人裁决 3):网关在**每一条在途路**上把
// 互斥身份经 loop.WithToolLegID 挂在 ctx 上(普通回合 / 并行路 / 队首续跑路
// 都挂,取值 = 该路的 turnID),工具层凭 tool.LegIDFrom 读到「我是哪一路」
// 做文件级互斥;未挂载 ⇒ 空串 ⇒ 完全不做互斥,行为逐字不变(兼容硬要求)。
//
// 刻意**不**读 ParallelLegFrom:那条通道只有真并行路才有、且只决定 checkpoint
// 归属(checkpoint.go 的 checkpointAgentID),拿它当互斥身份会让普通回合在
// 「1 普通回合 + 1 并行路」时完全不受保护。
result, err := l.invokeTool(ctx, d, tc)
phase := model.ToolPhaseDone
if err != nil {
phase = model.ToolPhaseError
}
l.emitToolExecution(ctx, tc, model.StreamKindToolExecutionEnd, phase, toolEndFrameSummary(result, err))
out := ToolOutcome{Result: result, Err: err, ErrKind: errorKind(err)}
l.spanProp(toolSpan, "phase", phase)
l.recordToolDone(toolSpan, tc, out)
if err != nil {
l.spanProp(toolSpan, "error", err.Error())
l.spanProp(toolSpan, "error_kind", out.ErrKind)
} else {
recordToolResult(toolSpan, result)
}
l.finishSpan(toolSpan, spanStatusForToolPhase(phase))
return out
}
// emitToolExecution 在循环处于流式路径(配置了 OnStream)时把
// tool_execution 三事件(start / update / end)转发给前端;否则为
// 空操作(被静音的内部流程也会跳过它)。phase 是结构化字段
// (running/done/error),绝不塞进 Delta;工具名走 Tool 字段,
// ID 复用 provider 的 tool call id,使执行帧能与其 tool_call.* 帧关联;
// resultSummary 只在 end 帧、且只在结果真正拿到时非空(口径见 tool_frame_summary.go)。
func (l *Loop) emitToolExecution(ctx context.Context, tc agent.ToolCall, kind model.StreamKind, phase, resultSummary string) {
if l.onStream == nil || isMutedBroadcast(ctx) {
return
}
ev := model.StreamEvent{Kind: kind, Phase: phase, Tool: tc.Name, ID: tc.ID, ResultSummary: resultSummary}
ev.SessionID = loopSession(ctx)
ev.TurnID = loopTurn(ctx)
l.onStream(ev)
}
// spanStatusForToolPhase 把工具执行的终态 phase 映射为 span 终态,
// 使 trace/流程图与 WS 帧从同一个 phase 取值得出一致结论。
// 非终态 phase(running)不会走到这里 —— span 只在执行结束时收尾。
func spanStatusForToolPhase(phase string) trace.SpanStatus {
if phase == model.ToolPhaseError {
return trace.SpanStatusError
}
return trace.SpanStatusOK
}
// invokeParallel 并发运行 idxs 处的调用,上限为循环的
// 并行度,并把每个结果写回 out 的对应调用索引处,使调用方
// 能按原始顺序渲染结果。信号量限制并发,在途
// goroutine 完成时释放其槽位,因此被取消的 context
// (它使每个 Invoke 迅速返回)不会泄露 goroutine。
func (l *Loop) invokeParallel(ctx context.Context, d *tool.Dispatcher, calls []agent.ToolCall, idxs []int, out []ToolOutcome) {
if len(idxs) == 0 {
return
}
sem := make(chan struct{}, l.toolParallelism)
var wg sync.WaitGroup
for _, i := range idxs {
wg.Add(1)
sem <- struct{}{}
go func(i int) {
defer wg.Done()
defer func() { <-sem }()
out[i] = l.invokeOne(ctx, d, calls[i])
}(i)
}
wg.Wait()
}
+172
View File
@@ -0,0 +1,172 @@
package loop
// 本文件是 speak 的接线面(M57):两个注入依赖的接口、接线判定、
// 以及「本回合的出站语音结论」从工具结局到 Result.Voice 的提取。
// 它们刻意留在循环里而不是工具包里 —— speak 需要本回合的人格与
// 预算口径,而这两样只有循环持有。
import (
"context"
"errors"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/agent"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/model"
mvoice "git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/multimodal/voice"
)
// VoiceSynth 是 speak 需要的合成能力(*tts.Service 天然满足)。
// 接口收窄到**一个方法**:循环只需要「给定文本拿回音频与提供者」,
// 发音人解析、降级链与配额全在实现内部,循环不重复实现它们。
type VoiceSynth interface {
// TTSWithProvider 合成文本并返回音频与**实际**提供者标识
// (降级后是备用的那个),供日志与可观测性使用。
TTSWithProvider(ctx context.Context, text string) (mvoice.Audio, string, error)
}
// VoiceRef 是音频副本落盘后的交付描述:循环只需要「叫什么、什么类型、
// 多大」,与存储层的哈希/去重标记/磁盘路径无关。单独定义而不复用
// documents.Ref 是为了让循环不 import documents 包 —— 该包的测试会
// import 循环,反向依赖构成测试期的 import cycle。
type VoiceRef struct {
// Name 是服务端存储名(客户端凭它取回音频)。
Name string
// MIME 是存储层字节嗅探所得,不是合成侧的声明。
MIME string
// Size 是该副本的字节数。
Size int64
}
// VoiceStore 是 speak 需要的落盘能力。只要一个方法:音频副本必须走文档
// 存储的内容寻址 + 权限口径,而不是循环自己写文件。*documents.Store 经
// VoiceStoreFunc 适配即可接上(见该类型的用法说明)。
type VoiceStore interface {
// SaveAudio 落盘一份音频副本并返回其交付描述。
SaveAudio(ctx context.Context, origName string, data []byte) (VoiceRef, error)
}
// VoiceStoreFunc 把函数适配为 VoiceStore(与 ImageResolveFunc 同一惯例)。
// 接线方用它把存储层接到本包的接缝上,避免循环反向依赖 documents:
//
// loop.VoiceStoreFunc(func(ctx context.Context, name string, data []byte) (loop.VoiceRef, error) {
// ref, err := docs.SaveAudio(ctx, name, data)
// return loop.VoiceRef{Name: ref.Name, MIME: ref.MIME, Size: ref.Size}, err
// })
type VoiceStoreFunc func(ctx context.Context, origName string, data []byte) (VoiceRef, error)
// SaveAudio 实现 VoiceStore。
func (f VoiceStoreFunc) SaveAudio(ctx context.Context, origName string, data []byte) (VoiceRef, error) {
return f(ctx, origName, data)
}
// voiceEnabled 报告 speak 是否已接线。两个依赖**都**非 nil 才算 ——
// 只接了合成却没有存储时,调用必然在落盘一步失败,不如干脆不广告,
// 让模型面与接线前逐字节一致。
func (l *Loop) voiceEnabled() bool {
return l != nil && l.voiceSynth != nil && l.voiceStore != nil
}
// withSpeakSpec 把 speak 追加到模型面的工具声明末尾。它由 toolSchemas
// 在**所有收缩之后**调用,因此 speak 不受能力允许集(quiet/awake)与
// 技能轮次的 allowed-tools 裁剪 —— speak 不在任何内置 toolset 里,
// 走那些过滤就等于永远不广告。代价(群聊/陌生人也能看到 speak)由
// 执行侧的工具安全门兜住:speak 未注册进能力登记表,非主人环境下
// Policy.Allow 以「未知工具」拒绝,fail-closed。
func (l *Loop) withSpeakSpec(schemas []model.ToolSpec) []model.ToolSpec {
spec := l.speakSpec()
if spec == nil {
return schemas
}
for i := range schemas {
if schemas[i].Name == spec.Name {
return schemas
}
}
out := make([]model.ToolSpec, 0, len(schemas)+1)
out = append(out, schemas...)
return append(out, *spec)
}
// applyBatchVoice 把一批工具调用的语音结论回填进本回合的 Result。
// 后面批次里没有 speak 时保留前面那批的结论 —— 「这条回复想用语音」
// 发生在哪一批都算数。
//
// 跨批也守 captureVoice 的同一条优先级:**已落盘的音频绝不被后续批次的
// 失败抹掉**。同一批里由 captureVoice 自己保证(成功优先于失败),但批次
// 之间是两次独立结论,若这里无条件覆盖,则「先成功合成 abc.wav、下一轮
// 换个参数再试却失败」会把交付物退化成 tts_failed —— 音频已经存在,退回
// 文本既没有收益也违背她那次表达的意愿。反过来(先降级后成功)仍然覆盖:
// 后到的成功就是这条消息最终的语音。
func applyBatchVoice(res *Result, batch *toolRunResult) {
if batch.voice == nil {
return
}
if res.Voice != nil && res.Voice.Name != "" && batch.voice.Name == "" {
return
}
res.Voice = batch.voice
}
// captureVoice 从一批调用的结局里提取本回合的出站语音结论。返回 nil
// 表示「这一批没有要求语音」,Result.Voice 因此保持 nil,客户端按纯
// 文本呈现 —— 与没有 speak 之前逐字节一致。
//
// 多个 speak 调用时取**最后一个成功的**:成功意味着音频真的落了盘,
// 后续的重试即使失败也不该让已经交付的音频消失。全都失败时取最后
// 一个失败的原因,使客户端仍能区分「想用语音但没成」与「本来就用
// 文本」。
func (l *Loop) captureVoice(calls []agent.ToolCall, outcomes []ToolOutcome) *OutboundVoice {
var lastOK, lastFail *OutboundVoice
n := len(calls)
if len(outcomes) < n {
n = len(outcomes)
}
for i := 0; i < n; i++ {
if calls[i].Name != speakToolName || outcomes[i].Skipped {
continue
}
if outcomes[i].Err == nil {
lastOK = synthesizedVoice(outcomes[i].Result)
continue
}
lastFail = degradedVoice(outcomes[i].Err)
}
if lastOK != nil {
return lastOK
}
return lastFail
}
// synthesizedVoice 把 speak 成功时的结构化结果翻成 OutboundVoice。
// 结果被钩子改写成非对象时返回 nil:拿不到存储名就播放不了,此时
// 按文本呈现是唯一诚实的选择(DurationMs 保持 0 = 未解析)。
func synthesizedVoice(result any) *OutboundVoice {
m, ok := result.(map[string]any)
if !ok {
return nil
}
v := &OutboundVoice{Requested: true}
v.Name, _ = m["name"].(string)
v.Mime, _ = m["mime"].(string)
v.Size, _ = m["size"].(int64)
v.DurationMs, _ = m["durationMs"].(int64)
v.CostMs, _ = m["costMs"].(int)
v.BudgetMs, _ = m["budgetMs"].(int)
return v
}
// degradedVoice 把 speak 的失败翻成「想用语音但退回了文本」。原因码
// 优先取结构化错误的 Code(超时/无音频/落盘失败/文本过长),非结构化
// 失败一律归 tts_failed,绝不把空原因码交给客户端。
func degradedVoice(err error) *OutboundVoice {
v := &OutboundVoice{Requested: true, FallbackReason: speakReasonFailed}
var se *speakError
if !errors.As(err, &se) {
return v
}
if se.Code != "" {
v.FallbackReason = se.Code
}
v.BudgetMs = se.BudgetMs
v.CostMs = se.CostMs
return v
}
+296
View File
@@ -0,0 +1,296 @@
package loop
import (
"context"
"errors"
"testing"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/agent"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/model"
mvoice "git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/multimodal/voice"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/tool"
)
// 本文件锁住「speak 调用 → Result.Voice」这条出站链(M57):
// 成功、降级、没调三态在叶子层,加上一次端到端回合,证明模型面真的
// 被广告了 speak、调用真的被执行、结论真的落在 Result 上。
// TestCaptureVoice_States 锁住三态:成功带交付物、失败带原因码与
// 预算/耗时、没调(或只调了别的工具)返回 nil。
func TestCaptureVoice_States(t *testing.T) {
t.Parallel()
l := New(Options{})
if v := l.captureVoice(
[]agent.ToolCall{{Name: "terminal"}},
[]ToolOutcome{{Result: "ok"}},
); v != nil {
t.Fatalf("non-speak batch voice = %+v, want nil", v)
}
ok := l.captureVoice(
[]agent.ToolCall{{Name: speakToolName}},
[]ToolOutcome{{Result: map[string]any{
"name": "abc.wav", "mime": "audio/wav", "size": int64(9),
"durationMs": int64(0), "budgetMs": 3025, "costMs": 120,
}}},
)
if ok == nil || !ok.Requested {
t.Fatalf("success voice = %+v, want Requested", ok)
}
if ok.Name != "abc.wav" || ok.Mime != "audio/wav" || ok.Size != 9 {
t.Fatalf("success voice = %+v, want the stored ref", ok)
}
if ok.BudgetMs != 3025 || ok.CostMs != 120 || ok.DurationMs != 0 {
t.Fatalf("success voice = %+v, want budget/cost and unparsed duration", ok)
}
if ok.FallbackReason != "" {
t.Fatalf("success FallbackReason = %q, want empty", ok.FallbackReason)
}
bad := l.captureVoice(
[]agent.ToolCall{{Name: speakToolName}},
[]ToolOutcome{{Err: &speakError{Code: speakReasonTimeout, BudgetMs: 3000, CostMs: 3100}}},
)
if bad == nil || !bad.Requested {
t.Fatalf("failure voice = %+v, want Requested", bad)
}
if bad.FallbackReason != speakReasonTimeout || bad.BudgetMs != 3000 || bad.CostMs != 3100 {
t.Fatalf("failure voice = %+v, want the timeout code and budget/cost", bad)
}
if bad.Name != "" {
t.Fatalf("failure name = %q, want empty(没有交付物)", bad.Name)
}
// 非结构化失败也要有原因码,绝不把空串交给客户端。
raw := l.captureVoice(
[]agent.ToolCall{{Name: speakToolName}},
[]ToolOutcome{{Err: errors.New("boom")}},
)
if raw == nil || raw.FallbackReason != speakReasonFailed {
t.Fatalf("raw failure voice = %+v, want %q", raw, speakReasonFailed)
}
}
// TestCaptureVoice_LastSuccessWins 锁住多次 speak 的取用规则:成功优先
// 于失败(音频已经落盘就交付),同类取最后一个(这条消息最后的意愿)。
func TestCaptureVoice_LastSuccessWins(t *testing.T) {
t.Parallel()
l := New(Options{})
calls := []agent.ToolCall{{Name: speakToolName}, {Name: speakToolName}, {Name: speakToolName}}
// 先成功、后失败:交付已落盘的那份。
got := l.captureVoice(calls[:2], []ToolOutcome{
{Result: map[string]any{"name": "first.wav", "mime": "audio/wav", "size": int64(1)}},
{Err: &speakError{Code: speakReasonFailed}},
})
if got == nil || got.Name != "first.wav" {
t.Fatalf("voice = %+v, want first.wav(成功优先于后续失败)", got)
}
// 两次都成功:取后者。
got = l.captureVoice(calls[:2], []ToolOutcome{
{Result: map[string]any{"name": "first.wav"}},
{Result: map[string]any{"name": "second.wav"}},
})
if got == nil || got.Name != "second.wav" {
t.Fatalf("voice = %+v, want second.wav(后者覆盖前者)", got)
}
// 被跳过的调用不算数。
if v := l.captureVoice(calls[:1], []ToolOutcome{{Skipped: true, Reason: "duplicate"}}); v != nil {
t.Fatalf("skipped voice = %+v, want nil", v)
}
}
// TestRun_VoiceResult 是一次端到端回合:speak 出现在模型面里、被调用
// 后真的走了注入的合成与落盘,结论落在 Result.Voice 上。
func TestRun_VoiceResult(t *testing.T) {
t.Parallel()
call := model.Response{Model: "m", ToolCalls: []model.ToolCall{{
ID: "call_speak_1",
Name: speakToolName,
Args: map[string]any{"text": "晚安", "emotion": "温柔"},
}}}
ad := &mockAdapter{stream: []mgResp{{resp: call}, {resp: model.Response{Model: "m", Content: "晚安"}}}}
td := dispatcherWith(t, &mockTool{name: "terminal", result: "ok"})
synth := &stubSynth{audio: mvoice.Audio{Format: "wav", Data: []byte{1, 2, 3}}, provider: "emotiontts"}
store := &stubStore{ref: VoiceRef{Name: "abc.wav", MIME: "audio/wav", Size: 3}}
l := New(Options{
Agent: newTestAgent(t, ad, td),
MaxRounds: 8,
OnStream: func(model.StreamEvent) {},
VoiceSynth: synth,
VoiceStore: store,
})
res, err := l.Run(context.Background(), "sess", []model.Message{model.TextMessage(model.RoleUser, "说晚安")})
if err != nil {
t.Fatalf("Run: %v", err)
}
if res.ToolCallsExecuted != 1 {
t.Fatalf("ToolCallsExecuted = %d, want 1", res.ToolCallsExecuted)
}
// ① 模型面确实被广告了 speak(否则模型不可能调它)。
if len(ad.streamReqs) == 0 {
t.Fatal("no streaming request captured")
}
if set := toolSet(ad.streamReqs[0].Tools); !set[speakToolName] {
t.Fatalf("advertised tools = %v, want %q", toolNames(ad.streamReqs[0].Tools), speakToolName)
}
// ② 调用真的走了注入的合成与落盘。
if synth.gotText != "晚安" {
t.Fatalf("synth text = %q, want 晚安", synth.gotText)
}
if store.gotName != "speak.wav" {
t.Fatalf("origName = %q, want speak.wav", store.gotName)
}
// ③ 结论落在 Result.Voice 上。
if res.Voice == nil {
t.Fatal("Result.Voice = nil, want the synthesized audio")
}
if !res.Voice.Requested || res.Voice.Name != "abc.wav" || res.Voice.Mime != "audio/wav" || res.Voice.Size != 3 {
t.Fatalf("Result.Voice = %+v, want the stored ref", res.Voice)
}
if res.Voice.FallbackReason != "" {
t.Fatalf("FallbackReason = %q, want empty", res.Voice.FallbackReason)
}
}
// TestRun_VoiceDegraded 锁住端到端的降级:合成失败时回合照常结束
// (绝不因语音失败而失败),Result.Voice 带上原因码让客户端特殊显示。
func TestRun_VoiceDegraded(t *testing.T) {
t.Parallel()
call := model.Response{Model: "m", ToolCalls: []model.ToolCall{{
ID: "call_speak_2",
Name: speakToolName,
Args: map[string]any{"text": "晚安"},
}}}
ad := &mockAdapter{stream: []mgResp{{resp: call}, {resp: model.Response{Model: "m", Content: "晚安"}}}}
l := New(Options{
Agent: newTestAgent(t, ad, dispatcherWith(t)),
MaxRounds: 8,
OnStream: func(model.StreamEvent) {},
VoiceSynth: &stubSynth{err: errors.New("upstream down")},
VoiceStore: &stubStore{},
})
res, err := l.Run(context.Background(), "sess", []model.Message{model.TextMessage(model.RoleUser, "说晚安")})
if err != nil {
t.Fatalf("Run: %v", err)
}
if res.Text != "晚安" {
t.Fatalf("Text = %q, want the text answer(语音失败不得拖垮回合)", res.Text)
}
if res.Voice == nil || res.Voice.FallbackReason != speakReasonFailed {
t.Fatalf("Result.Voice = %+v, want %q", res.Voice, speakReasonFailed)
}
if res.Voice.Requested != true {
t.Fatalf("Result.Voice.Requested = %v, want true(确实想用语音)", res.Voice.Requested)
}
}
// TestRun_NoSpeakNoVoice 锁住「不接线就没有语音面」:未注入依赖时
// 模型面里没有 speak,Result.Voice 保持 nil —— 与接线前逐字节一致。
func TestRun_NoSpeakNoVoice(t *testing.T) {
t.Parallel()
ad := &mockAdapter{stream: []mgResp{{resp: model.Response{Model: "m", Content: "晚安"}}}}
l := New(Options{
Agent: newTestAgent(t, ad, dispatcherWith(t, &mockTool{name: "terminal", result: "ok"})),
MaxRounds: 8,
OnStream: func(model.StreamEvent) {},
})
res, err := l.Run(context.Background(), "sess", []model.Message{model.TextMessage(model.RoleUser, "说晚安")})
if err != nil {
t.Fatalf("Run: %v", err)
}
if res.Voice != nil {
t.Fatalf("Result.Voice = %+v, want nil", res.Voice)
}
if len(ad.streamReqs) == 0 {
t.Fatal("no streaming request captured")
}
if set := toolSet(ad.streamReqs[0].Tools); set[speakToolName] {
t.Fatalf("advertised tools = %v, must not contain %q", toolNames(ad.streamReqs[0].Tools), speakToolName)
}
}
// TestInvokeTool_SpeakWithoutDispatcher 锁住 speak 不依赖 dispatcher:
// 未接线时普通工具仍按原样报「没有 dispatcher」,接线后 speak 照常执行。
func TestInvokeTool_SpeakWithoutDispatcher(t *testing.T) {
t.Parallel()
wired := New(Options{
VoiceSynth: &stubSynth{audio: mvoice.Audio{Format: "wav", Data: []byte{1}}, provider: "p"},
VoiceStore: &stubStore{ref: VoiceRef{Name: "a.wav", MIME: "audio/wav", Size: 1}},
})
out := wired.invokeOne(context.Background(), nil, agent.ToolCall{
Name: speakToolName, Args: map[string]any{"text": "晚安"},
})
if out.Err != nil {
t.Fatalf("speak without dispatcher err = %v, want nil", out.Err)
}
plain := New(Options{})
out = plain.invokeOne(context.Background(), nil, agent.ToolCall{Name: "terminal"})
if out.Err == nil || out.ErrKind != tool.KindExecution {
t.Fatalf("nil dispatcher outcome = %+v, want an execution error", out)
}
}
// TestApplyBatchVoice_KeepsCrossBatchSuccess 锁住跨批的语音优先级:批次之间
// 是两次独立结论,后一批换个参数失败时**绝不能**把前一批已落盘的音频抹成
// tts_failed —— 交付物已经存在,退回文本既没有收益也违背她那次表达的意愿。
// 反过来(先降级后成功)后到的成功仍然覆盖;后一批没有 speak 时保留结论。
func TestApplyBatchVoice_KeepsCrossBatchSuccess(t *testing.T) {
t.Parallel()
delivered := &OutboundVoice{Requested: true, Name: "abc.wav", Mime: "audio/wav", Size: 3}
degraded := &OutboundVoice{Requested: true, FallbackReason: speakReasonFailed}
res := &Result{}
applyBatchVoice(res, &toolRunResult{voice: delivered})
applyBatchVoice(res, &toolRunResult{voice: degraded})
if res.Voice == nil || res.Voice.Name != "abc.wav" || res.Voice.FallbackReason != "" {
t.Fatalf("success then cross-batch failure voice = %+v, want the delivered abc.wav", res.Voice)
}
// 先降级后成功:后到的成功就是这条消息最终的语音。
res = &Result{}
applyBatchVoice(res, &toolRunResult{voice: degraded})
applyBatchVoice(res, &toolRunResult{voice: delivered})
if res.Voice == nil || res.Voice.Name != "abc.wav" || res.Voice.FallbackReason != "" {
t.Fatalf("degraded then success voice = %+v, want abc.wav", res.Voice)
}
// 后一批没有 speak:前面那批的结论照旧算数。
res = &Result{}
applyBatchVoice(res, &toolRunResult{voice: delivered})
applyBatchVoice(res, &toolRunResult{})
if res.Voice == nil || res.Voice.Name != "abc.wav" {
t.Fatalf("batch without voice = %+v, want the earlier abc.wav", res.Voice)
}
}
// TestVoiceStoreFunc 锁住接线方用的函数适配器:它满足 VoiceStore,且
// 原样转发原始名与字节(接线方正是靠它把 documents.Store 接到接缝上,
// 而不让循环反向 import documents)。
func TestVoiceStoreFunc(t *testing.T) {
t.Parallel()
var gotName string
var gotData []byte
var store VoiceStore = VoiceStoreFunc(func(_ context.Context, origName string, data []byte) (VoiceRef, error) {
gotName, gotData = origName, data
return VoiceRef{Name: "saved.wav", MIME: "audio/wav", Size: int64(len(data))}, nil
})
ref, err := store.SaveAudio(context.Background(), "speak.wav", []byte{7, 8})
if err != nil {
t.Fatalf("SaveAudio: %v", err)
}
if gotName != "speak.wav" || len(gotData) != 2 {
t.Fatalf("forwarded (%q, %d bytes), want (speak.wav, 2 bytes)", gotName, len(gotData))
}
if ref.Name != "saved.wav" || ref.Size != 2 {
t.Fatalf("ref = %+v, want the adapter's own result", ref)
}
}
+3 -18
View File
@@ -32,9 +32,6 @@ const (
// SourcePlatform 表示渠道平台自带转写(例如微信 iLink 的 `voice_item.text`): // SourcePlatform 表示渠道平台自带转写(例如微信 iLink 的 `voice_item.text`):
// 平台把语音转成文字后送达,我们**不调 ASR**(不重复付费)。 // 平台把语音转成文字后送达,我们**不调 ASR**(不重复付费)。
SourcePlatform Source = "platform" SourcePlatform Source = "platform"
// SourceTTS 表示正文是我们自己合成的语音(出站方向,design/25 §7.1):成功时
// Name 指向那份可播副本,降级时 Status=failed 且正文照发文本、只带降级原因。
SourceTTS Source = "tts"
) )
// Status 是转写结果的**三态闭集**(design/24 §10.4)。客户端按它分支,**绝不按正文 // Status 是转写结果的**三态闭集**(design/24 §10.4)。客户端按它分支,**绝不按正文
@@ -42,8 +39,7 @@ const (
type Status string type Status string
const ( const (
// StatusTranscribed 表示正文即转写文本(真转写,或平台自带转写);出站方向表示 // StatusTranscribed 表示正文即转写文本(真转写,或平台自带转写)。
// **合成成功**(正文即这段语音的文字内容,Name 指向可播副本)。
StatusTranscribed Status = "transcribed" StatusTranscribed Status = "transcribed"
// StatusEmpty 表示转写成功但没识别出文字(静音 / 太短 / 全是噪声)——是服务正常 // StatusEmpty 表示转写成功但没识别出文字(静音 / 太短 / 全是噪声)——是服务正常
// 的空结果,**不是**故障。 // 的空结果,**不是**故障。
@@ -89,10 +85,6 @@ type Ref struct {
// DurationMs 是时长,**只有渠道如实给出或转码产物可确证时**才 >0;读不出来就是 0 // DurationMs 是时长,**只有渠道如实给出或转码产物可确证时**才 >0;读不出来就是 0
// (编码时该键缺席)。**绝不估算**(design/24 §10.4)。 // (编码时该键缺席)。**绝不估算**(design/24 §10.4)。
DurationMs int64 `json:"durationMs,omitempty"` DurationMs int64 `json:"durationMs,omitempty"`
// FallbackReason 只在**出站合成降级**时非空(Source=tts、Status=failed):说明为什么
// 这条消息没能变成语音(能力未接线 / 预算超时 / 合成为空)。入站转写不使用它 ——
// 入站的失败原因由正文那行诚实提示承载。
FallbackReason string `json:"fallbackReason,omitempty"`
} }
// IsVoice 报告这条事实是否代表「这条消息来自一条语音消息」(= 来源与状态都在闭集内)。 // IsVoice 报告这条事实是否代表「这条消息来自一条语音消息」(= 来源与状态都在闭集内)。
@@ -118,11 +110,6 @@ func (r Ref) Validate() error {
if r.DurationMs < 0 { if r.DurationMs < 0 {
return fmt.Errorf("%w: negative durationMs %d", ErrBadRef, r.DurationMs) return fmt.Errorf("%w: negative durationMs %d", ErrBadRef, r.DurationMs)
} }
// 降级原因只属于失败态:成功还带原因会让人以为「其实降级了」,客户端也就无从按
// status 单点分支(design/24 §10.4 的「客户端绝不按文案分支」同一条纪律)。
if r.FallbackReason != "" && r.Status != StatusFailed {
return fmt.Errorf("%w: fallbackReason with status %q", ErrBadRef, r.Status)
}
switch { switch {
case r.Name == "" && (r.MIME != "" || r.Size != 0): case r.Name == "" && (r.MIME != "" || r.Size != 0):
return fmt.Errorf("%w: mime/size without name", ErrBadRef) return fmt.Errorf("%w: mime/size without name", ErrBadRef)
@@ -137,7 +124,7 @@ func (r Ref) Validate() error {
// IsZero 报告这条事实是零值(= 这条消息不是语音消息)。 // IsZero 报告这条事实是零值(= 这条消息不是语音消息)。
func (r Ref) IsZero() bool { func (r Ref) IsZero() bool {
return r.Source == "" && r.Status == "" && r.Name == "" && r.MIME == "" && return r.Source == "" && r.Status == "" && r.Name == "" && r.MIME == "" &&
r.Size == 0 && r.DurationMs == 0 && r.FallbackReason == "" r.Size == 0 && r.DurationMs == 0
} }
// Encode 把一条语音事实编码成落库用的 JSON 单对象。零值返回 ""(⇒ 列里空串 ⇒ DTO 里 // Encode 把一条语音事实编码成落库用的 JSON 单对象。零值返回 ""(⇒ 列里空串 ⇒ DTO 里
@@ -180,9 +167,7 @@ func Decode(raw string) (Ref, error) {
} }
// validSource / validStatus 是闭集判定,只此一处(编码、解码与渲染共用)。 // validSource / validStatus 是闭集判定,只此一处(编码、解码与渲染共用)。
func validSource(s Source) bool { func validSource(s Source) bool { return s == SourceASR || s == SourcePlatform }
return s == SourceASR || s == SourcePlatform || s == SourceTTS
}
func validStatus(s Status) bool { func validStatus(s Status) bool {
return s == StatusTranscribed || s == StatusEmpty || s == StatusFailed return s == StatusTranscribed || s == StatusEmpty || s == StatusFailed
-43
View File
@@ -31,14 +31,6 @@ func TestEncodeDecodeRoundTrip(t *testing.T) {
Source: SourceASR, Status: StatusEmpty, Name: "0123456789abcdef0123456789abcdef.mp3", Source: SourceASR, Status: StatusEmpty, Name: "0123456789abcdef0123456789abcdef.mp3",
MIME: "audio/mpeg", Size: 4321, MIME: "audio/mpeg", Size: 4321,
}}, }},
{"出站合成成功 + 可播副本", Ref{
Source: SourceTTS, Status: StatusTranscribed,
Name: "fedcba9876543210fedcba9876543210.ogg", MIME: "audio/ogg",
Size: 8192, DurationMs: 1500,
}},
{"出站合成降级(正文照发文本)", Ref{
Source: SourceTTS, Status: StatusFailed, FallbackReason: "budget_exceeded",
}},
} }
for _, tc := range cases { for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) { t.Run(tc.name, func(t *testing.T) {
@@ -127,11 +119,6 @@ func TestEncodeRejectsBrokenRef(t *testing.T) {
{"有 name 无 mime", Ref{Source: SourceASR, Status: StatusTranscribed, Name: "x.ogg", Size: 12}}, {"有 name 无 mime", Ref{Source: SourceASR, Status: StatusTranscribed, Name: "x.ogg", Size: 12}},
{"有 name 无 size", Ref{Source: SourceASR, Status: StatusTranscribed, Name: "x.ogg", MIME: "audio/ogg"}}, {"有 name 无 size", Ref{Source: SourceASR, Status: StatusTranscribed, Name: "x.ogg", MIME: "audio/ogg"}},
{"时长为负", Ref{Source: SourceASR, Status: StatusTranscribed, DurationMs: -1}}, {"时长为负", Ref{Source: SourceASR, Status: StatusTranscribed, DurationMs: -1}},
{"降级原因挂在成功态", Ref{
Source: SourceTTS, Status: StatusTranscribed,
Name: "x.ogg", MIME: "audio/ogg", Size: 12, FallbackReason: "budget_exceeded",
}},
{"只有降级原因", Ref{FallbackReason: "budget_exceeded"}},
} }
for _, tc := range cases { for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) { t.Run(tc.name, func(t *testing.T) {
@@ -162,7 +149,6 @@ func TestDecodeRejectsGarbage(t *testing.T) {
{"闭集外的来源", `{"source":"cloud","status":"transcribed"}`}, {"闭集外的来源", `{"source":"cloud","status":"transcribed"}`},
{"闭集外的状态", `{"source":"asr","status":"done"}`}, {"闭集外的状态", `{"source":"asr","status":"done"}`},
{"副本字段不同生共死", `{"source":"asr","status":"transcribed","size":9}`}, {"副本字段不同生共死", `{"source":"asr","status":"transcribed","size":9}`},
{"降级原因挂在成功态", `{"source":"tts","status":"transcribed","fallbackReason":"x"}`},
} }
for _, tc := range cases { for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) { t.Run(tc.name, func(t *testing.T) {
@@ -178,33 +164,6 @@ func TestDecodeRejectsGarbage(t *testing.T) {
} }
} }
// TestOutboundTTSJSONShape 把**出站**两种落库形状钉死(design/25 §7.1):
// 成功 = source/status/name/mime/size(+ 可选 durationMs);降级 = source/status +
// fallbackReason,**没有 name/url**(正文照发文本,客户端据此只挂一个小标)。
func TestOutboundTTSJSONShape(t *testing.T) {
t.Parallel()
raw, err := Encode(Ref{
Source: SourceTTS, Status: StatusTranscribed,
Name: "fedcba9876543210fedcba9876543210.ogg", MIME: "audio/ogg", Size: 8192,
})
if err != nil {
t.Fatalf("Encode(成功): %v", err)
}
want := `{"source":"tts","status":"transcribed","name":"fedcba9876543210fedcba9876543210.ogg",` +
`"mime":"audio/ogg","size":8192}`
if raw != want {
t.Fatalf("出站成功 JSON = %s\n want %s", raw, want)
}
raw, err = Encode(Ref{Source: SourceTTS, Status: StatusFailed, FallbackReason: "budget_exceeded"})
if err != nil {
t.Fatalf("Encode(降级): %v", err)
}
if want := `{"source":"tts","status":"failed","fallbackReason":"budget_exceeded"}`; raw != want {
t.Fatalf("出站降级 JSON = %s\n want %s", raw, want)
}
}
// TestRefIsVoiceAndHasCopy 钉住两个判据的边界: // TestRefIsVoiceAndHasCopy 钉住两个判据的边界:
// IsVoice = 来源与状态都在闭集内(= 这条消息来自一条语音); // IsVoice = 来源与状态都在闭集内(= 这条消息来自一条语音);
// HasCopy = 有 name(name/mime/size 同生共死,因此 name 是代表)。 // HasCopy = 有 name(name/mime/size 同生共死,因此 name 是代表)。
@@ -219,8 +178,6 @@ func TestRefIsVoiceAndHasCopy(t *testing.T) {
{"零值(不是语音消息)", Ref{}, false, false}, {"零值(不是语音消息)", Ref{}, false, false},
{"平台转写无副本", Ref{Source: SourcePlatform, Status: StatusTranscribed}, true, false}, {"平台转写无副本", Ref{Source: SourcePlatform, Status: StatusTranscribed}, true, false},
{"有副本", Ref{Source: SourceASR, Status: StatusFailed, Name: "n.wav", MIME: "audio/wav", Size: 4}, true, true}, {"有副本", Ref{Source: SourceASR, Status: StatusFailed, Name: "n.wav", MIME: "audio/wav", Size: 4}, true, true},
{"出站合成成功", Ref{Source: SourceTTS, Status: StatusTranscribed, Name: "n.ogg", MIME: "audio/ogg", Size: 4}, true, true},
{"出站降级(算语音消息,但无可播副本)", Ref{Source: SourceTTS, Status: StatusFailed, FallbackReason: "no_capability"}, true, false},
{"只有来源(半截,不算语音)", Ref{Source: SourceASR}, false, false}, {"只有来源(半截,不算语音)", Ref{Source: SourceASR}, false, false},
} }
for _, tc := range cases { for _, tc := range cases {
+9 -45
View File
@@ -16,8 +16,7 @@ import (
type bubbleSender func(ctx context.Context, msg adapter.UnifiedMessage) error type bubbleSender func(ctx context.Context, msg adapter.UnifiedMessage) error
// sendIMBubbles 是 sendReply(回复路径)与 SendProactive(主动推送) // sendIMBubbles 是 sendReply(回复路径)与 SendProactive(主动推送)
// 共用的唯一 IM 出站出口(带音频的回复走 sendIMBubblesVoice,它只是多挂一条 Media)。 // 共用的唯一 IM 出站出口。它把内容变成 IM 渠道应收到的纯聊天气泡:
// 它把内容变成 IM 渠道应收到的聊天气泡:
// 角色扮演/动作/旁白标记(在 Web 上渲染为暗色斜体)会被剥离, // 角色扮演/动作/旁白标记(在 Web 上渲染为暗色斜体)会被剥离,
// 每个 <p> 段落作为独立 // 每个 <p> 段落作为独立
// 气泡/消息投递(design/17 §2),多气泡连发时相邻发送间隔 0.5-2s, // 气泡/消息投递(design/17 §2),多气泡连发时相邻发送间隔 0.5-2s,
@@ -26,19 +25,10 @@ type bubbleSender func(ctx context.Context, msg adapter.UnifiedMessage) error
// //
// target 携带平台/渠道路由字段,并被复制到每个 // target 携带平台/渠道路由字段,并被复制到每个
// 气泡上(每个气泡只有 Content/ContentType/Timestamp 不同,入站载荷字段一律 // 气泡上(每个气泡只有 Content/ContentType/Timestamp 不同,入站载荷字段一律
// 不带 —— 见 outboundRoute);sender 是 // 不带 —— 见 outboundBubble);sender 是
// 适配器的出站调用。被剥成空的内容(纯动作行) // 适配器的出站调用。被剥成空的内容(纯动作行)
// 直接丢弃不发送;单个气泡失败时会带上其下标包装返回。 // 直接丢弃不发送;单个气泡失败时会带上其下标包装返回。
func (b *Bridge) sendIMBubbles(ctx context.Context, target adapter.UnifiedMessage, sender bubbleSender, content string) error { func (b *Bridge) sendIMBubbles(ctx context.Context, target adapter.UnifiedMessage, sender bubbleSender, content string) error {
return b.sendIMBubblesVoice(ctx, target, sender, content, nil)
}
// sendIMBubblesVoice 是 sendIMBubbles 的**带音频**变体:voice 非 nil 时,那一份音频只
// 挂在**第一个**气泡上,其余气泡仍是纯文本。这条「只挂第一个」不是优化而是正确性 ——
// 同一条合成语音若跟着每个气泡各发一次,对方会收到 N 份同样的音频。
//
// voice 为 nil 时行为与 sendIMBubbles 逐字相同(因此主动推送等纯文本出口不受影响)。
func (b *Bridge) sendIMBubblesVoice(ctx context.Context, target adapter.UnifiedMessage, sender bubbleSender, content string, voice *adapter.Media) error {
// splitBubbles 对每个气泡应用 stripNarration(因此 <action>/<narration> 与 // splitBubbles 对每个气泡应用 stripNarration(因此 <action>/<narration> 与
// 星号漂移的角色扮演形式都会为 IM 剥离),并按 <p> 切分。它是 // 星号漂移的角色扮演形式都会为 IM 剥离),并按 <p> 切分。它是
// 唯一的清洗入口,与旧的回复路径共用。 // 唯一的清洗入口,与旧的回复路径共用。
@@ -52,9 +42,6 @@ func (b *Bridge) sendIMBubblesVoice(ctx context.Context, target adapter.UnifiedM
time.Sleep(b.bubbleGapFn()) time.Sleep(b.bubbleGapFn())
} }
out := outboundBubble(target, bubble) out := outboundBubble(target, bubble)
if i == 0 && voice != nil {
out = outboundVoiceBubble(target, bubble, *voice)
}
// 每个气泡各自发送一次,间隔才会落在消息之间。 // 每个气泡各自发送一次,间隔才会落在消息之间。
if err := sender(ctx, out); err != nil { if err := sender(ctx, out); err != nil {
return fmt.Errorf("channelbridge: send bubble #%d of %d: %w", i+1, len(bubbles), err) return fmt.Errorf("channelbridge: send bubble #%d of %d: %w", i+1, len(bubbles), err)
@@ -63,9 +50,7 @@ func (b *Bridge) sendIMBubblesVoice(ctx context.Context, target adapter.UnifiedM
return nil return nil
} }
// outboundRoute 依据一条入站消息的**路由字段**构造一个出站信封:只有路由字段, // outboundBubble 依据一条入站消息的**路由字段**构造一个纯文本出站气泡。
// 没有内容。outboundBubble(纯文本)与 outboundVoiceBubble(文本 + 音频)都从它出发,
// 因此「哪些字段允许出站」只有一份白名单。
// //
// 为什么不能直接 `out := target`:target 是**入站**消息,整条复制会把入站事件的 // 为什么不能直接 `out := target`:target 是**入站**消息,整条复制会把入站事件的
// 载荷(Media 附件、原始平台载荷、提及列表、回复引用、入站消息 id、发送者昵称) // 载荷(Media 附件、原始平台载荷、提及列表、回复引用、入站消息 id、发送者昵称)
@@ -75,9 +60,8 @@ func (b *Bridge) sendIMBubblesVoice(ctx context.Context, target adapter.UnifiedM
// 之后**整条返回错误,主人连文本都收不到。 // 之后**整条返回错误,主人连文本都收不到。
// //
// 因此这里按白名单只带路由字段(适配器据此解析投递对端),新加进 UnifiedMessage // 因此这里按白名单只带路由字段(适配器据此解析投递对端),新加进 UnifiedMessage
// 的字段默认不外发。⚠️ 出站的 `Media` 只能由出站语音自己填(那份副本是我们刚合成的, // 的字段默认不外发:出站是纯文本气泡,本派发器只接收文本。
// 见 outbound_voice.go),**绝不**从 target 继承。 func outboundBubble(target adapter.UnifiedMessage, content string) adapter.UnifiedMessage {
func outboundRoute(target adapter.UnifiedMessage) adapter.UnifiedMessage {
return adapter.UnifiedMessage{ return adapter.UnifiedMessage{
// 路由字段:平台 / 渠道 / 群 / 线程 / 发送者 id。 // 路由字段:平台 / 渠道 / 群 / 线程 / 发送者 id。
Platform: target.Platform, Platform: target.Platform,
@@ -96,29 +80,9 @@ func outboundRoute(target adapter.UnifiedMessage) adapter.UnifiedMessage {
IsGroup: target.Sender.IsGroup, IsGroup: target.Sender.IsGroup,
GroupID: target.Sender.GroupID, GroupID: target.Sender.GroupID,
}, },
// 本气泡的内容:出站永远是纯文本。
Content: content,
ContentType: adapter.ContentText,
Timestamp: time.Now().UTC(),
} }
} }
// outboundBubble 构造一个**纯文本**出站气泡:路由字段 + 文本内容。
func outboundBubble(target adapter.UnifiedMessage, content string) adapter.UnifiedMessage {
out := outboundRoute(target)
out.Content = content
out.ContentType = adapter.ContentText
out.Timestamp = time.Now().UTC()
return out
}
// outboundVoiceBubble 构造**带音频**的出站气泡:与纯文本气泡同一套路由字段与同一份
// 正文,只是把 ContentType 标成 voice 并挂上那一条 Media。
//
// 正文**照发**(不是「有音频就不发文字」):适配器把它当「文字 item + 语音 item」一起
// 投递(weixin_ilink 的 sendToOne),对方既看得到文字也点得开音频;而任何一侧失败都
// 只影响那一侧(音频发不出去时文字仍在)。
func outboundVoiceBubble(target adapter.UnifiedMessage, content string, md adapter.Media) adapter.UnifiedMessage {
out := outboundRoute(target)
out.Content = content
out.ContentType = adapter.ContentVoice
out.Media = []adapter.Media{md}
out.Timestamp = time.Now().UTC()
return out
}
@@ -161,7 +161,7 @@ func (b *Bridge) persistBatchUsers(ctx context.Context, parts []batchMessage, ta
notify := b.persistVoiceFirstNotify(p.voiceRef) notify := b.persistVoiceFirstNotify(p.voiceRef)
var row history.Message var row history.Message
if target.private { if target.private {
row = b.persistMainWith(ctx, mainRow(history.RoleUser, p.prepared.text, "", p.document, p.image, row = b.persistMain(ctx, mainRow(history.RoleUser, p.prepared.text, "", p.document, p.image,
target.platform, deviceIDFor(msg), adapterName(a), turnID), notify) target.platform, deviceIDFor(msg), adapterName(a), turnID), notify)
} else { } else {
row = b.persistIsolatedWith(ctx, isolatedRow(history.RoleUser, p.prepared.text, p.document, p.image, row = b.persistIsolatedWith(ctx, isolatedRow(history.RoleUser, p.prepared.text, p.document, p.image,
+3 -19
View File
@@ -193,31 +193,15 @@ func (b *Bridge) deliverReply(ctx context.Context, msg adapter.UnifiedMessage, a
if res != nil { if res != nil {
mood = res.Mood mood = res.Mood
} }
// 出站语音(design/25 §7.1):先把结算结论折成 voice 列事实,再让落库那条路走
// 「INSERT → 写 voice 列 → notify」(M44 首帧纪律)—— 与入站语音共用同一份
// persistVoiceFirstNotify,因此首帧上就带着 `voice`,客户端不必等重连。
ref, hasVoice := b.outboundVoiceRef(resultVoice(res))
notify := b.notifyPersisted
if hasVoice {
notify = b.persistVoiceFirstNotify(ref)
}
// 持久化助手回复(主人私聊 → 主会话 + message_persisted // 持久化助手回复(主人私聊 → 主会话 + message_persisted
// 广播;受限 → 隔离日志 + channel_persisted 广播),随后 // 广播;受限 → 隔离日志 + channel_persisted 广播),随后
// 送回渠道。 // 送回渠道。
if target.private { if target.private {
b.persistMainWith(ctx, mainRow(history.RoleAssistant, reply, mood, "", "", target.platform, deviceIDFor(msg), adapterName(a), turnID), notify) b.persistToMain(ctx, history.RoleAssistant, reply, mood, "", "", target.platform, deviceIDFor(msg), adapterName(a), turnID)
} else { } else {
b.persistIsolatedWith(ctx, isolatedRow(history.RoleAssistant, reply, "", "", "", sess, msg.Platform, "", adapterName(a), turnID), notify) b.persistIsolated(ctx, history.RoleAssistant, reply, "", "", "", sess, msg.Platform, "", adapterName(a), turnID)
} }
// 音频只在合成成功、且盘上那份副本读得回来时才挂上;读不回来就诚实退回纯文本 if err := b.sendReply(ctx, msg, a, reply); err != nil {
// (正文照发,WARN 已由 outboundVoiceMedia 记下)。
var voiceMedia *adapter.Media
if hasVoice {
if md, ok := b.outboundVoiceMedia(msg, ref); ok {
voiceMedia = &md
}
}
if err := b.sendReplyVoice(ctx, msg, a, reply, voiceMedia); err != nil {
if errors.Is(err, adapter.ErrUnsupported) { if errors.Is(err, adapter.ErrUnsupported) {
b.log.Debug("channel does not support outbound reply, skipped", b.log.Debug("channel does not support outbound reply, skipped",
"platform", msg.Platform, "session", target.sessionID) "platform", msg.Platform, "session", target.sessionID)
@@ -116,9 +116,6 @@ func (b *Bridge) ingestInboundVoiceCopy(ctx context.Context, msg adapter.Unified
// 若先广播后写列,这条语音消息的**第一帧**就永远缺 `voice`,客户端只能等重连或下一次 REST // 若先广播后写列,这条语音消息的**第一帧**就永远缺 `voice`,客户端只能等重连或下一次 REST
// 拉取才升级出播放器(安卓 2026-10-26 回执报的正是这个)。 // 拉取才升级出播放器(安卓 2026-10-26 回执报的正是这个)。
// //
// 入站与出站**共用**它:两边都是「结论已经在手里、列还没写」的同一形状(入站是转写结论,
// 出站是合成结论),首帧纪律因此只有一处实现。
//
// 只换顺序、不换代价:仍是 INSERT + 一次 UPDATE(与 design/24 §10.5 的第二选项同一手法, // 只换顺序、不换代价:仍是 INSERT + 一次 UPDATE(与 design/24 §10.5 的第二选项同一手法,
// 与 document 结论的两段式同族)。非语音消息(ref 零值)时 persistMessageVoice 直接返回, // 与 document 结论的两段式同族)。非语音消息(ref 零值)时 persistMessageVoice 直接返回,
// 因此本包装与 notifyPersisted 逐字等价 —— 普通消息的时序一个字节都没变。 // 因此本包装与 notifyPersisted 逐字等价 —— 普通消息的时序一个字节都没变。
@@ -129,9 +126,9 @@ func (b *Bridge) persistVoiceFirstNotify(ref voiceref.Ref) func(history.Message)
} }
} }
// persistMessageVoice 把这条语音的来源事实与副本引用写进刚落库的消息 `voice` 列 // persistMessageVoice 把这条语音的来源事实与副本引用写进刚落库的用户消息 `voice` 列
// (design/24 §10.2 第 5 步;出站方向同列同形)。它由落库那条路在消息写进去之后调用, // (design/24 §10.2 第 5 步)。它由 runInboundTurn 在用户消息落库之后调用,因此
// 因此「引用 + 三态」最终是同一行里的一份事实,客户端不必自己拼。 // 「引用 + 三态」最终是同一行里的一份事实,客户端不必自己拼。
// //
// 三个提前返回都是**正常**情形,不刷日志:这条消息不是语音(零值)、没有落库 // 三个提前返回都是**正常**情形,不刷日志:这条消息不是语音(零值)、没有落库
// (msg.ID 为空 ⇒ 存储未接或写库失败)、编码后为空串。 // (msg.ID 为空 ⇒ 存储未接或写库失败)、编码后为空串。
@@ -155,7 +152,7 @@ func (b *Bridge) persistMessageVoice(msg history.Message, ref voiceref.Ref) {
if err != nil { if err != nil {
// 编码失败只可能是坏数据(闭集破坏 / 副本字段不同生共死):**绝不**写半截, // 编码失败只可能是坏数据(闭集破坏 / 副本字段不同生共死):**绝不**写半截,
// 但也绝不静默 —— 消息本身已经落库,少的是这条语音标记。 // 但也绝不静默 —— 消息本身已经落库,少的是这条语音标记。
b.voiceLogger().Error("voice ref not persisted: encode failed", b.voiceLogger().Error("inbound voice ref not persisted: encode failed",
"message", msg.ID, "source", ref.Source, "status", ref.Status, "error", err) "message", msg.ID, "source", ref.Source, "status", ref.Status, "error", err)
return return
} }
@@ -166,13 +163,13 @@ func (b *Bridge) persistMessageVoice(msg history.Message, ref voiceref.Ref) {
if !ok { if !ok {
// 历史存储没有 voice 列写入视图(迁移 0020 只加列,不替调用方接写路径): // 历史存储没有 voice 列写入视图(迁移 0020 只加列,不替调用方接写路径):
// 如实报告「没写成」,绝不假装写了。 // 如实报告「没写成」,绝不假装写了。
b.voiceLogger().Warn("voice ref not persisted", b.voiceLogger().Warn("inbound voice ref not persisted",
"reason", "history store has no voice column path", "message", msg.ID, "reason", "history store has no voice column path", "message", msg.ID,
"source", ref.Source, "status", ref.Status) "source", ref.Source, "status", ref.Status)
return return
} }
if err := writer.UpdateMessageVoice(context.Background(), msg.ID, raw); err != nil { if err := writer.UpdateMessageVoice(context.Background(), msg.ID, raw); err != nil {
b.voiceLogger().Warn("voice ref update", b.voiceLogger().Warn("inbound voice ref update",
"err", err, "message", msg.ID, "source", ref.Source, "status", ref.Status) "err", err, "message", msg.ID, "source", ref.Source, "status", ref.Status)
} }
} }
+2 -9
View File
@@ -17,20 +17,13 @@ import (
// 使适配器解析出消息来自的同一对端,并经 // 使适配器解析出消息来自的同一对端,并经
// 共享的 IM 气泡派发器出口(剥离角色扮演/旁白 + 按 <p> 切分 + 0.5-2s 间隔)。 // 共享的 IM 气泡派发器出口(剥离角色扮演/旁白 + 按 <p> 切分 + 0.5-2s 间隔)。
func (b *Bridge) sendReply(ctx context.Context, in adapter.UnifiedMessage, a adapter.PlatformAdapter, text string) error { func (b *Bridge) sendReply(ctx context.Context, in adapter.UnifiedMessage, a adapter.PlatformAdapter, text string) error {
return b.sendReplyVoice(ctx, in, a, text, nil)
}
// sendReplyVoice 是 sendReply 的**带音频**变体:voice 非 nil 时随第一个气泡一起送出
// (出站语音,design/25 §7.1)。正文照发文本 —— 音频是这条回复的另一种呈现,不是
// 文本的替代品,因此音频发不出去时对方仍读得到字。
func (b *Bridge) sendReplyVoice(ctx context.Context, in adapter.UnifiedMessage, a adapter.PlatformAdapter, text string, voice *adapter.Media) error {
if a == nil { if a == nil {
a = b.adapter(in.Platform) a = b.adapter(in.Platform)
} }
if a == nil { if a == nil {
return fmt.Errorf("channelbridge: no adapter for platform %q", in.Platform) return fmt.Errorf("channelbridge: no adapter for platform %q", in.Platform)
} }
return b.sendIMBubblesVoice(ctx, in, func(ctx context.Context, msg adapter.UnifiedMessage) error { return b.sendIMBubbles(ctx, in, func(ctx context.Context, msg adapter.UnifiedMessage) error {
return a.SendMessage(ctx, adapter.UnifiedResponse{Messages: []adapter.UnifiedMessage{msg}}) return a.SendMessage(ctx, adapter.UnifiedResponse{Messages: []adapter.UnifiedMessage{msg}})
}, text, voice) }, text)
} }
@@ -1,134 +0,0 @@
package channelbridge
// 本文件承载**出站语音**(design/25 §7.1;切片 M58):把循环结算出的
// `loop.OutboundVoice`(M57 的 speak 工具结论)折成两件东西 ——
//
// 1. 一条 `voice` 列事实(`voiceref.Ref`,source=tts):成功即
// `{source:"tts",status:"transcribed",name,mime,size,durationMs}`,降级即
// `{source:"tts",status:"failed",fallbackReason}`。它由 deliverReply 交给与入站
// **同一条**「先写列、再广播」的落库口径(persistVoiceFirstNotify),因此出站语音
// 的**第一帧**就带着 `voice`(M44 首帧纪律,入站已有先例)。
// 2. 一份出站 `adapter.Media`:把盘上那份副本读回字节挂到第一个气泡上,渠道才真的
// 发得出去音频(bubbles.go 的 outboundVoiceBubble)。
//
// 三条口径:
// - **绝不写半截**:副本三字段(name/mime/size)不齐就不写 `transcribed`,宁可降级成
// `failed` + 机器可读原因 —— 一个没有 mime 的 name 会让客户端拼出读不懂的对象。
// - **拿不到字节就不挂音频**:附件存储未接线、或副本读不出来时,正文照发文本、只留一条
// WARN。这与入站「没有字节就不落副本」是同一条诚实纪律的反方向。
// - **绝不静默**:每一类失败都有一条带来源(`services/channelbridge/voice`)的日志。
//
// ⚠️ 出站 Media 的 URL 刻意留空:`/api/v1/media/<name>` 走 Bearer 鉴权(gateway 的
// writeAuth),适配器没有、也不该有 owner 令牌,因此它只能拿**字节**,不能自取 URL。
import (
"os"
"path/filepath"
"git.yeij.top/AskaEth/Cyrene_v2/internal/channel/adapter"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/loop"
voiceref "git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/voice"
)
// outboundVoiceFallbackCopyIncomplete 是本模块自己产生的降级码(speak 工具不会给出它):
// 合成报告了成功,但副本事实不完整(缺 mime/size,或 name 不是合法的内容寻址名)。
// 这是上游缺陷、不该发生,因此记 ERROR;但写进列里的原因仍要机器可读 —— 客户端据此
// 与真实降级一样只挂小标,而不是去点一个播不出来的播放器。
const outboundVoiceFallbackCopyIncomplete = "copy_incomplete"
// resultVoice 取出这一回合的出站语音结论;回合报错(res 为 nil)时没有结论。
func resultVoice(res *loop.Result) *loop.OutboundVoice {
if res == nil {
return nil
}
return res.Voice
}
// outboundVoiceRef 把出站语音结论折成一条 `voice` 列事实。第二个返回值报告「这一回合
// 要不要写 voice 列」—— false 表示这次回复就是纯文本,落库路径与引入本文件之前逐字相同。
//
// 三种结局(与 loop.OutboundVoice 的注释一一对应):
// - 未请求(`v == nil` 或 `!v.Requested`)⇒ 不写列,返回 false;
// - 合成成功且有完整副本 ⇒ `transcribed` + 副本事实;
// - 请求了但没有音频 ⇒ `failed` + 降级原因(来自 speak 工具,或本模块的 copy_incomplete)。
//
// 「Requested 却既无副本又无原因」是上游契约被破坏的状态:不写列(写一个没有原因的
// failed 等于把「为什么」丢掉),但必须 WARN —— 静默吞掉会让这条语音彻底看不见。
func (b *Bridge) outboundVoiceRef(v *loop.OutboundVoice) (voiceref.Ref, bool) {
if v == nil || !v.Requested {
return voiceref.Ref{}, false
}
log := b.voiceLogger()
if v.Name != "" {
if v.Mime == "" || v.Size <= 0 || !voiceref.ValidMediaName(v.Name) {
log.Error("outbound voice copy is incomplete, degraded to text",
"name", v.Name, "mime", v.Mime, "size", v.Size,
"duration_ms", v.DurationMs)
return voiceref.Ref{
Source: voiceref.SourceTTS, Status: voiceref.StatusFailed,
FallbackReason: outboundVoiceFallbackCopyIncomplete,
}, true
}
ref := voiceref.Ref{
Source: voiceref.SourceTTS, Status: voiceref.StatusTranscribed,
Name: v.Name, MIME: v.Mime, Size: v.Size,
}
// 时长只取上游如实给出且非负的值:Ref.Validate 拒绝负数,绝不为了「有个数」
// 而估算(与入站同一口径)。
if v.DurationMs > 0 {
ref.DurationMs = v.DurationMs
}
return ref, true
}
if v.FallbackReason != "" {
return voiceref.Ref{
Source: voiceref.SourceTTS, Status: voiceref.StatusFailed,
FallbackReason: v.FallbackReason,
}, true
}
log.Warn("outbound voice requested without copy or fallback reason, treated as text",
"cost_ms", v.CostMs, "budget_ms", v.BudgetMs)
return voiceref.Ref{}, false
}
// outboundVoiceMedia 把盘上那份副本读回字节,折成一个出站媒体项(design/25 §7.1)。
// 第二个返回值报告「挂不挂得上」:false 时调用方照发纯文本,绝不发一个空音频。
//
// 目录与上限来自组合根注入的文档存储配置(与入站附件同一份 `documentsCfg`):副本就是
// `documents.SaveAudio` 落下的内容寻址文件,因此 `Dir()` + 存储名就是它的路径 —— 不需要
// (也没有)一条按名读回的存储 API。名字再校验一次是纵深防御:`filepath.Join` 前面绝不
// 允许出现未经验证的渠道/上游字符串。
func (b *Bridge) outboundVoiceMedia(msg adapter.UnifiedMessage, ref voiceref.Ref) (adapter.Media, bool) {
if !ref.HasCopy() {
return adapter.Media{}, false
}
log := b.voiceLogger()
if !voiceref.ValidMediaName(ref.Name) {
log.Error("outbound voice media skipped: illegal copy name", "name", ref.Name)
return adapter.Media{}, false
}
store := b.inboundDocumentStore(msg)
if store == nil || store.Dir() == "" {
log.Warn("outbound voice media skipped: document store not wired",
"platform", msg.Platform, "channel", msg.ChannelID, "name", ref.Name)
return adapter.Media{}, false
}
data, err := os.ReadFile(filepath.Join(store.Dir(), ref.Name))
if err != nil {
log.Warn("outbound voice media skipped: copy unreadable",
"platform", msg.Platform, "channel", msg.ChannelID, "name", ref.Name, "error", err)
return adapter.Media{}, false
}
if int64(len(data)) != ref.Size {
// 列里的 size 与实际字节不一致:以实际字节为准(发出去的就是这些),但要说出来。
log.Warn("outbound voice copy size differs from column",
"name", ref.Name, "column_size", ref.Size, "actual_size", len(data))
}
return adapter.Media{
Kind: adapter.MediaVoice,
Name: ref.Name,
Size: int64(len(data)),
MIMEType: ref.MIME,
Data: data,
}, true
}
@@ -1,489 +0,0 @@
package channelbridge
// 本文件是 M58 的**跨层整链**验收:出站语音结论(`loop.Result.Voice`,M57 的 speak 工具
// 结算)→ 桥接器落库并写 `chat_messages.voice` 列 → 真网关的**实时帧** + REST 读面;
// 以及同一回合的出站 `adapter.Media` 挂载。
//
// 手法与入站整链(inbound_voice_gateway_e2e_test.go)同源:**同一个**真 `history.Store`
// 喂给桥接器与网关,断言因此打在真 WS 帧与真 REST 读面上,而不是两个替身各自的想象。
// 唯一的差别是驱动方式:出站结论来自循环,所以这里用 `stubChatter` 直接给出带 `Voice`
// 的 `Result` —— 真循环 + 假模型跑不出 `Result.Voice`(假模型不会去调 speak 工具),
// 而本切片要钉的正是「结论已经在手里之后」那一段。
//
// 钉住四件事:
// 1. 合成成功 ⇒ 助手回复的**第一帧**就带 `voice`(source=tts/status=transcribed + 副本四键),
// 重新连 WS 的握手回放与 REST 读面逐字相同;
// 2. 合成降级 ⇒ `voice.status=failed` + `fallbackReason` 在帧上**与**历史回看(REST、握手回放)
// 上都可见,且正文照发原文(降级不是「少说一句话」);
// 3. 音频只挂在**第一个**气泡上(多段回复里重复挂会让对方收到 N 份同样的语音);
// 4. 出站 Media 的字节就是盘上那份副本(读不回来时宁可不挂,见 outbound_voice.go)。
import (
"bytes"
"context"
"encoding/json"
"fmt"
"log/slog"
"net/http/httptest"
"path/filepath"
"strings"
"testing"
"time"
"github.com/gorilla/websocket"
"git.yeij.top/AskaEth/Cyrene_v2/internal/auth"
"git.yeij.top/AskaEth/Cyrene_v2/internal/channel/adapter"
"git.yeij.top/AskaEth/Cyrene_v2/internal/channel/session"
"git.yeij.top/AskaEth/Cyrene_v2/internal/config"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/documents"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/history"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/loop"
voiceref "git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/voice"
"git.yeij.top/AskaEth/Cyrene_v2/internal/services/gateway"
)
const (
// outboundVoiceText 是所有出站用例的回复正文(单段,便于把「正文原样」写成字面量)。
outboundVoiceText = "晚安啦,做个好梦。"
// outboundVoiceStoredText 是它经回合可见回复规整(turnreply.NormalizeParagraphs)后
// **真正落库、真正上帧**的文本:单段被包进一对 `<p>`。降级用例按这个字面量比对,
// 因此「合成失败有没有动正文」是一个逐字问题,而不是「大概还在」。
outboundVoiceStoredText = "<p>晚安啦,做个好梦。</p>"
// outboundVoiceMultiText 是两段回复:出站音频只该跟着第一段走。
outboundVoiceMultiText = "第一段话,先说给你听。\n\n第二段话,说给明天听。"
// outboundVoiceAudioMIME 是 `.ogg` 副本的下发类型(voice/media.go 的成对裁决产物)。
outboundVoiceAudioMIME = "audio/ogg"
)
// outboundVoiceE2E 打包出站语音整链环境:真桥接器(可注入 `Result.Voice` 的循环替身 +
// 真历史存储 + 真音频落盘配置)+ 真网关(同一个历史存储)+ 组合根的持久化钩子。
type outboundVoiceE2E struct {
bridge *Bridge
chat *stubChatter
platform *fakeAdapter
// uploads 是桥接器会去读副本的那个目录(`documents.Store.Dir()`)。
uploads string
ts *httptest.Server
tok string
}
// newOutboundVoiceE2E 装配整链环境。历史存储是**同一个句柄**喂给桥接器与网关 ——
// 被验证的正是这条边界:桥接器写进 `voice` 列的事实,就是实时帧与 REST 读面投影出来的事实。
func newOutboundVoiceE2E(t *testing.T, sess session.Session) *outboundVoiceE2E {
t.Helper()
dataDir := t.TempDir()
uploads := filepath.Join(dataDir, "uploads", "documents")
log := slog.New(slog.NewTextHandler(&safeBuffer{}, nil))
st := openGatewayDocStore(t, log)
chat := &stubChatter{res: &loop.Result{Text: outboundVoiceText}}
ad := &fakeAdapter{name: "telegram"}
b, err := New(Options{
Log: log,
Loop: chat,
Sessions: &stubSessions{sess: sess},
Owner: testOwnerProfile("telegram", "100"),
History: st,
// 真音频落盘配置:副本由 documents.Store.SaveAudio 落下,出站时按同一目录读回。
DocumentStore: documents.StoreConfig{DataDir: dataDir, UploadsDir: uploads},
})
if err != nil {
t.Fatalf("构造桥接器: %v", err)
}
// 气泡间隔在生产里是 0.5-2s:多段用例不该为它付真实时间。
b.bubbleGapFn = func() time.Duration { return 0 }
b.Add(ad)
jwt := auth.New(config.AuthConfig{JWTSecret: gatewayDocSecret})
srv, err := gateway.New(
&config.Config{Auth: config.AuthConfig{AdminUser: "admin", AdminPass: "secret", JWTSecret: gatewayDocSecret}},
gateway.Deps{Auth: jwt, History: st, DataDir: dataDir},
)
if err != nil {
t.Fatalf("构造网关: %v", err)
}
t.Cleanup(srv.Close)
ts := httptest.NewServer(srv.Handler())
t.Cleanup(ts.Close)
// 🔴 组合根的同一接线(cmd/cyrene/wire_frontend.go:SetOnPersisted(gw.BroadcastPersisted))。
b.SetOnPersisted(srv.BroadcastPersisted)
tok, err := jwt.Sign("acct", auth.RoleOwner, time.Hour)
if err != nil {
t.Fatalf("签发 token: %v", err)
}
return &outboundVoiceE2E{bridge: b, chat: chat, platform: ad, uploads: uploads, ts: ts, tok: tok}
}
// dialOwnerWS 连上真网关的 `/ws/chat`(owner token),并吃掉握手回放的 `history` 帧,
// 使后续读取从「本回合的第一帧」开始(与入站整链同一手法)。
func (e *outboundVoiceE2E) dialOwnerWS(t *testing.T) *websocket.Conn {
t.Helper()
conn, _ := e.dialOwnerWSHistory(t)
return conn
}
// dialOwnerWSHistory 同 dialOwnerWS,但把握手回放的 `history` 帧一并交回 —— 出站语音
// 也得在「隔天重新打开客户端」的那条路上看得见(列有值即带),这条读面因此要能被断言。
func (e *outboundVoiceE2E) dialOwnerWSHistory(t *testing.T) (*websocket.Conn, wsFrame) {
t.Helper()
wsURL := "ws" + strings.TrimPrefix(e.ts.URL, "http") + "/ws/chat?token=" + e.tok
conn, _, err := websocket.DefaultDialer.Dial(wsURL, nil)
if err != nil {
t.Fatalf("连接 WS: %v", err)
}
t.Cleanup(func() { _ = conn.Close() })
ev := readWSFrame(t, conn)
if ev.Type != "history" {
t.Fatalf("第一个帧 = %q, want history(握手回放)", ev.Type)
}
return conn, ev
}
// replayVoiceOfID 从握手回放帧的 `data.messages` 里按 id 找到那一行,返回它的 `voice`
// 原始 JSON(键的在场性因此可判);该行没有 `voice` 键时返回 nil 与 false。
func replayVoiceOfID(t *testing.T, ev wsFrame, id string) (json.RawMessage, bool) {
t.Helper()
var data struct {
Messages []map[string]json.RawMessage `json:"messages"`
}
if err := json.Unmarshal(ev.Data, &data); err != nil {
t.Fatalf("解码握手回放帧 %s: %v", ev.Data, err)
}
for _, row := range data.Messages {
var gotID string
_ = json.Unmarshal(row["id"], &gotID)
if gotID != id {
continue
}
raw, ok := row["voice"]
return raw, ok
}
t.Fatalf("握手回放里没有 id=%q 的那一行(回放=%s)", id, ev.Data)
return nil, false
}
// storeOutboundCopy 用**真**音频落盘分支(`documents.Store.SaveAudio`)把一份合成产物
// 落到桥接器会去读的那个目录里,返回它签发的引用。名字/类型/大小都按生产口径算出来 ——
// 绝不手拼一个「看起来像」的文件名,否则本文件测的就不是那条读回路径。
func storeOutboundCopy(t *testing.T, uploads string, data []byte) documents.Ref {
t.Helper()
store := documents.NewStore(documents.StoreConfig{UploadsDir: uploads})
ref, err := store.SaveAudio(context.Background(), "voice.ogg", data)
if err != nil {
t.Fatalf("落盘出站语音副本: %v", err)
}
if ref.Name == "" || ref.MIME != outboundVoiceAudioMIME || ref.Size != int64(len(data)) {
t.Fatalf("落盘引用 = %+v, want 名字/%s/%d 字节", ref, outboundVoiceAudioMIME, len(data))
}
return ref
}
// nextPersistedFrameWithRole 读到**下一条指定 role 的持久化帧**(跳过其余帧)。出站用例
// 要的是**助手**那一行(本回合的用户行帧先到),所以按 role 过滤而不是按先后猜 ——
// 也因此不需要 sleep 或重试。
func nextPersistedFrameWithRole(t *testing.T, conn *websocket.Conn, role string) wsFrame {
t.Helper()
for i := 0; i < 10; i++ {
ev := readWSFrame(t, conn)
switch ev.Type {
case "message_persisted", "channel_persisted":
if messageRole(t, persistedMessage(t, ev)) == role {
return ev
}
}
}
t.Fatalf("读了 10 帧仍没等到 %s 行的持久化帧", role)
return wsFrame{}
}
// frameContent 取帧内消息的正文。
func frameContent(t *testing.T, msg map[string]json.RawMessage) string {
t.Helper()
var content string
if err := json.Unmarshal(msg["content"], &content); err != nil {
t.Fatalf("解析帧里的 content: %v", err)
}
return content
}
// TestIMOutboundVoiceFirstFrameCarriesVoice 钉住出站方向的首帧纪律(M44 的同一条口径):
// 合成成功的回复,其**第一帧**里就要有 `voice`,且与 REST 读面同一行逐字相等。
func TestIMOutboundVoiceFirstFrameCarriesVoice(t *testing.T) {
t.Parallel()
env := newOutboundVoiceE2E(t, session.Session{SessionID: "sess-1", Owner: true, MemWritable: true})
audio := []byte("OggS-outbound-voice-payload")
ref := storeOutboundCopy(t, env.uploads, audio)
env.chat.res = &loop.Result{
Text: outboundVoiceText,
Voice: &loop.OutboundVoice{
Requested: true, Name: ref.Name, Mime: ref.MIME, Size: ref.Size, DurationMs: 1500,
},
}
conn := env.dialOwnerWS(t)
env.bridge.process(context.Background(), directMessage())
ev := nextPersistedFrameWithRole(t, conn, history.RoleAssistant)
if ev.Type != "message_persisted" {
t.Fatalf("主人私聊的帧类型 = %q, want message_persisted", ev.Type)
}
msg := persistedMessage(t, ev)
raw, ok := msg["voice"]
if !ok {
t.Fatalf("🔴 出站语音的**第一帧**缺 data.message.voice: %s", ev.Data)
}
shape := canonicalVoice(t, raw)
for _, want := range []string{
`"source":"tts"`, `"status":"transcribed"`,
`"name":"` + ref.Name + `"`, `"mime":"` + outboundVoiceAudioMIME + `"`,
fmt.Sprintf(`"size":%d`, ref.Size), `"durationMs":1500`,
`"url":"/api/v1/media/` + ref.Name + `"`,
} {
if !strings.Contains(shape, want) {
t.Fatalf("帧里的 voice = %s, 缺 %s", shape, want)
}
}
// 与 REST 读面**同一行**逐字相等:同一份事实,不是两处各算。
id := messageIDOf(t, msg)
restVoice := voiceOfRowByID(t, fetchMessages(t, env.ts.URL, history.MainSessionID, env.tok), id)
if got, want := canonicalVoice(t, raw), canonicalVoice(t, restVoice); got != want {
t.Fatalf("帧里的 voice = %s, REST 读面同一行 = %s", got, want)
}
// 握手回放(隔天重新打开客户端):同一条消息在 `history` 帧里也带着这份出站语音。
_, replay := env.dialOwnerWSHistory(t)
replayRaw, ok := replayVoiceOfID(t, replay, id)
if !ok {
t.Fatalf("🔴 握手回放里这条出站语音缺 voice 键: %s", replay.Data)
}
if got, want := canonicalVoice(t, replayRaw), canonicalVoice(t, raw); got != want {
t.Fatalf("握手回放里的 voice = %s, 实时帧 = %s", got, want)
}
// 正文一字未改:语音是这条回复的另一种呈现,不是它的替代品。
if got := frameContent(t, msg); got != outboundVoiceStoredText {
t.Fatalf("帧里的正文 = %q, want %q", got, outboundVoiceStoredText)
}
}
// TestIMOutboundVoiceFallbackIsVisibleAsFailed 钉住降级的可见性(切片 M58 的第 3 条):
// `voice.status=failed` + `fallbackReason` 在**实时帧**与**历史回看**上都看得见,正文照发
// 原文,且渠道侧只收到纯文本气泡(绝不发一个空音频)。
func TestIMOutboundVoiceFallbackIsVisibleAsFailed(t *testing.T) {
t.Parallel()
env := newOutboundVoiceE2E(t, session.Session{SessionID: "sess-1", Owner: true, MemWritable: true})
env.chat.res = &loop.Result{
Text: outboundVoiceText,
Voice: &loop.OutboundVoice{Requested: true, FallbackReason: "tts_timeout"},
}
conn := env.dialOwnerWS(t)
env.bridge.process(context.Background(), directMessage())
ev := nextPersistedFrameWithRole(t, conn, history.RoleAssistant)
msg := persistedMessage(t, ev)
raw, ok := msg["voice"]
if !ok {
t.Fatalf("🔴 合成降级的**第一帧**缺 data.message.voice(客户端看不到「本来想用语音」): %s", ev.Data)
}
shape := canonicalVoice(t, raw)
for _, want := range []string{
`"source":"tts"`, `"status":"failed"`, `"fallbackReason":"tts_timeout"`,
} {
if !strings.Contains(shape, want) {
t.Fatalf("帧里的 voice = %s, 缺 %s", shape, want)
}
}
// 没有副本 ⇒ 副本四键**一起缺席**(不是空串、不是 0):客户端据此不画播放器。
for _, absent := range []string{`"name"`, `"mime"`, `"size"`, `"url"`} {
if strings.Contains(shape, absent) {
t.Fatalf("降级的 voice = %s, 不该有 %s", shape, absent)
}
}
// 历史回看(REST 读面)与帧上是同一份事实 —— 隔天再打开也看得到这条降级。
id := messageIDOf(t, msg)
restVoice := voiceOfRowByID(t, fetchMessages(t, env.ts.URL, history.MainSessionID, env.tok), id)
if got, want := canonicalVoice(t, raw), canonicalVoice(t, restVoice); got != want {
t.Fatalf("帧里的 voice = %s, REST 读面同一行 = %s", got, want)
}
// 握手回放(重新连 WS)同样带着这条降级:客户端的「本来想用语音」小标不只在实时帧上有。
_, replay := env.dialOwnerWSHistory(t)
replayRaw, ok := replayVoiceOfID(t, replay, id)
if !ok {
t.Fatalf("🔴 握手回放里这条降级回复缺 voice 键: %s", replay.Data)
}
if got, want := canonicalVoice(t, replayRaw), canonicalVoice(t, raw); got != want {
t.Fatalf("握手回放里的 voice = %s, 实时帧 = %s", got, want)
}
// 正文原样:降级只影响「有没有音频」,绝不影响说什么。
if got := frameContent(t, msg); got != outboundVoiceStoredText {
t.Fatalf("降级回复的正文 = %q, want %q(正文不该被降级改写)", got, outboundVoiceStoredText)
}
if len(env.platform.sends) != 1 {
t.Fatalf("渠道侧气泡数 = %d, want 1", len(env.platform.sends))
}
sent := env.platform.sends[0]
if sent.ContentType != adapter.ContentText {
t.Fatalf("降级气泡的 ContentType = %q, want %q", sent.ContentType, adapter.ContentText)
}
if len(sent.Media) != 0 {
t.Fatalf("降级气泡带上了 Media = %+v, want 空(绝不发空音频)", sent.Media)
}
}
// TestIMOutboundVoiceMountsMediaOnFirstBubbleOnly 钉住出站 Media:音频只挂**第一个**气泡,
// 字节就是盘上那份副本,第二个气泡仍是纯文本(同一条合成语音跟着每段各发一次,对方会收到
// N 份同样的音频)。
func TestIMOutboundVoiceMountsMediaOnFirstBubbleOnly(t *testing.T) {
t.Parallel()
env := newOutboundVoiceE2E(t, session.Session{SessionID: "sess-1", Owner: true, MemWritable: true})
audio := []byte("OggS-multi-paragraph-voice")
ref := storeOutboundCopy(t, env.uploads, audio)
env.chat.res = &loop.Result{
Text: outboundVoiceMultiText,
Voice: &loop.OutboundVoice{
Requested: true, Name: ref.Name, Mime: ref.MIME, Size: ref.Size,
},
}
env.bridge.process(context.Background(), directMessage())
if len(env.platform.sends) != 2 {
t.Fatalf("两段回复的气泡数 = %d, want 2(%+v)", len(env.platform.sends), env.platform.sends)
}
first := env.platform.sends[0]
if !strings.Contains(first.Content, "第一段话") {
t.Fatalf("第一个气泡的正文 = %q, want 第一段", first.Content)
}
if first.ContentType != adapter.ContentVoice {
t.Fatalf("第一个气泡的 ContentType = %q, want %q", first.ContentType, adapter.ContentVoice)
}
if len(first.Media) != 1 {
t.Fatalf("第一个气泡的 Media = %+v, want 恰好一条", first.Media)
}
md := first.Media[0]
if md.Kind != adapter.MediaVoice {
t.Fatalf("出站媒体 Kind = %q, want %q", md.Kind, adapter.MediaVoice)
}
if md.Name != ref.Name || md.MIMEType != outboundVoiceAudioMIME {
t.Fatalf("出站媒体 = %+v, want name=%s mime=%s", md, ref.Name, outboundVoiceAudioMIME)
}
if md.Size != int64(len(audio)) {
t.Fatalf("出站媒体 Size = %d, want %d", md.Size, len(audio))
}
if !bytes.Equal(md.Data, audio) {
t.Fatalf("出站媒体字节 = %q, want 盘上那份副本 %q", md.Data, audio)
}
// URL 刻意留空:`/api/v1/media/` 走 Bearer 鉴权,适配器没有(也不该有)owner 令牌。
if md.URL != "" {
t.Fatalf("出站媒体 URL = %q, want 空(适配器只拿字节)", md.URL)
}
second := env.platform.sends[1]
if !strings.Contains(second.Content, "第二段话") {
t.Fatalf("第二个气泡的正文 = %q, want 第二段", second.Content)
}
if second.ContentType != adapter.ContentText || len(second.Media) != 0 {
t.Fatalf("第二个气泡 = %+v, want 纯文本且无 Media", second)
}
}
// TestOutboundVoiceRefShapes 是 outbound_voice.go 的纯函数面:三种结局与「上游契约被破坏」
// 那一种都必须各归其位 —— 一个没有 mime 的 name 绝不写成 transcribed。
func TestOutboundVoiceRefShapes(t *testing.T) {
t.Parallel()
b, _, _, _ := newTestBridge(t)
const copyName = "0123456789abcdef0123456789abcdef.ogg"
cases := []struct {
name string
voice *loop.OutboundVoice
wantOK bool
wantRef string
wantSize int64
}{
{name: "nil 结论", voice: nil, wantOK: false},
{name: "未请求", voice: &loop.OutboundVoice{Requested: false}, wantOK: false},
{
name: "合成成功且副本完整",
voice: &loop.OutboundVoice{Requested: true, Name: copyName, Mime: outboundVoiceAudioMIME, Size: 8192, DurationMs: 1200},
wantOK: true, wantRef: `{"source":"tts","status":"transcribed","name":"` + copyName + `","mime":"audio/ogg","size":8192,"durationMs":1200}`, wantSize: 8192,
},
{
name: "缺 mime ⇒ 降级 copy_incomplete",
voice: &loop.OutboundVoice{Requested: true, Name: copyName, Size: 8192},
wantOK: true, wantRef: `{"source":"tts","status":"failed","fallbackReason":"copy_incomplete"}`, wantSize: 0,
},
{
name: "名字不是内容寻址名 ⇒ 降级 copy_incomplete",
voice: &loop.OutboundVoice{Requested: true, Name: "../../etc/passwd", Mime: outboundVoiceAudioMIME, Size: 10},
wantOK: true, wantRef: `{"source":"tts","status":"failed","fallbackReason":"copy_incomplete"}`, wantSize: 0,
},
{
name: "请求了但没有音频 ⇒ 降级原因来自上游",
voice: &loop.OutboundVoice{Requested: true, FallbackReason: "budget_exceeded"},
wantOK: true, wantRef: `{"source":"tts","status":"failed","fallbackReason":"budget_exceeded"}`, wantSize: 0,
},
{name: "请求了但既无副本又无原因", voice: &loop.OutboundVoice{Requested: true, CostMs: 90}, wantOK: false},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
ref, ok := b.outboundVoiceRef(tc.voice)
if ok != tc.wantOK {
t.Fatalf("写列 = %v, want %v(ref=%+v)", ok, tc.wantOK, ref)
}
if !ok {
return
}
raw, err := voiceref.Encode(ref)
if err != nil {
t.Fatalf("编码 voice ref: %v", err)
}
if raw != tc.wantRef {
t.Fatalf("voice ref = %s, want %s", raw, tc.wantRef)
}
if ref.Size != tc.wantSize {
t.Fatalf("ref.Size = %d, want %d", ref.Size, tc.wantSize)
}
})
}
}
// voiceRefWithCopy 造一条「盘上应当有一份副本」的 voice 事实。字段按生产口径配齐
// (name/mime/size 同生共死,否则 Validate 会拒绝)—— 名字交给调用方给,本函数不猜。
func voiceRefWithCopy(name string, size int64) voiceref.Ref {
return voiceref.Ref{
Source: voiceref.SourceTTS, Status: voiceref.StatusTranscribed,
Name: name, MIME: outboundVoiceAudioMIME, Size: size,
}
}
// TestOutboundVoiceMediaSkipsUnreadableCopy 钉住「拿不到字节就不挂音频」:附件存储未接线、
// 或副本读不回来时,出站媒体一律不挂(正文照发文本,WARN 由 outbound_voice.go 记下)。
func TestOutboundVoiceMediaSkipsUnreadableCopy(t *testing.T) {
t.Parallel()
const copyName = "0123456789abcdef0123456789abcdef.ogg"
ref := voiceRefWithCopy(copyName, 1)
// ① 附件存储未接线(UploadsDir 与 DataDir 都空)⇒ inboundDocumentStore 返回 nil。
bare, _, _, _ := newTestBridge(t)
if md, ok := bare.outboundVoiceMedia(directMessage(), ref); ok {
t.Fatalf("存储未接线却挂上了出站媒体 = %+v", md)
}
// ② 目录配好了,但盘上没有这份副本 ⇒ 读不回来。
empty := newOutboundVoiceE2E(t, session.Session{SessionID: "sess-1", Owner: true, MemWritable: true})
if md, ok := empty.bridge.outboundVoiceMedia(directMessage(), ref); ok {
t.Fatalf("副本不存在却挂上了出站媒体 = %+v", md)
}
// ③ 副本真在盘上 ⇒ 挂得上,且字节逐字相等。
env := newOutboundVoiceE2E(t, session.Session{SessionID: "sess-1", Owner: true, MemWritable: true})
audio := []byte("OggS-readback")
stored := storeOutboundCopy(t, env.uploads, audio)
md, ok := env.bridge.outboundVoiceMedia(directMessage(), voiceRefWithCopy(stored.Name, stored.Size))
if !ok {
t.Fatalf("副本在盘上却没挂上出站媒体(name=%s)", stored.Name)
}
if !bytes.Equal(md.Data, audio) {
t.Fatalf("读回的字节 = %q, want %q", md.Data, audio)
}
}
+10 -13
View File
@@ -77,12 +77,12 @@ func (b *Bridge) notifyPersisted(msg history.Message) {
// (inbound_documents_persist.go 的两段式)。未装配历史存储时为空操作, // (inbound_documents_persist.go 的两段式)。未装配历史存储时为空操作,
// 因此桥接器在没有存储的情况下行为不变。 // 因此桥接器在没有存储的情况下行为不变。
// //
// 写入本身走 persistMainWith(与主动推送同一个写入口径),通知走普通通道 notifyPersisted。 // 写入本身走 persistMain(与主动推送同一个写入口径),通知走普通通道 notifyPersisted。
// **主动推送不经过本函数**:它直接调 persistMainWith,并额外走 notifyProactive 追加一条带 // **主动推送不经过本函数**:它直接调 persistMain,并额外走 notifyProactive 追加一条带
// 结构化判据的帧(切片 P0b)—— 因此带判据的帧只可能来自主动推送,**普通消息的帧永远 // 结构化判据的帧(切片 P0b)—— 因此带判据的帧只可能来自主动推送,**普通消息的帧永远
// 不带它**。 // 不带它**。
func (b *Bridge) persistToMain(ctx context.Context, role, content, mood, document, image, platform, deviceID, adapterID, turnID string) history.Message { func (b *Bridge) persistToMain(ctx context.Context, role, content, mood, document, image, platform, deviceID, adapterID, turnID string) history.Message {
return b.persistMainWith(ctx, mainRow(role, content, mood, document, image, platform, deviceID, adapterID, turnID), b.notifyPersisted) return b.persistMain(ctx, mainRow(role, content, mood, document, image, platform, deviceID, adapterID, turnID), b.notifyPersisted)
} }
// mainRow 构造一条写进 Web 主会话的行。它只写一次字段清单:persistToMain 与 // mainRow 构造一条写进 Web 主会话的行。它只写一次字段清单:persistToMain 与
@@ -94,16 +94,13 @@ func mainRow(role, content, mood, document, image, platform, deviceID, adapterID
} }
} }
// persistMainWith 是「写进主会话」的**唯一**写入口径(与 persistIsolatedWith 同一手法): // persistMain 是「写进主会话」的**唯一**写入口径:persistToMain(普通消息)与
// persistToMain(普通消息)、主动推送(proactive.go)与入站/出站语音那条「先写 `voice` 列、 // 主动推送(proactive.go)共用它,因此两条路的存储语义逐字一致 —— 未接线历史存储
// 再广播」的路径共用它,因此几条路的存储语义逐字一致 —— 未接线历史存储时空操作并返回零值、 // 时空操作并返回零值、写库失败 WARN 并返回零值(绝不谎称已落库)、成功则返回刚落库的
// 写库失败 WARN 并返回零值(绝不谎称已落库)、成功则返回刚落库的完整消息。 // 完整消息。**差别只在 notify**:那是「这条要不要通知实时通道」的接缝,由调用方给出
// // —— 普通消息传 notifyPersisted(`message_persisted` / `channel_persisted`),主动推送
// **差别只在 notify**:那是「这条要不要通知实时通道、以及先写列还是先广播」的接缝,由调用方 // 传 notifyProactive(带结构化判据的同一类帧,切片 P0b)。nil 表示本次写入不通知。
// 给出 —— 普通消息传 notifyPersisted(`message_persisted` / `channel_persisted`),主动推送 func (b *Bridge) persistMain(ctx context.Context, msg history.Message, notify func(history.Message)) history.Message {
// 传 notifyProactive(带结构化判据的同一类帧,切片 P0b),语音传 persistVoiceFirstNotify
// (先写 `voice` 列再广播,M44 首帧纪律)。nil 表示本次写入不通知。
func (b *Bridge) persistMainWith(ctx context.Context, msg history.Message, notify func(history.Message)) history.Message {
if b.history == nil { if b.history == nil {
return history.Message{} return history.Message{}
} }
+3 -3
View File
@@ -83,7 +83,7 @@ func (b *Bridge) SendProactive(ctx context.Context, msg adapter.UnifiedMessage)
// 把主动消息镜像进主对话上下文(design/16 // 把主动消息镜像进主对话上下文(design/16
// §4.4 + §4.6.1)。否则模型看不到自己主动说了什么, // §4.4 + §4.6.1)。否则模型看不到自己主动说了什么,
// 主人后续回复就会显得不连贯(该消息「丢失」了历史)。 // 主人后续回复就会显得不连贯(该消息「丢失」了历史)。
// 它用的是普通回复那条写入口径(persistMainWith,相同的列、相同的时间戳语义), // 它用的是普通回复那条写入口径(persistMain,相同的列、相同的时间戳语义),
// 作用域恒为 MainSessionID,落库的同样是**规整后**的那一份(Web 与 IM 看到的必须是 // 作用域恒为 MainSessionID,落库的同样是**规整后**的那一份(Web 与 IM 看到的必须是
// 同一份),因此落库行为与本切片之前逐字相同;变的只有通知的第二步(见下)。 // 同一份),因此落库行为与本切片之前逐字相同;变的只有通知的第二步(见下)。
// turn_id 传 ""(零值):主动推送是**她单方面开口**,没有对应的入站回合,也就没有 // turn_id 传 ""(零值):主动推送是**她单方面开口**,没有对应的入站回合,也就没有
@@ -97,13 +97,13 @@ func (b *Bridge) SendProactive(ctx context.Context, msg adapter.UnifiedMessage)
// id),前端按 id 去重 ⇒ 仍是一个气泡、仍只提醒一次。 // id),前端按 id 去重 ⇒ 仍是一个气泡、仍只提醒一次。
// //
// ② 只在**确实落了库**时发:`message_persisted` 的载荷要求权威 id,客户端拿不到 id // ② 只在**确实落了库**时发:`message_persisted` 的载荷要求权威 id,客户端拿不到 id
// 会整帧丢弃 —— 发一条没有 id 的帧等于假装提醒过。落库失败本身已由 persistMainWith // 会整帧丢弃 —— 发一条没有 id 的帧等于假装提醒过。落库失败本身已由 persistMain
// 记 WARN(可见,不静默)。 // 记 WARN(可见,不静默)。
row := history.Message{ row := history.Message{
SessionID: history.MainSessionID, Role: history.RoleAssistant, Content: out.Content, SessionID: history.MainSessionID, Role: history.RoleAssistant, Content: out.Content,
Platform: target.platform, DeviceID: proactiveDeviceID(target), AdapterID: target.platform, Mode: modeChat, Platform: target.platform, DeviceID: proactiveDeviceID(target), AdapterID: target.platform, Mode: modeChat,
} }
if full := b.persistMainWith(ctx, row, b.notifyPersisted); full.ID != "" { if full := b.persistMain(ctx, row, b.notifyPersisted); full.ID != "" {
b.notifyProactive(full) b.notifyProactive(full)
} }
return nil return nil
@@ -6,7 +6,7 @@ import (
// 本文件承载 IM 主动推送那条**实时通知**接缝(切片 P0b「IM 路由的主动消息也要在非前台 // 本文件承载 IM 主动推送那条**实时通知**接缝(切片 P0b「IM 路由的主动消息也要在非前台
// 补通知」)。它与 persist.go 的 notifyPersisted 并列:普通消息走那一条,主动推送走这 // 补通知」)。它与 persist.go 的 notifyPersisted 并列:普通消息走那一条,主动推送走这
// 一条。两条都写主会话,因此「写」是同一份代码(persistMainWith),差别只在通知。 // 一条。两条都写主会话,因此「写」是同一份代码(persistMain),差别只在通知。
// //
// 为什么必须分开而不是去猜:主人裁定「她说话该不该提醒我,与走哪条路由无关」,因此 // 为什么必须分开而不是去猜:主人裁定「她说话该不该提醒我,与走哪条路由无关」,因此
// 主动推送必须自己声明「我是主动推送」。判据是**结构化**的(本次写入走的是哪条通知 // 主动推送必须自己声明「我是主动推送」。判据是**结构化**的(本次写入走的是哪条通知
+6 -22
View File
@@ -9,12 +9,8 @@ package gateway
// //
// 四条硬口径: // 四条硬口径:
// //
// - **`voice` 键存在 = 这条消息有语音事实**:非语音消息整键缺席(`omitempty` 的指针 // - **🎤 判据 = `voice` 键存在**(契约明写):非语音消息整键缺席(`omitempty` 的
// nil),**不是** `{}` 也不是 `null`。客户端绝不从正文里认 `[语音]` 字样。 // 指针 nil),**不是** `{}` 也不是 `null`。客户端绝不从正文里认 `[语音]` 字样。
// ⚠️ 出站合成降级(source=tts、status=failed + fallbackReason)同样**有键**,但
// 没有可播副本(name/mime/size/url 四键一起缺席)⇒ 客户端按 `status` 分支:只挂一个
// 「本来想用语音」的小标、**不**画播放器,也不打「来自语音」的 🎤(正文是文本,
// 不是转写文本)。契约文档对 🎤 判据的措辞由塔统一收口。
// - **副本四键同生共死**:`name`/`mime`/`size`/`url` 只在**真的有副本**时一起出现 // - **副本四键同生共死**:`name`/`mime`/`size`/`url` 只在**真的有副本**时一起出现
// (`Ref.Validate` 在解码时已保证,本文件再用 `HasCopy` 复核一次),绝不出现 // (`Ref.Validate` 在解码时已保证,本文件再用 `HasCopy` 复核一次),绝不出现
// 「`url` 指着一个不存在的 `name`」。 // 「`url` 指着一个不存在的 `name`」。
@@ -33,8 +29,7 @@ package gateway
// ⚠️ 实时帧那条路对**时序**有依赖(已由 M44 的跨层修复保证):`voice` 是**读列**得来的, // ⚠️ 实时帧那条路对**时序**有依赖(已由 M44 的跨层修复保证):`voice` 是**读列**得来的,
// 所以帧必须在 `voice` 列写好之后才发出。渠道桥原来「先 notify、后写列」,导致入站语音的 // 所以帧必须在 `voice` 列写好之后才发出。渠道桥原来「先 notify、后写列」,导致入站语音的
// **第一帧**读不到 voice;M44 把它改成「INSERT → 写 voice 列 → notify」(见 channelbridge // **第一帧**读不到 voice;M44 把它改成「INSERT → 写 voice 列 → notify」(见 channelbridge
// 的 persistBatchUsers / persistVoiceFirstNotify),出站语音(outbound_voice.go)走的是 // 的 persistBatchUsers / persistVoiceFirstNotify),本文件因此无需再区别对待实时帧与读面。
// 同一条顺序纪律。本文件因此无需再区别对待实时帧与读面。
import ( import (
"context" "context"
@@ -50,12 +45,10 @@ import (
// `source` / `status` 恒在(属于闭集,见 `voice.Source` / `voice.Status`);其余 // `source` / `status` 恒在(属于闭集,见 `voice.Source` / `voice.Status`);其余
// 全部 `omitempty` —— 给不出那一项时它**缺席**,绝不用 `0` 或空串编一个值。 // 全部 `omitempty` —— 给不出那一项时它**缺席**,绝不用 `0` 或空串编一个值。
type historyVoice struct { type historyVoice struct {
// Source ∈ asr(服务端识别,含云端)| platform(渠道平台自带转写)| // Source ∈ asr(服务端识别,含云端)| platform(渠道平台自带转写)。
// tts(我们自己合成的出站语音)。
Source string `json:"source"` Source string `json:"source"`
// Status ∈ transcribed(正文即转写文本 / 出站合成成功)| empty(转写成功但没识别出 // Status ∈ transcribed(正文即转写文本)| empty(转写成功但没识别出文字)|
// 文字)| failed(没拿到转写文本,正文是一行中文诚实提示;出站方向 = 合成降级)。 // failed(没拿到转写文本,正文是一行中文诚实提示)。闭集三态。
// 闭集三态。
Status string `json:"status"` Status string `json:"status"`
// Name 是音频副本的存储名;无副本时**整键缺席**(与 mime/size/url 同生共死)。 // Name 是音频副本的存储名;无副本时**整键缺席**(与 mime/size/url 同生共死)。
Name string `json:"name,omitempty"` Name string `json:"name,omitempty"`
@@ -65,10 +58,6 @@ type historyVoice struct {
Size int64 `json:"size,omitempty"` Size int64 `json:"size,omitempty"`
// DurationMs 只有渠道如实给出、或转码产物可确证时才有;读不出来就缺席,绝不估算。 // DurationMs 只有渠道如实给出、或转码产物可确证时才有;读不出来就缺席,绝不估算。
DurationMs int64 `json:"durationMs,omitempty"` DurationMs int64 `json:"durationMs,omitempty"`
// FallbackReason 只在**出站合成降级**时出现(source=tts、status=failed):说明这条
// 消息为什么没能变成语音(能力未接线 / 预算超时 / 合成为空)。正文此时照发文本,
// 客户端据 status=failed 只挂一个小标,**绝不**把它当成可播气泡。
FallbackReason string `json:"fallbackReason,omitempty"`
// URL 是服务端相对路径 `/api/v1/media/<name>`(唯一派生字段,由 MediaPath 一处拼出)。 // URL 是服务端相对路径 `/api/v1/media/<name>`(唯一派生字段,由 MediaPath 一处拼出)。
URL string `json:"url,omitempty"` URL string `json:"url,omitempty"`
} }
@@ -159,11 +148,6 @@ func historyVoiceOf(ref voiceref.Ref) *historyVoice {
return nil return nil
} }
out := &historyVoice{Source: string(ref.Source), Status: string(ref.Status)} out := &historyVoice{Source: string(ref.Source), Status: string(ref.Status)}
// 降级原因只在失败态下发(`Ref.Validate` 已保证它不会挂在其它状态上):客户端据此
// 显示「这条本来想用语音」的小标,正文仍是原文本。
if ref.Status == voiceref.StatusFailed {
out.FallbackReason = ref.FallbackReason
}
if ref.HasCopy() { if ref.HasCopy() {
// 四键同生共死:有副本时 name/mime/size/url 一起在,没有时一起缺席。 // 四键同生共死:有副本时 name/mime/size/url 一起在,没有时一起缺席。
out.Name, out.MIME, out.Size = ref.Name, ref.MIME, ref.Size out.Name, out.MIME, out.Size = ref.Name, ref.MIME, ref.Size