Files
Cyrene_v2/internal/services/gateway/history_toolcalls_gap_test.go
T
AskaEth ddd3d00d09 fix: 修「假话但绿」的用例 + 补 VoiceTranscoder 注入(W4)
- history_toolcalls_gap_test.go 改成正向断言:单路回合经 StartRoundWithTurnID 带权威 turnId
  ⇒ 落库行按该标识查得到 ⇒ HTTP 出口 toolCalls 在场;并保留「无权威标识 ⇒ 整体缺席」边界用例
- 组合根补 VoiceTranscoder: a.transcoder ⇒ 微信/QQ 的 AMR 语音在落盘前先转 wav 再落副本
- 4 个变异自检(删注入/摘 ctx 标识/清 turn_id/调换 wiring 顺序,均红)
2026-10-06 15:59:14 +08:00

170 lines
8.5 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package gateway
// 本文件钉住**单路回合的工具回看面覆盖率**(切片 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` 整体缺席。当时本文件只能如实钉住
// 那个缺口(自称「现状,不是期望行为」)。
//
// CODE-TC-2 把生产建图点切成 `StartRoundWithTurnID(ctx, …)`(权威回合标识取自
// `loop.TurnIDFrom(ctx)`)⇒ 编排器驱动的**每一路**(普通单路回合、队首续跑路、真并行路)
// 的图都带上与 `chat_messages.turn_id` 同值的 `turnId`。覆盖率因此补齐,本文件改成正向
// 断言,并**同时**保留那条诚实边界 —— 少了任何一半,这个文件都会开始说谎:
//
// - **正向**:走**生产那条入口**(ctx 上挂权威回合标识)的单路回合,其回复**能**回看
// 到工具;
// - **诚实边界**:拿不到权威标识的写入路径(后台思考、未接编排器的调用方)仍**缺席**
// —— 图取不到 ⇒ 字段整体缺席,**绝不**回 `[]` 冒充「这一轮没调过工具」,也绝不按
// 时间窗或正文反解。
//
// ⚠️ 边界用例走的是**老入口** `pub.StartRound(owner, text)`(没有 ctx 通道 ⇒ 图上该键
// 缺席)。它今天服务的是「没有 ctx 通道的调用方」这一档;生产建图点
// (`internal/runtime/loop/run.go`)走的是 `StartRoundWithTurnID`。
import (
"context"
"testing"
"time"
"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"
)
// gapTestTurnID 是本文件用的**权威回合标识**:生产里由编排器生成,一份挂进 ctx、一份写
// 进消息行的 `chat_messages.turn_id` —— 两者逐字同值正是「按消息回看工具」的前提。
const gapTestTurnID = "turn-abc"
// 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(session, sp)
_ = sp.SetProp("tool", "web_search")
_ = sp.SetProp("result", "晴,18~26℃")
sp.Finish(trace.SpanStatusOK)
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)
if snap == nil {
t.Fatal("单路回合应当建出一张图")
}
if snap.TurnID != "" {
t.Fatalf("老入口本不该给图带上 turnId,得到 %q(若 loop 侧改了语义,请连同本用例的"+
"前提一起交代,不要只删断言)", snap.TurnID)
}
// 按消息侧那个标识查不到 —— 图 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)
}
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)
}
}