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