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)**逐字一致**; // ② **失败也是结果**(取消 / 超时同普通报错,一视同仁):帧带错误文本的已脱敏摘要, // 与回看面回落的图上 `error` 摘要**逐字一致**;③ 只有真的没有结果(结果为空、或错误 // 文本为空)才留空 ⇒ 下游让键整键缺席,绝不用空串顶位;④ 凭据形状必须被清洗 // (成功与失败两条路径共用同一条 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 } // toolNodeProp 反读最后一份图快照里 tool 节点的某个字符串 prop —— 回看面 // `toolCalls[]` 各项的取值源。没有该 prop(或它不是字符串)时返回 ("", false)。 func toolNodeProp(t *testing.T, g *trace.FlowGraph, key string) (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[key].(string) return s, ok } t.Fatalf("图里没有 tool 节点,无法反读 props[%q](nodes=%+v)", key, 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_ToolEndFrameCarriesErrorSummaryOnFailure 覆盖失败/取消/超时路径:三者对循环 // 都是 `Invoke` 返回 error,而**失败也是结果** —— end 帧带的是错误文本的**已脱敏**摘要, // 且与回看面回落的图上 `error` 摘要**逐字一致**(同一次调用,实时与事后只有一种说法)。 // 失败时若还带回部分结果,摘要仍取错误文本:部分结果是过程,失败原因才是结论。 // // 错误文本故意带凭据形状:这样「同源钉」在**脱敏**上也灵敏 —— 若哪天失败路径绕过 // `trace.RedactText`,图上那份仍是清洗过的、帧上那份不是,逐字比对会立刻红。 func TestRun_ToolEndFrameCarriesErrorSummaryOnFailure(t *testing.T) { t.Parallel() const secret = "abcdef1234567890" 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, graphs := runToolTurn(t, tc.result, errors.New("boom: token="+secret)) 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) } got := ex[1].ResultSummary if got == "" { t.Fatalf("失败调用的 end 帧没带摘要;失败也是结果,回看面对同一次调用给的是 error 摘要") } if !strings.Contains(got, "boom") { t.Fatalf("end 帧 resultSummary = %q,want 含工具报错文本 boom", got) } if strings.Contains(got, secret) { t.Fatalf("失败帧 resultSummary 里带着凭据原文:%q", got) } fromGraph, ok := toolNodeProp(t, graphs[len(graphs)-1], "error") if !ok || fromGraph == "" { t.Fatalf("图上 tool 节点没有 error prop (present=%v);回看面会缺席,而实时帧带着错误摘要", ok) } if got != fromGraph { t.Fatalf("实时帧 resultSummary = %q,图上 error = %q;同一份文本才允许两边都出", got, fromGraph) } }) } } // TestRun_ToolEndFrameErrorSummaryIsRedacted 钉「脱敏不放宽」在失败路径上同样成立: // 错误文本里带凭据形状时,帧上的摘要必须已经是清洗后的文本 —— 失败不是绕过脱敏的后门。 func TestRun_ToolEndFrameErrorSummaryIsRedacted(t *testing.T) { t.Parallel() const secret = "abcdef1234567890" evs, _ := runToolTurn(t, nil, errors.New("lookup failed: token="+secret)) 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("失败帧 resultSummary 里带着凭据原文:%q", got) } if !strings.Contains(got, "[redacted]") { t.Fatalf("失败帧 resultSummary = %q,want 含 [redacted](与结果摘要同一条清洗路径)", got) } } // TestRun_ToolEndFrameErrorSummaryIsBounded 钉上限:超长错误文本不得整段上帧 —— // 它走的仍是同一条有界脱敏路径(loop 侧被 trace 收得更短),线上再按与回看面共用的 // ≤500 rune 预算兜底。 func TestRun_ToolEndFrameErrorSummaryIsBounded(t *testing.T) { t.Parallel() evs, _ := runToolTurn(t, nil, errors.New(strings.Repeat("详", 2000))) ex := toolExecEvents(evs) if len(ex) != 2 { t.Fatalf("tool_execution events = %d, want 2: %+v", len(ex), ex) } got := ex[1].ResultSummary if got == "" { t.Fatal("超长失败的 end 帧没带摘要;失败也是结果") } if n := len([]rune(got)); n > 500 { t.Fatalf("失败帧 resultSummary 长度 = %d rune,超过与回看面共用的上限 500", n) } } // 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) } } if got := toolEndFrameSummary(nil, errors.New("")); got != "" { t.Fatalf("toolEndFrameSummary(nil, 空错误) = %q,want 空串(真的没有结果才缺席)", got) } }