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