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

235 lines
9.7 KiB
Go
Raw 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 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)
}
}