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) } }