From ddd3d00d09c2e8dc1bcf34e3f261c6828f8bedc5 Mon Sep 17 00:00:00 2001 From: AskaEth Date: Tue, 6 Oct 2026 15:59:14 +0800 Subject: [PATCH] =?UTF-8?q?fix:=20=E4=BF=AE=E3=80=8C=E5=81=87=E8=AF=9D?= =?UTF-8?q?=E4=BD=86=E7=BB=BF=E3=80=8D=E7=9A=84=E7=94=A8=E4=BE=8B=20+=20?= =?UTF-8?q?=E8=A1=A5=20VoiceTranscoder=20=E6=B3=A8=E5=85=A5=EF=BC=88W4?= =?UTF-8?q?=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - history_toolcalls_gap_test.go 改成正向断言:单路回合经 StartRoundWithTurnID 带权威 turnId ⇒ 落库行按该标识查得到 ⇒ HTTP 出口 toolCalls 在场;并保留「无权威标识 ⇒ 整体缺席」边界用例 - 组合根补 VoiceTranscoder: a.transcoder ⇒ 微信/QQ 的 AMR 语音在落盘前先转 wav 再落副本 - 4 个变异自检(删注入/摘 ctx 标识/清 turn_id/调换 wiring 顺序,均红) --- cmd/cyrene/wire_channels.go | 6 + cmd/cyrene/wire_channels_test.go | 68 ++++++++ .../gateway/history_toolcalls_gap_test.go | 164 +++++++++++++----- 3 files changed, 196 insertions(+), 42 deletions(-) create mode 100644 cmd/cyrene/wire_channels_test.go diff --git a/cmd/cyrene/wire_channels.go b/cmd/cyrene/wire_channels.go index 5b5bdf2..15cc107 100644 --- a/cmd/cyrene/wire_channels.go +++ b/cmd/cyrene/wire_channels.go @@ -133,6 +133,12 @@ func (a *app) wireChannels() error { // a.voice 同时服务 Web 侧 WS 的 voice_input(同一条降级链,不另建一条)。 VoiceSTT: a.voice, VoiceSTTTimeout: time.Duration(a.cfg.Voice.VoiceSTTTimeoutSec) * time.Second, + // VoiceTranscoder 把本地转码引擎交给入站语音副本链(design/25 §7.1 第 ④ 步; + // 裁决 11):**不可播格式(微信/QQ 的 AMR)必须先转成可播副本再落盘**,否则 + // 那条语音只能不落副本(`category=transcode_not_wired` 的诚实 WARN),客户端 + // 便拿不到可回放的字节。注入的是 `wireTranscode` 装配的**同一个**引擎实例 + // (与工具面的 `transcode` 工具同源),不是第二条降级链。 + VoiceTranscoder: a.transcoder, }) if berr != nil { return fmt.Errorf("channel bridge: %w", berr) diff --git a/cmd/cyrene/wire_channels_test.go b/cmd/cyrene/wire_channels_test.go new file mode 100644 index 0000000..a93aefa --- /dev/null +++ b/cmd/cyrene/wire_channels_test.go @@ -0,0 +1,68 @@ +package main + +import ( + "os" + "reflect" + "strings" + "testing" + + "git.yeij.top/AskaEth/Cyrene_v2/internal/capability/transcode" + "git.yeij.top/AskaEth/Cyrene_v2/internal/services/channelbridge" +) + +// TestWireChannels_InjectsVoiceTranscoder 是一道「接线存在性」防回归检查 +// (裁决 11;与同目录 `TestWireChannels_WiresInboundDocumentStore` 同一手法)。 +// +// 钉住的事实:组合根把 `wireTranscode` 装配的本地转码引擎交给渠道 bridge,于是**不可播 +// 格式的入站语音(微信 / QQ 的 AMR)在落副本之前先被转成可播副本** +// (design/25 §7.1 第 ④ 步)。少了这一行,bridge 按 nil 走它的诚实路径 —— 那句 +// `category=transcode_not_wired` 的 WARN 照记、消息一个字不丢,**但那条语音不落副本** +// ⇒ 客户端拿不到可回放的字节,而**没有任何既有用例会变红**。所以必须显式钉住。 +// +// 为什么是源码级检查而不是端到端:组合根本身无法在单元测试里实例化(开库、迁移、绑端口, +// 而后端还在运行);退化的行为侧(「转码不可用 ⇒ 不落副本」)已由 +// `internal/services/channelbridge/inbound_voice_copy_test.go` 覆盖,这里要钉的只是 +// **那一行注入**(以及它的**时机**,见下)。 +func TestWireChannels_InjectsVoiceTranscoder(t *testing.T) { + t.Parallel() + + src := readSource(t, "wire_channels.go") + + // 断言的是**一整行字段赋值**(行首到行尾),因此被注释掉的那一行 + // (`\t\t// VoiceTranscoder: …`)或写在别处的同名字符串都不会让它通过。 + const assignment = "\n\t\tVoiceTranscoder: a.transcoder," + if !strings.Contains(src, assignment) { + t.Fatalf("wire_channels.go 里找不到 %q —— 入站语音的 AMR 不会被转成可播副本,"+ + "微信语音因此不落副本(design/25 §7.1 第 ④ 步 / 裁决 11)", strings.TrimSpace(assignment)) + } + + // 注入面必须真装得下我们交进去的那个值:字段类型与 `a.transcoder` 的静态类型同源, + // 否则「接线成功」这句话就只是字面巧合(例如字段被改成一条自定义窄接口)。 + field, ok := reflect.TypeOf(channelbridge.Options{}).FieldByName("VoiceTranscoder") + if !ok { + t.Fatal("channelbridge.Options 没有 VoiceTranscoder 字段:注入面无处可放(设计改了?)") + } + if want := reflect.TypeOf((*transcode.Transcoder)(nil)).Elem(); field.Type != want { + t.Fatalf("channelbridge.Options.VoiceTranscoder 的静态类型 = %v,want %v", field.Type, want) + } + + // 注入的**时机**同样要钉住:`a.transcoder` 由 `wireTranscode()`(在 `wireRuntime` 里) + // 赋值,所以 `wireRuntime` 必须先于 `wireChannels` 执行。反过来的话上面那行注入的是 + // **nil** —— 一切照旧、一条日志都不差,只有「AMR 语音落副本」这条能力静默消失。 + app := readSource(t, "wire.go") + idxRuntime, idxChannels := strings.Index(app, "a.wireRuntime()"), strings.Index(app, "a.wireChannels()") + if idxRuntime < 0 || idxChannels < 0 || idxRuntime > idxChannels { + t.Fatalf("wire.go 里 wireRuntime / wireChannels 的先后不对(偏移 %d vs %d):转码引擎必须"+ + "在渠道桥被构造**之前**装配好,否则 VoiceTranscoder 注入的是 nil", idxRuntime, idxChannels) + } +} + +// readSource 读一个组合根源文件并把行尾归一成 LF(断言里的 `\n` 因此与平台无关)。 +func readSource(t *testing.T, name string) string { + t.Helper() + raw, err := os.ReadFile(name) + if err != nil { + t.Fatalf("read %s: %v", name, err) + } + return strings.ReplaceAll(string(raw), "\r\n", "\n") +} diff --git a/internal/services/gateway/history_toolcalls_gap_test.go b/internal/services/gateway/history_toolcalls_gap_test.go index c65c3bc..50d1b70 100644 --- a/internal/services/gateway/history_toolcalls_gap_test.go +++ b/internal/services/gateway/history_toolcalls_gap_test.go @@ -1,26 +1,30 @@ package gateway -// 本文件钉住**单路回合的工具回看面缺口**(切片 CODE-TC 的诚实边界 —— 本用例钉的是 -// **现状,不是期望行为**)。 +// 本文件钉住**单路回合的工具回看面覆盖率**(切片 CODE-TC 的覆盖率补齐,由 CODE-TC-2 的 +// 写侧改动解锁)。 // -// 事实链(三处代码,全部可核对): +// 历史(为什么本文件曾经是**反向**断言):覆盖率原先只到真并行路 —— 发布器建图时把 +// 「这一路的路标识」取自 `loop.flowOwner` → `ParallelLegFrom(ctx)` +// (`internal/runtime/loop/flow_branch.go`),而 `WithParallelLeg` 只给真并行路挂 +// (`internal/services/gateway/session_parallel.go` 的 `beginLegLocked`),普通回合 / +// 队首续跑路都不挂。于是普通回合的图 `TurnID == ""`、落盘 `flow_graphs.lane_turn_id` +// 为空 ⇒ 按消息的 `turn_id` 查不到图 ⇒ `toolCalls` 整体缺席。当时本文件只能如实钉住 +// 那个缺口(自称「现状,不是期望行为」)。 // -// 1. 发布器建图时把「这一路的路标识」取自 `loop.flowOwner` → `ParallelLegFrom(ctx)` -// (`internal/runtime/loop/flow_branch.go`); -// 2. `WithParallelLeg` **只给真并行路**挂(`internal/services/gateway/session_parallel.go` -// 的 `beginLegLocked`:`if parallel { ... }`),普通回合 / 队首续跑路都不挂; -// 3. 于是普通回合的图 `TurnID == ""`(既有回归 `internal/runtime/loop/flow_lane_test.go` -// 的 `TestFlowPublisher_SingleLegUnchanged` 正是这么钉的),落盘后 -// `flow_graphs.lane_turn_id` 为空 ⇒ 按消息的 `turn_id` **查不到图** ⇒ `toolCalls` -// 整体缺席(这是契约允许的「图取不到」,但**不是**我们要的覆盖率)。 +// CODE-TC-2 把生产建图点切成 `StartRoundWithTurnID(ctx, …)`(权威回合标识取自 +// `loop.TurnIDFrom(ctx)`)⇒ 编排器驱动的**每一路**(普通单路回合、队首续跑路、真并行路) +// 的图都带上与 `chat_messages.turn_id` 同值的 `turnId`。覆盖率因此补齐,本文件改成正向 +// 断言,并**同时**保留那条诚实边界 —— 少了任何一半,这个文件都会开始说谎: // -// 因此本特性在当前代码下只覆盖**真并行路**的回复。要覆盖普通回合,必须让发布器把 -// **ctx 上的权威 turnId**(`loop.TurnIDFrom`,普通回合也有)盖在图上 —— 那是 -// `internal/runtime/loop/**` 的改动,**不属于本切片的面**,故此处只如实钉住。 +// - **正向**:走**生产那条入口**(ctx 上挂权威回合标识)的单路回合,其回复**能**回看 +// 到工具; +// - **诚实边界**:拿不到权威标识的写入路径(后台思考、未接编排器的调用方)仍**缺席** +// —— 图取不到 ⇒ 字段整体缺席,**绝不**回 `[]` 冒充「这一轮没调过工具」,也绝不按 +// 时间窗或正文反解。 // -// ⚠️ 本用例**红了**只可能意味着 loop 已经把权威 turnId 盖上了单路图:那时请把它改成 -// 「单路回合也能回看到工具」的**正向断言**(并把 `loop` 侧那条改动一并交代), -// **不要**删掉它。 +// ⚠️ 边界用例走的是**老入口** `pub.StartRound(owner, text)`(没有 ctx 通道 ⇒ 图上该键 +// 缺席)。它今天服务的是「没有 ctx 通道的调用方」这一档;生产建图点 +// (`internal/runtime/loop/run.go`)走的是 `StartRoundWithTurnID`。 import ( "context" @@ -29,31 +33,113 @@ import ( "git.yeij.top/AskaEth/Cyrene_v2/internal/auth" "git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/history" + "git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/loop" "git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/trace" ) -// TestSingleLegRoundLacksLaneTurnID_现状缺口 演练一次**普通回合**(没有 -// `WithParallelLeg`,与生产里 Web 单路聊天逐字同形):它的图确实落了盘、也确实带工具 -// 节点,但图上没有路标识 ⇒ 消息侧无论用哪个值都查不到它 ⇒ `toolCalls` 缺席。 -func TestSingleLegRoundLacksLaneTurnID_现状缺口(t *testing.T) { - t.Parallel() - env := newToolCallEnv(t, Deps{}, nil, true) - ctx := context.Background() +// gapTestTurnID 是本文件用的**权威回合标识**:生产里由编排器生成,一份挂进 ctx、一份写 +// 进消息行的 `chat_messages.turn_id` —— 两者逐字同值正是「按消息回看工具」的前提。 +const gapTestTurnID = "turn-abc" - // 走生产那条落盘路径(发布器 → flowgraph.SQLStore.RecordRound)。 - pub := flowPublisherWithStore(env.store, func(string) bool { return true }) - pub.StartRound(history.MainSessionID, "问题") +// finishToolSpan 让一个**已完成的工具 span** 走完发布器(起 → 补 props → 止),形状与 +// 生产写入侧一致:`tool` 是工具名、`result` 是工具返回的原始结果(出图时会被洗成 +// `result_summary`)。 +func finishToolSpan(pub *loop.FlowPublisher, session string) { sp := &trace.Span{ ID: "span-tool", Name: "tool.web_search", Kind: trace.SpanKindTool, StartedAt: time.Now(), // 生产里的 span 由 Tracer 建(Props 已初始化);这里手搓一个,必须自己给 map, // 否则 SetProp 会往 nil map 里写。 Props: map[string]any{}, } - pub.SpanStarted(history.MainSessionID, sp) + pub.SpanStarted(session, sp) _ = sp.SetProp("tool", "web_search") _ = sp.SetProp("result", "晴,18~26℃") sp.Finish(trace.SpanStatusOK) - pub.SpanFinished(history.MainSessionID, sp) + pub.SpanFinished(session, sp) +} + +// seedAssistantReply 落一条**带该回合标识**的助手消息:消息面的 `toolCalls` 正是拿它的 +// `turn_id` 与 `flow_graphs.lane_turn_id` 等值对上(`attachToolCalls` → `GraphsByTurnID`)。 +func seedAssistantReply(t *testing.T, env *toolCallEnv, turnID string) { + t.Helper() + if err := env.history.Append(context.Background(), history.Message{ + SessionID: history.MainSessionID, Role: history.RoleAssistant, Content: "答复", TurnID: turnID, CreatedAt: 1000, + }); err != nil { + t.Fatalf("seed: %v", err) + } +} + +// TestSingleLegRoundExposesToolCalls_正常 走**生产那条入口**(`StartRoundWithTurnID` + ctx +// 上的权威回合标识),断言普通单路回合的回复**能**回看到工具。 +// +// 三处断言按因果链排列,缺任一条都说明链条断了(而不是「碰巧绿了」):① 内存快照带权威 +// 标识;② 落盘行按该标识**等值**查得到、且图里真有工具节点;③ HTTP 出口上 `toolCalls` +// **在场**且内容逐字来自图。 +func TestSingleLegRoundExposesToolCalls_正常(t *testing.T) { + t.Parallel() + env := newToolCallEnv(t, Deps{}, nil, true) + ctx := context.Background() + + // 走生产那条落盘路径(发布器 → flowgraph.SQLStore.RecordRound)。 + pub := flowPublisherWithStore(env.store, func(string) bool { return true }) + pub.StartRoundWithTurnID(loop.WithTurnID(ctx, gapTestTurnID), history.MainSessionID, "问题") + finishToolSpan(pub, history.MainSessionID) + pub.EndRound(history.MainSessionID, "答复") + + // ① 这一路带上了权威回合标识(改前这里恒为空 —— 那正是缺口)。 + snap := pub.LastGraph(history.MainSessionID) + if snap == nil { + t.Fatal("单路回合应当建出一张图") + } + if snap.TurnID != gapTestTurnID { + t.Fatalf("单路图的 turnId = %q, want %q(权威回合标识必须取自 ctx,绝不拿别处顶替)", + snap.TurnID, gapTestTurnID) + } + + // ② 落盘行按消息那个 turn_id 查得到,且投影出的工具就是这一轮真调过的那一个。 + found, err := env.store.GraphsByTurnID(ctx, history.MainSessionID, []string{gapTestTurnID}) + if err != nil { + t.Fatalf("GraphsByTurnID: %v", err) + } + stored := found[gapTestTurnID] + if stored == nil { + t.Fatalf("按 %q 查不到这一路的图(lane_turn_id 没落上?查回 %d 条)", gapTestTurnID, len(found)) + } + if calls := toolCallsOf(stored); len(calls) != 1 || calls[0].Name != "web_search" || calls[0].Status != toolCallStatusOK { + t.Fatalf("落盘图投影出的工具 = %+v,want 1 条 web_search/ok", calls) + } + + // ③ 端到端:这条普通回合的回复在读接口上**带** toolCalls(在场,不是空数组)。 + seedAssistantReply(t, env, gapTestTurnID) + tok := signAny(t, env.jwt, "acct", auth.RoleOwner) + raw := env.pageMessages(t, tok)["答复"] + if _, present := raw["toolCalls"]; !present { + t.Fatal("单路回合应当能回看到工具(toolCalls 在场;缺席说明图没带上权威回合标识)") + } + calls := decodeToolCalls(t, "答复", raw) + if len(calls) != 1 || calls[0]["name"] != "web_search" || calls[0]["status"] != "ok" { + t.Fatalf("回复的 toolCalls = %v,want 1 条 web_search/ok", calls) + } + if got := calls[0]["resultSummary"]; got != "晴,18~26℃" { + t.Fatalf("resultSummary = %v,want 图上那份逐字搬运的已脱敏结果", got) + } +} + +// TestSingleLegNoAuthorityKeepsToolCallsAbsent_边界 钉住**诚实边界**:拿不到权威标识的 +// 写入路径(老入口、后台思考、未接编排器的调用方)即便真调过工具,消息面也**只能**得到 +// 「图取不到」这一个结果 —— `toolCalls` **整体缺席**。 +// +// 它是正向用例的对照面:**不是**「覆盖率没补齐」,而是「这一路确实没有可用作回看依据的 +// 图」。缺了这条,正向用例一旦因为别的原因恒在场(例如读侧回空数组),就没人能发现。 +func TestSingleLegNoAuthorityKeepsToolCallsAbsent_边界(t *testing.T) { + t.Parallel() + env := newToolCallEnv(t, Deps{}, nil, true) + ctx := context.Background() + + // 老入口:**没有 ctx 通道**,因此这一路取不到权威回合标识。 + pub := flowPublisherWithStore(env.store, func(string) bool { return true }) + pub.StartRound(history.MainSessionID, "问题") + finishToolSpan(pub, history.MainSessionID) pub.EndRound(history.MainSessionID, "答复") snap := pub.LastGraph(history.MainSessionID) @@ -61,29 +147,23 @@ func TestSingleLegRoundLacksLaneTurnID_现状缺口(t *testing.T) { t.Fatal("单路回合应当建出一张图") } if snap.TurnID != "" { - t.Fatalf("现状已变:单路图带上了 turnId=%q(loop 已盖上权威回合标识)—— 请把本用例改成"+ - "「单路也能回看」的正向断言,不要删掉它", snap.TurnID) + t.Fatalf("老入口本不该给图带上 turnId,得到 %q(若 loop 侧改了语义,请连同本用例的"+ + "前提一起交代,不要只删断言)", snap.TurnID) } - // 库里那一行的 `lane_turn_id` 为空:拿消息侧的 turnId 查不到,拿图 id 也查不到 - // (两者不同源,这正是 v0.76 登记的阻塞,只被 v0.77 解掉了一半)。 - found, err := env.store.GraphsByTurnID(ctx, history.MainSessionID, []string{"turn-abc", snap.ID}) + // 按消息侧那个标识查不到 —— 图 id 与消息的 turn_id 不同源,这正是缺口本身。 + found, err := env.store.GraphsByTurnID(ctx, history.MainSessionID, []string{gapTestTurnID, snap.ID}) if err != nil { t.Fatalf("GraphsByTurnID: %v", err) } if len(found) != 0 { - t.Fatalf("单路图竟然按路查到了:%v(现状已变,请更新本用例)", found) + t.Fatalf("没有权威标识的图竟然按路查到了:%v(诚实边界已变,请更新本用例)", found) } - // 端到端:这条普通回合的回复在读接口上没有 toolCalls(缺席,不是空数组)。 - if err := env.history.Append(ctx, history.Message{ - SessionID: history.MainSessionID, Role: history.RoleAssistant, Content: "答复", TurnID: "turn-abc", CreatedAt: 1000, - }); err != nil { - t.Fatalf("seed: %v", err) - } + seedAssistantReply(t, env, gapTestTurnID) tok := signAny(t, env.jwt, "acct", auth.RoleOwner) raw := env.pageMessages(t, tok)["答复"] if v, present := raw["toolCalls"]; present { - t.Fatalf("单路回合本应查不到图(缺口),却给出了 toolCalls=%s", v) + t.Fatalf("图取不到时 toolCalls 必须整体缺席(绝不回空数组),却给出了 %s", v) } }