Files
Cyrene_v2/internal/services/gateway/ws_tool_result_summary_test.go
T
AskaEth 215eedf8e9 feat(ws): tool_execution.end 带可选 resultSummary(Y1,答应安卓端的那条)
- 形状:data.resultSummary(可选)。与回看面 toolCalls[].resultSummary 代码级同源:同一个常量
  (500 rune)与同一个截断函数,摘要由 **loop 侧**生成(trace.RedactText + renderToolResultSummary)
  再随 StreamEvent 下发,gateway 只搬运不加工(与回看面同口径)
- 缺席语义:结果真拿到才带;失败/取消/超时/未落图 ⇒ 整键缺席(绝不用空串顶位)
- 线上帧证据:真 WS + 真 JWT + 真 loop ⇒ 客户端收到 resultSummary;同源钉用真图快照反读逐字比对
- 3 个变异(空摘要也写键/去掉脱敏/失败当结果)
⚠️ 待裁:失败路径现按派工书「整键缺席」,但回看面对失败给的是 error 摘要 ⇒ 实时无结果区、事后有;
   若要实时也显示失败原因,一行可改(toolEndFrameSummary 的 return "")
2026-10-06 21:48:48 +08:00

174 lines
6.9 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
import (
"context"
"encoding/json"
"strings"
"testing"
"git.yeij.top/AskaEth/Cyrene_v2/internal/auth"
"git.yeij.top/AskaEth/Cyrene_v2/internal/bus"
"git.yeij.top/AskaEth/Cyrene_v2/internal/capability/registry"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/agent"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/loop"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/model"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/tool"
)
// 本文件钉住 `tool_execution.end` 帧上的 `data.resultSummary`(契约 v0.82):
// 安卓端按「键在场 ⇒ 渲染结果区、缺席 ⇒ 只显示工具名与状态」编程,因此这里要同时锁住
// ① 成功路径经**真实生产链路**(真 loop → bus → relay → broadcastStream)到达客户端时
// 在场且文本正确;② 空摘要 ⇒ 键**整键缺席**(绝不回空串顶位);③ 只有 end 帧带它;
// ④ 超长按与回看面**同一个**上限(maxToolCallSummaryRunes)截断,末位 `…`。
// TestStreamFramesToolExecutionEndResultSummary 是端到端锁定:驱动一个真工具回合,
// end 帧必须带工具返回值的已脱敏摘要,start 帧不带。
func TestStreamFramesToolExecutionEndResultSummary(t *testing.T) {
t.Parallel()
frames, _ := runEventsTurn(t)
byType := eventsByType(t, frames)
endData := dataMap(t, byType["tool_execution.end"][0])
want := `{"now":"2026-10-04 12:00:00"}`
if got, _ := endData["resultSummary"].(string); got != want {
t.Fatalf("tool_execution.end resultSummary = %v, want %q(工具返回值的渲染)", endData["resultSummary"], want)
}
if startData := dataMap(t, byType["tool_execution.start"][0]); startData["resultSummary"] != nil {
t.Fatalf("tool_execution.start 带了 resultSummary %v;摘要只在结果落定那一刻存在",
startData["resultSummary"])
}
}
// TestFrameResultSummaryShapeMatrix 钉住载荷形状的四个边界(直接经生产映射函数
// streamEventData,不经 hub):在场语义、缺席语义、end-only、上限。
func TestFrameResultSummaryShapeMatrix(t *testing.T) {
t.Parallel()
t.Run("成功_键在场且逐字搬运", func(t *testing.T) {
t.Parallel()
_, data := streamEventData(model.StreamEvent{
Kind: model.StreamKindToolExecutionEnd, Tool: "web_search",
Phase: model.ToolPhaseDone, ResultSummary: "晴,18~26℃",
})
if got, _ := data["resultSummary"].(string); got != "晴,18~26℃" {
t.Fatalf("resultSummary = %v, want 晴,18~26℃", data["resultSummary"])
}
})
t.Run("无结果_键整键缺席", func(t *testing.T) {
t.Parallel()
// 失败 / 取消 / 超时:loop 侧留空 ⇒ 这里必须让键**缺席**,
// 绝不回空串(空串会让安卓端渲染出一个空的结果区)。
_, data := streamEventData(model.StreamEvent{
Kind: model.StreamKindToolExecutionEnd, Tool: "web_search",
Phase: model.ToolPhaseError,
})
if v, ok := data["resultSummary"]; ok {
t.Fatalf("空摘要仍然出了键 resultSummary=%v;缺席语义要求整键缺席", v)
}
})
t.Run("非end帧_不带摘要", func(t *testing.T) {
t.Parallel()
for _, kind := range []model.StreamKind{
model.StreamKindToolExecutionStart, model.StreamKindToolExecutionUpdate,
} {
_, data := streamEventData(model.StreamEvent{
Kind: kind, Tool: "web_search", Phase: model.ToolPhaseRunning,
// 防御性:即便有生产者误把摘要挂到 start/update 上,帧也不得带它。
ResultSummary: "不该出现在这里",
})
if v, ok := data["resultSummary"]; ok {
t.Fatalf("%s 帧带了 resultSummary=%v;契约只在 end 帧给结果", kind, v)
}
}
})
t.Run("超长_按同一份上限截断", func(t *testing.T) {
t.Parallel()
raw := strings.Repeat("备", maxToolCallSummaryRunes+100)
_, data := streamEventData(model.StreamEvent{
Kind: model.StreamKindToolExecutionEnd, Tool: "web_search",
Phase: model.ToolPhaseDone, ResultSummary: raw,
})
got, _ := data["resultSummary"].(string)
if n := len([]rune(got)); n > maxToolCallSummaryRunes {
t.Fatalf("resultSummary 长度 = %d rune,超过与回看面共用的上限 %d", n, maxToolCallSummaryRunes)
}
if !strings.HasSuffix(got, "…") {
t.Fatalf("被截断的 resultSummary 必须以省略号结尾(长度 %d rune)", len([]rune(got)))
}
})
t.Run("恰好等于上限_不截断", func(t *testing.T) {
t.Parallel()
raw := strings.Repeat("备", maxToolCallSummaryRunes)
_, data := streamEventData(model.StreamEvent{
Kind: model.StreamKindToolExecutionEnd, Tool: "web_search",
Phase: model.ToolPhaseDone, ResultSummary: raw,
})
if got, _ := data["resultSummary"].(string); got != raw {
t.Fatalf("恰好 %d rune 的摘要被改动了(长度 %d)", maxToolCallSummaryRunes, len([]rune(got)))
}
})
}
// TestWSToolExecutionEndResultSummaryOverTheWire 是**线上**锁定:真 HTTP/WS 服务
// (`New` + `httptest` + 真 JWT + 真帧门 + 真 hub 中继)+ 真 loop + 真工具 + 真 bus,
// 客户端发一条 chat 帧、跑完一个工具回合,并从连接上读回 `tool_execution.end` 帧 ——
// 断言它经 JSON 编码抵达客户端时 `data.resultSummary` 在场且文本正确。
// 与 in-process hub 测试的分工:那个钉映射函数,这个钉「帧真的能到客户端手里」。
func TestWSToolExecutionEndResultSummaryOverTheWire(t *testing.T) {
t.Parallel()
b := bus.New()
reg := registry.New()
td, err := tool.New(reg)
if err != nil {
t.Fatalf("tool.New: %v", err)
}
if err := td.Register(wsEventsTool{}); err != nil {
t.Fatalf("register tool: %v", err)
}
ag, err := agent.New("You are a helpful assistant.", td, nil, agent.WithModel(&wsEventsAdapter{}, nil))
if err != nil {
t.Fatalf("agent.New: %v", err)
}
lp := loop.New(loop.Options{
Agent: ag,
MaxRounds: 8,
OnStream: func(ev model.StreamEvent) {
_ = b.Publish(context.Background(), wsStreamSubject, ev)
},
})
srv, ts, jwt := buildEnv(t, cfgDefault(), Deps{
Loop: lp, Bus: b, History: newReplayHistoryStore(), Streaming: true,
})
tok := signAny(t, jwt, "acct", auth.RoleOwner)
conn := dialWS(t, ts.URL, tok)
defer conn.Close()
waitConnCount(t, srv, 1)
if err := conn.WriteJSON(wsInbound{Type: "chat", Data: json.RawMessage(`{"sessionId":"web","text":"time?"}`)}); err != nil {
t.Fatalf("write chat: %v", err)
}
frames := readChatFramesUntil(t, conn, "tool_execution.end")
var end *wsEvent
for i := range frames {
if frames[i].Type == "tool_execution.end" {
end = &frames[i]
}
}
if end == nil {
t.Fatalf("线上没有收到 tool_execution.end 帧(已见 %v)", chatFrameTypes(frames))
}
data, ok := end.Data.(map[string]any)
if !ok {
t.Fatalf("tool_execution.end 载荷 = %#v,want 对象", end.Data)
}
want := `{"now":"2026-10-04 12:00:00"}`
if got, _ := data["resultSummary"].(string); got != want {
t.Fatalf("线上 tool_execution.end.resultSummary = %v, want %q(工具返回值的渲染)", data["resultSummary"], want)
}
}