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 "")
This commit is contained in:
AskaEth committed 2026-10-06 21:48:48 +08:00
1 parent a820d9968e
commit 215eedf8e9
6 files changed
+433 -7

No files matched your search

+29 -1
View File
@@ -204,7 +204,9 @@ func withStreamID(data map[string]any, ev model.StreamEvent) map[string]any {
// toolExecutionData 构建 tool_execution.* 帧的载荷:工具名与细粒度
// phase 为结构化字段(phase 绝不塞进 delta,design/04 §1.1 裁决
// 01.4.1);id 复用 provider 的 tool call id(可关联 tool_call.* 帧),
// update 帧可再携带 delta 局部输出。键在空值时省略。
// update 帧可再携带 delta 局部输出。end 帧在**结果真的拿到**时带
// resultSummary(同一份已脱敏文本、同一份上限预算,见 frameResultSummary)。
// 键在空值时省略。
func toolExecutionData(ev model.StreamEvent) map[string]any {
data := map[string]any{"tool": ev.Tool, "phase": ev.Phase}
if ev.ID != "" {
@@ -213,9 +215,35 @@ func toolExecutionData(ev model.StreamEvent) map[string]any {
if ev.Kind == model.StreamKindToolExecutionUpdate && ev.Delta != "" {
data["delta"] = ev.Delta
}
if s := frameResultSummary(ev); s != "" {
data["resultSummary"] = s
}
return data
}
// frameResultSummary 取 tool_execution.end 帧上的结果摘要(契约 v0.82)。它只认
// end 帧,且只在 loop 真的填了摘要时给值 —— loop 侧
// (`internal/runtime/loop/tool_frame_summary.go`)已走**图侧同一条脱敏路径**
// (放行表 + 凭据形状清洗,即 `trace.RedactText`),因此这里与回看面同一口径:
// **只截断、不做二次脱敏**(二次脱敏会掩盖「写侧漏了」这个真问题),也绝不回上游
// 原文 —— 帧上根本没有原文可回,摘要就是 loop 给的这一份。
//
// 上限与回看面 `toolCalls[].resultSummary` **共用同一个常量与同一个截断函数**
// (`maxToolCallSummaryRunes` / `truncateToolSummary`,见 history_toolcalls.go):
// 「同一份预算」是代码级复用而不是口头约定。它在这里是**独立兜底** —— 正常路径上
// 图侧 RedactText 已把长度收到 ≤80 rune,只有写入侧放宽或出现绕过它的生产者时
// 这道上限才生效(同 history_toolcalls.go 对 argsSummary 的理由)。
//
// 🔴 缺席语义:空串 ⇒ 该键**整键缺席**,绝不用空串顶位 —— 安卓端按「键在场 ⇒ 渲染
// 结果区,缺席 ⇒ 只显示工具名与状态」编程,一个空的 resultSummary 会让它渲染出
// 一个空的结果区。
func frameResultSummary(ev model.StreamEvent) string {
if ev.Kind != model.StreamKindToolExecutionEnd {
return ""
}
return truncateToolSummary(ev.ResultSummary)
}
// isBroadcastAgentEvent 报告 subject 是否恰为 4 段广播形态
// cyrene.agent.<from>.event,调度器正是这样发布进度的。
// 它刻意不匹配寻址事件(cyrene.agent.<from>.<to>.event)
@@ -0,0 +1,173 @@
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)
}
}