Files
Cyrene_v2/internal/runtime/loop/tool_frame_summary_test.go
T
AskaEth 215eedf8e9 feat(ws): tool_execution.end 带可选 resultSummary(Y1,答应安卓端的那条)
- 形状:data.resultSummary(可选)。与回看面 toolCalls[].resultSummary 代码级同源:同一个常量
  (500 rune)与同一个截断函数,摘要由 **loop 侧**生成(trace.RedactText + renderToolResultSummary)
  再随 StreamEvent 下发,gateway 只搬运不加工(与回看面同口径)
- 缺席语义:结果真拿到才带;失败/取消/超时/未落图 ⇒ 整键缺席(绝不用空串顶位)
- 线上帧证据:真 WS + 真 JWT + 真 loop ⇒ 客户端收到 resultSummary;同源钉用真图快照反读逐字比对
- 3 个变异(空摘要也写键/去掉脱敏/失败当结果)
⚠️ 待裁:失败路径现按派工书「整键缺席」,但回看面对失败给的是 error 摘要 ⇒ 实时无结果区、事后有;
   若要实时也显示失败原因,一行可改(toolEndFrameSummary 的 return "")
2026-10-06 21:48:48 +08:00

155 lines
6.0 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)**逐字一致**;
// ② 失败/取消/超时 ⇒ 空串(下游据此让键整键缺席),绝不用空串顶位、绝不把错误
// 冒充成结果;③ 凭据形状必须被清洗(与出图同一条 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)
}
}
}