Files
Cyrene_v2/internal/services/gateway/ws_tool_result_summary_test.go
AskaEth 9fa7e65278 feat(loop): tool_execution.end 失败也带 resultSummary(Z3,主会话裁定)
与回看面对齐:失败调用给该次错误文本的脱敏渲染(仍 ≤500 rune、仍走 trace.RedactText),
缺席只剩「真的没有结果」(空结果 / 空错误文本)。2 变异(失败仍缺席 / 绕过脱敏)
2026-10-07 09:34:45 +08:00

301 lines
12 KiB
Go
Raw Permalink 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"
"errors"
"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)截断,末位 `…`;
// ⑤ **失败也是结果**:失败调用(phase=error)的 end 帧同样带摘要 —— loop 侧给的是
// 已脱敏的错误文本(与回看面回落的图上 `error` 摘要同源),网关只搬运与截断。
// 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):在场语义、缺席语义(各 phase 的空摘要)、
// 失败也带、非 end 帧不带、上限(超长截断 / 恰好等于上限)。
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 侧留空 ⇒ 这里必须让键**缺席**,
// 绝不回空串(空串会让安卓端渲染出一个空的结果区)。
// ⚠️ 失败 / 取消 / 超时**不落在这里** —— 那是「有结论」的情形,
// loop 侧给的是已脱敏的错误文本(见下一个子用例)。
for _, phase := range []string{model.ToolPhaseDone, model.ToolPhaseError} {
_, data := streamEventData(model.StreamEvent{
Kind: model.StreamKindToolExecutionEnd, Tool: "web_search",
Phase: phase,
})
if v, ok := data["resultSummary"]; ok {
t.Fatalf("phase=%s 空摘要仍然出了键 resultSummary=%v;缺席语义要求整键缺席", phase, v)
}
}
})
t.Run("失败_带错误摘要且逐字搬运", func(t *testing.T) {
t.Parallel()
// 失败也是结果:loop 侧把 `error` 文本脱敏后填进同一字段,网关逐字搬运
// (只截断、不二次脱敏),因此失败的 end 帧同样有结果区。
const want = `tool "web_search" failed: upstream 503: upstream 503`
_, data := streamEventData(model.StreamEvent{
Kind: model.StreamKindToolExecutionEnd, Tool: "web_search",
Phase: model.ToolPhaseError, ResultSummary: want,
})
if got, _ := data["resultSummary"].(string); got != want {
t.Fatalf("失败帧 resultSummary = %v, want %q(错误摘要逐字搬运)", data["resultSummary"], want)
}
})
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)
}
}
// wsFailTool 是注册进真实 dispatcher 的最小**失败**工具:报错文本里故意带一个
// 凭据形状的串,用来在线上同时钉住「失败也带摘要」与「失败路径不放宽脱敏」——
// 摘要是 loop 侧脱敏后的那一份,客户端拿到的东西里不该有凭据原文。
type wsFailTool struct{}
func (wsFailTool) Name() string { return "web_search" }
func (wsFailTool) Description() string { return "test failing tool" }
func (wsFailTool) Parameters() (map[string]any, error) { return map[string]any{}, nil }
func (wsFailTool) Execute(context.Context, any) (any, error) {
return nil, errors.New("upstream 503: token=abcdef1234567890")
}
// wsFailAdapter 是两步脚本:第一步返回一个指向 wsFailTool 的原生工具调用,
// 第二步(错误回灌给模型之后)正常收尾,使回合完整跑完。
type wsFailAdapter struct{ calls int }
func (a *wsFailAdapter) Name() string { return "ws-fail-test" }
func (a *wsFailAdapter) Chat(context.Context, model.Request, model.Options) (model.Response, error) {
return model.Response{Content: "done"}, nil
}
func (a *wsFailAdapter) ChatStream(_ context.Context, _ model.Request, _ func(model.StreamEvent)) (model.Response, error) {
a.calls++
if a.calls == 1 {
return model.Response{Model: "m", ToolCalls: []model.ToolCall{{
ID: "call_fail_1", Name: "web_search", Args: map[string]any{},
}}}, nil
}
return model.Response{Model: "m", Content: "done"}, nil
}
// TestWSToolExecutionEndErrorSummaryOverTheWire 是失败路径的**线上**锁定(与成功路径
// 那个对称):真 HTTP/WS 服务 + 真 loop + 真失败工具,客户端从连接上读回
// `tool_execution.end` 帧 —— 它必须 phase=error **且**带 resultSummary(失败也是结果),
// 内容是已脱敏的错误文本。这条用例是「实时与回看对同一次失败给出同一份文本」的线上凭据:
// 现场如果摘掉 loop 侧的脱敏,这里会直接读到凭据原文。
func TestWSToolExecutionEndErrorSummaryOverTheWire(t *testing.T) {
t.Parallel()
const secret = "abcdef1234567890"
b := bus.New()
reg := registry.New()
td, err := tool.New(reg)
if err != nil {
t.Fatalf("tool.New: %v", err)
}
if err := td.Register(wsFailTool{}); err != nil {
t.Fatalf("register tool: %v", err)
}
ag, err := agent.New("You are a helpful assistant.", td, nil, agent.WithModel(&wsFailAdapter{}, 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":"search?"}`)}); 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)
}
if got, _ := data["phase"].(string); got != model.ToolPhaseError {
t.Fatalf("线上 tool_execution.end.phase = %v, want error(失败路径)", data["phase"])
}
got, _ := data["resultSummary"].(string)
if got == "" {
t.Fatalf("线上失败帧没带 resultSummary(data=%#v);失败也是结果,与回看面同一份文本",
data)
}
if !strings.Contains(got, "upstream 503") {
t.Fatalf("线上失败帧 resultSummary = %q,want 含工具报错文本 upstream 503", got)
}
if strings.Contains(got, secret) {
t.Fatalf("线上失败帧 resultSummary 里带着凭据原文:%q", got)
}
if !strings.Contains(got, "[redacted]") {
t.Fatalf("线上失败帧 resultSummary = %q,want 含 [redacted](与出图同一条清洗)", got)
}
}