- 形状:data.resultSummary(可选)。与回看面 toolCalls[].resultSummary 代码级同源:同一个常量
(500 rune)与同一个截断函数,摘要由 **loop 侧**生成(trace.RedactText + renderToolResultSummary)
再随 StreamEvent 下发,gateway 只搬运不加工(与回看面同口径)
- 缺席语义:结果真拿到才带;失败/取消/超时/未落图 ⇒ 整键缺席(绝不用空串顶位)
- 线上帧证据:真 WS + 真 JWT + 真 loop ⇒ 客户端收到 resultSummary;同源钉用真图快照反读逐字比对
- 3 个变异(空摘要也写键/去掉脱敏/失败当结果)
⚠️ 待裁:失败路径现按派工书「整键缺席」,但回看面对失败给的是 error 摘要 ⇒ 实时无结果区、事后有;
若要实时也显示失败原因,一行可改(toolEndFrameSummary 的 return "")
174 lines
6.9 KiB
Go
174 lines
6.9 KiB
Go
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)
|
||
}
|
||
}
|