From 215eedf8e9601c0d4c8424d792f8411626ee51ac Mon Sep 17 00:00:00 2001 From: AskaEth Date: Tue, 6 Oct 2026 21:48:48 +0800 Subject: [PATCH] =?UTF-8?q?feat(ws):=20tool=5Fexecution.end=20=E5=B8=A6?= =?UTF-8?q?=E5=8F=AF=E9=80=89=20resultSummary=EF=BC=88Y1=EF=BC=8C=E7=AD=94?= =?UTF-8?q?=E5=BA=94=E5=AE=89=E5=8D=93=E7=AB=AF=E7=9A=84=E9=82=A3=E6=9D=A1?= =?UTF-8?q?=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 形状:data.resultSummary(可选)。与回看面 toolCalls[].resultSummary 代码级同源:同一个常量 (500 rune)与同一个截断函数,摘要由 **loop 侧**生成(trace.RedactText + renderToolResultSummary) 再随 StreamEvent 下发,gateway 只搬运不加工(与回看面同口径) - 缺席语义:结果真拿到才带;失败/取消/超时/未落图 ⇒ 整键缺席(绝不用空串顶位) - 线上帧证据:真 WS + 真 JWT + 真 loop ⇒ 客户端收到 resultSummary;同源钉用真图快照反读逐字比对 - 3 个变异(空摘要也写键/去掉脱敏/失败当结果) ⚠️ 待裁:失败路径现按派工书「整键缺席」,但回看面对失败给的是 error 摘要 ⇒ 实时无结果区、事后有; 若要实时也显示失败原因,一行可改(toolEndFrameSummary 的 return "") --- internal/runtime/loop/tool_frame_summary.go | 62 +++++++ .../runtime/loop/tool_frame_summary_test.go | 154 ++++++++++++++++ internal/runtime/loop/tools.go | 12 +- internal/runtime/model/stream.go | 9 + internal/services/gateway/ws_bridge.go | 30 ++- .../gateway/ws_tool_result_summary_test.go | 173 ++++++++++++++++++ 6 files changed, 433 insertions(+), 7 deletions(-) create mode 100644 internal/runtime/loop/tool_frame_summary.go create mode 100644 internal/runtime/loop/tool_frame_summary_test.go create mode 100644 internal/services/gateway/ws_tool_result_summary_test.go diff --git a/internal/runtime/loop/tool_frame_summary.go b/internal/runtime/loop/tool_frame_summary.go new file mode 100644 index 0000000..722cd28 --- /dev/null +++ b/internal/runtime/loop/tool_frame_summary.go @@ -0,0 +1,62 @@ +package loop + +import ( + "encoding/json" + "fmt" + + "git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/trace" +) + +// 本文件是 `tool_execution.end` 帧上 `resultSummary` 的**生产侧**(契约 v0.82)。 +// 它回答「这次工具调用到底拿到了什么」,让实时工具卡与事后**回看**看到同一份文本: +// 客户端回看一条回复时读的是 `toolCalls[].resultSummary`(图节点 `props.result_summary`, +// 见 gateway/history_toolcalls.go),实时帧读的就是这里的产物。 +// +// 🔴 为什么必须在 loop 侧算:`.end` 帧发出的那一刻,工具返回值只在这一层存在; +// 网关拿到的 `model.StreamEvent` 里没有原文(也**不该**有 —— 帧一旦携带上游原文, +// 脱敏面就被绕过了)。所以摘要只在这里、在**同一份已脱敏文本**上生成,网关只做搬运 +// 与截断,绝不反解上游原文。 + +// toolFrameResultSummary 把工具返回值渲染成 loop 侧的结果摘要文本,口径与图上那份 +// `props.result_summary`(读回看面的数据源)**逐字一致**。 +// +// 图侧的取值链是:tool span 的 `result` prop → `trace.safeSpanProps` → +// `RedactText(summarize(result))`。`RedactText` 是导出 API(凭据形状清洗 + 截断, +// 连同密钥键名防线都在 trace/redact.go),但 `summarize` 只服务出图、未导出, +// 且 `internal/runtime/trace/**` 不在本切片文件面内 —— 因此这里按同一口径重述那一步 +// 渲染(nil ⇒ 空、字符串原样、其余 JSON 编码,编码失败才回落 %v)。 +// +// 🔴 两边必须逐字一致:由 `tool_frame_summary_test.go` 用**真建图器** +// (`trace.RunGraph` + 真 span → 反读图上那份 `result_summary`)逐字比对来钉住 —— +// 照字面量写断言会与实现一起漂移。若哪天 trace 导出了这一步渲染,这里应改为直接调用。 +func toolFrameResultSummary(result any) string { + return trace.RedactText(renderToolResultSummary(result)) +} + +// toolEndFrameSummary 是一次调用结束时**要不要带摘要**的取舍点:只有结果真正拿到 +// (Invoke 未报错)才给摘要。失败 —— 含工具自身报错、取消、超时(三者对调用方都是 +// `Invoke` 返回 error)—— 一律返回空串,帧上该键随之**整键缺席**,绝不用空串顶位, +// 也绝不把错误文本冒充成「结果」:错误的原因属于图与回看面(那里 `status:"error"` 的 +// 调用给出的就是图上的 `error` 摘要),实时帧只如实说「这次没拿到结果」。 +func toolEndFrameSummary(result any, err error) string { + if err != nil { + return "" + } + return toolFrameResultSummary(result) +} + +// renderToolResultSummary 把工具返回值渲染成一行文本(口径与 trace/summarize 相同)。 +func renderToolResultSummary(v any) string { + switch x := v.(type) { + case nil: + return "" + case string: + return x + default: + b, err := json.Marshal(x) + if err != nil { + return fmt.Sprintf("%v", x) + } + return string(b) + } +} diff --git a/internal/runtime/loop/tool_frame_summary_test.go b/internal/runtime/loop/tool_frame_summary_test.go new file mode 100644 index 0000000..708215d --- /dev/null +++ b/internal/runtime/loop/tool_frame_summary_test.go @@ -0,0 +1,154 @@ +package loop + +import ( + "errors" + "strings" + "testing" + + "git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/model" + "git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/trace" +) + +// 本文件钉住 `tool_execution.end` 帧上的 resultSummary(契约 v0.82)的四条口径: +// ① 结果真拿到 ⇒ 帧在场,且文本与客户端**回看**同一次调用所见的 +// `toolCalls[].resultSummary`(图节点 props.result_summary)**逐字一致**; +// ② 失败/取消/超时 ⇒ 空串(下游据此让键整键缺席),绝不用空串顶位、绝不把错误 +// 冒充成结果;③ 凭据形状必须被清洗(与出图同一条 trace.RedactText); +// ④ nil 与空串结果也不产出「看起来像结果」的占位文本。 + +// toolNodeResultSummary 反读最后一份图快照里 tool 节点的 props.result_summary —— +// 也就是回看面 `toolCalls[].resultSummary` 的**同一份数据源**。没有该 prop 时返回 +// ("", false),使「图上缺席」与「图上给了空串」在测试里可分辨。 +func toolNodeResultSummary(t *testing.T, g *trace.FlowGraph) (string, bool) { + t.Helper() + if g == nil { + t.Fatal("nil graph snapshot") + } + for _, n := range g.Nodes { + if n.Kind != "tool" { + continue + } + s, ok := n.Props["result_summary"].(string) + return s, ok + } + t.Fatalf("图里没有 tool 节点,无法反读 result_summary(nodes=%+v)", g.Nodes) + return "", false +} + +// TestRun_ToolEndFrameCarriesResultSummary 覆盖成功路径:end 帧带结果摘要, +// start 帧不带(摘要只在结果落定那一刻存在)。 +func TestRun_ToolEndFrameCarriesResultSummary(t *testing.T) { + t.Parallel() + evs, _ := runToolTurn(t, map[string]any{"now": "2026-09-08 22:34:00"}, nil) + ex := toolExecEvents(evs) + if len(ex) != 2 { + t.Fatalf("tool_execution events = %d, want 2 (start+end): %+v", len(ex), ex) + } + if ex[0].ResultSummary != "" { + t.Fatalf("start 帧带了结果摘要 %q;结果尚未拿到,绝不能带", ex[0].ResultSummary) + } + want := `{"now":"2026-09-08 22:34:00"}` + if ex[1].ResultSummary != want { + t.Fatalf("end 帧 resultSummary = %q, want %q(工具返回值的 JSON 渲染)", ex[1].ResultSummary, want) + } +} + +// TestRun_ToolEndFrameOmitsSummaryOnError 覆盖失败/取消/超时路径:三者对循环都是 +// `Invoke` 返回 error ⇒ end 帧的 resultSummary **留空**(下游据此让键整键缺席), +// 绝不把错误文本冒充成「结果」。 +func TestRun_ToolEndFrameOmitsSummaryOnError(t *testing.T) { + t.Parallel() + cases := []struct { + name string + result any + }{ + {"执行失败(无结果)", nil}, + {"失败但带部分结果", map[string]any{"partial": true}}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + evs, _ := runToolTurn(t, tc.result, errors.New("boom")) + ex := toolExecEvents(evs) + if len(ex) != 2 { + t.Fatalf("tool_execution events = %d, want 2: %+v", len(ex), ex) + } + if ex[1].Phase != model.ToolPhaseError { + t.Fatalf("end phase = %q, want error(失败路径)", ex[1].Phase) + } + if ex[1].ResultSummary != "" { + t.Fatalf("失败调用的 end 帧带了 %q;失败/取消/超时一律留空,由下游让键缺席", + ex[1].ResultSummary) + } + }) + } +} + +// TestRun_ToolEndFrameSummaryMatchesGraphResultSummary 是**同源钉**:同一个工具结果, +// 实时帧上的 resultSummary 必须与最后一份图快照里 tool 节点的 props.result_summary +// (回看面 `toolCalls[].resultSummary` 的取值源)逐字相等。它用真建图器(真 span → +// trace.RunGraph)反读,因此改一边不改另一边会红 —— 只按字面量断言会与实现一起漂移。 +func TestRun_ToolEndFrameSummaryMatchesGraphResultSummary(t *testing.T) { + t.Parallel() + result := map[string]any{"now": "2026-09-08 22:34:00", "note": "token=abcdef1234567890"} + evs, graphs := runToolTurn(t, result, nil) + ex := toolExecEvents(evs) + if len(ex) != 2 { + t.Fatalf("tool_execution events = %d, want 2: %+v", len(ex), ex) + } + fromGraph, ok := toolNodeResultSummary(t, graphs[len(graphs)-1]) + if !ok || fromGraph == "" { + t.Fatalf("图上的 result_summary = %q (present=%v);回看面会缺席,而实时帧带着摘要,"+ + "两边就此分叉", fromGraph, ok) + } + if ex[1].ResultSummary != fromGraph { + t.Fatalf("实时帧 resultSummary = %q,图上的 result_summary = %q;同一份文本才允许两边都出", + ex[1].ResultSummary, fromGraph) + } +} + +// TestRun_ToolEndFrameSummaryIsRedacted 覆盖脱敏:喂一个含凭据形状的结果(两种形状: +// 顶层字符串与 JSON 对象里的键值),帧上的摘要必须已经是清洗后的文本 —— 绝不放宽、 +// 绝不回上游原文。 +func TestRun_ToolEndFrameSummaryIsRedacted(t *testing.T) { + t.Parallel() + const secret = "abcdef1234567890" + cases := []struct { + name string + result any + }{ + {"顶层字符串", "token=" + secret}, + {"对象里的键值", map[string]any{"token": secret}}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + evs, _ := runToolTurn(t, tc.result, nil) + ex := toolExecEvents(evs) + if len(ex) != 2 { + t.Fatalf("tool_execution events = %d, want 2: %+v", len(ex), ex) + } + got := ex[1].ResultSummary + if strings.Contains(got, secret) { + t.Fatalf("end 帧 resultSummary 里带着凭据原文:%q", got) + } + if !strings.Contains(got, "[redacted]") { + t.Fatalf("end 帧 resultSummary = %q,want 含 [redacted](出图同一条凭据形状清洗)", got) + } + }) + } +} + +// TestToolFrameResultSummary_EmptyResultsStayEmpty 钉住「绝不产出看起来像结果的占位 +// 文本」:nil 与空串结果的摘要必须为空 ⇒ 下游让键整键缺席(不是回 "null"/"empty")。 +func TestToolFrameResultSummary_EmptyResultsStayEmpty(t *testing.T) { + t.Parallel() + for _, v := range []any{nil, ""} { + if got := toolFrameResultSummary(v); got != "" { + t.Fatalf("toolFrameResultSummary(%#v) = %q, want 空串", v, got) + } + if got := toolEndFrameSummary(v, nil); got != "" { + t.Fatalf("toolEndFrameSummary(%#v, nil) = %q, want 空串", v, got) + } + } +} diff --git a/internal/runtime/loop/tools.go b/internal/runtime/loop/tools.go index cbcd269..d4ae5f2 100644 --- a/internal/runtime/loop/tools.go +++ b/internal/runtime/loop/tools.go @@ -298,7 +298,7 @@ func (l *Loop) invokeOne(ctx context.Context, d *tool.Dispatcher, tc agent.ToolC return ToolOutcome{Err: fmt.Errorf("no tool dispatcher configured for %q", tc.Name), ErrKind: tool.KindExecution} } _, toolSpan := l.span(ctx, "tool."+tc.Name, trace.SpanKindTool) - l.emitToolExecution(ctx, tc, model.StreamKindToolExecutionStart, model.ToolPhaseRunning) + l.emitToolExecution(ctx, tc, model.StreamKindToolExecutionStart, model.ToolPhaseRunning, "") // 路标识桥(design/15 §2.5.7、主人裁决 3):网关在**每一条在途路**上把互斥 // 身份经 loop.WithToolLegID 挂在 ctx 上(普通回合 / 并行路 / 队首续跑路都挂, // 取值 = 该路的 turnID),这里把它桥接成工具层的 tool.WithLegID —— 工具侧 @@ -313,7 +313,7 @@ func (l *Loop) invokeOne(ctx context.Context, d *tool.Dispatcher, tc agent.ToolC if err != nil { phase = model.ToolPhaseError } - l.emitToolExecution(ctx, tc, model.StreamKindToolExecutionEnd, phase) + l.emitToolExecution(ctx, tc, model.StreamKindToolExecutionEnd, phase, toolEndFrameSummary(result, err)) out := ToolOutcome{Result: result, Err: err, ErrKind: errorKind(err)} l.spanProp(toolSpan, "phase", phase) l.recordToolDone(toolSpan, tc, out) @@ -331,13 +331,13 @@ func (l *Loop) invokeOne(ctx context.Context, d *tool.Dispatcher, tc agent.ToolC // tool_execution 三事件(start / update / end)转发给前端;否则为 // 空操作(被静音的内部流程也会跳过它)。phase 是结构化字段 // (running/done/error),绝不塞进 Delta;工具名走 Tool 字段, -// ID 复用 provider 的 tool call id,使执行帧能与其 tool_call.* 帧 -// 关联。 -func (l *Loop) emitToolExecution(ctx context.Context, tc agent.ToolCall, kind model.StreamKind, phase string) { +// ID 复用 provider 的 tool call id,使执行帧能与其 tool_call.* 帧关联; +// resultSummary 只在 end 帧、且只在结果真正拿到时非空(口径见 tool_frame_summary.go)。 +func (l *Loop) emitToolExecution(ctx context.Context, tc agent.ToolCall, kind model.StreamKind, phase, resultSummary string) { if l.onStream == nil || isMutedBroadcast(ctx) { return } - ev := model.StreamEvent{Kind: kind, Phase: phase, Tool: tc.Name, ID: tc.ID} + ev := model.StreamEvent{Kind: kind, Phase: phase, Tool: tc.Name, ID: tc.ID, ResultSummary: resultSummary} ev.SessionID = loopSession(ctx) ev.TurnID = loopTurn(ctx) l.onStream(ev) diff --git a/internal/runtime/model/stream.go b/internal/runtime/model/stream.go index 475e1b7..3c3f1b0 100644 --- a/internal/runtime/model/stream.go +++ b/internal/runtime/model/stream.go @@ -96,6 +96,15 @@ type StreamEvent struct { // (ToolPhase*):start/update 时为 running,end 时为 done 或 error。 // 其它所有种类为空。 Phase string + // ResultSummary 是 tool_execution_end 事件上**已脱敏**的工具结果摘要, + // 供客户端在工具卡上渲染「这次调用拿到了什么」。它的取值与客户端**回看** + // 同一次调用所见的 `toolCalls[].resultSummary`(图节点 props.result_summary) + // 同源同口径 —— 同一份放行表、同一份凭据形状清洗(loop.toolFrameResultSummary)。 + // + // 🔴 缺席语义:只有结果**真正拿到**时才非空;失败/取消/超时(Invoke 返回 + // error)一律留空 ⇒ 下行帧让该键**整键缺席**,绝不用空串顶位、也绝不把 + // 一次失败说成「拿到了结果」。其它所有种类为空。 + ResultSummary string // StreamID 标识某一帧所属的那一次模型流(一次 ChatStream 调用)。 // loop 在流边界打上它,使同一条流式消息的每一帧 // 共享它,而多步回合后续的模型调用会拿到新 id; diff --git a/internal/services/gateway/ws_bridge.go b/internal/services/gateway/ws_bridge.go index e28338e..454cbc9 100644 --- a/internal/services/gateway/ws_bridge.go +++ b/internal/services/gateway/ws_bridge.go @@ -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..event,调度器正是这样发布进度的。 // 它刻意不匹配寻址事件(cyrene.agent...event) diff --git a/internal/services/gateway/ws_tool_result_summary_test.go b/internal/services/gateway/ws_tool_result_summary_test.go new file mode 100644 index 0000000..feef3fe --- /dev/null +++ b/internal/services/gateway/ws_tool_result_summary_test.go @@ -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) + } +}