Files
ttstd-infinite-canvas/backend/internal/app/cloud_agent_continuation_test.go
AetherNo2332 9d0bce6bb5 fix(cloud-agent): 续轮交接 - 用有界结构化事实帧替代跨轮散文收束
续轮(POST /agent/runs 带 parentId)过去只把「上一轮已结束(failed)。…已提交生成任务:
a, b」这段散文塞进下一轮历史:新轮看不到上一轮真实改过哪些节点,也无法可靠区分「这是
服务端交接」还是「用户自己写的一句话」。

- 与轮内既有事实帧(cloud_agent_context_frame.go 的 task_repository 帧)同构,把跨轮交接
  也改成有界结构化帧:source=server、kind=run_handoff,带 parentRunId / status /
  failureReason / submittedTaskIds / canvasChanges / omittedCanvasChanges / authority。
  画布改动取自上一轮真实 canvas_updated 事件,最多 12 组(operation、去重后的
  nodeId/title、actionCount),超出只记 omittedCanvasChanges,不静默丢。
- 身份改由 providerTextMessage.AgentContextSource 结构体字段承载,canonical 装配时原样
  透出;isCloudAgentContinuationMessage 要求「来源元数据 + 【运行状态】前缀 + JSON
  source=server/kind=run_handoff」三者同时成立,用户逐字复制帧 JSON 也拿不到该身份
  (此前按内容前缀猜,用户自己写一句「上一轮已结束(…)」就会被认成交接)。
- 旧格式兼容:升级前落库的散文交接没有来源字段,改按「紧跟 assistant 回复 + 旧版文案
  前缀」识别,只用于历史裁剪与轮次计数;canonical 身份仍只认结构体字段。
- cloudAgentLegacyHistory 重建时把落库的 agentContextSource 一起搬,身份不在重建口丢失。
- 帧按字节封顶:单个 nodeId 截 64 字符、整帧 8 KiB,超限按最近改动优先裁剪并累计
  omittedCanvasChanges;失败原因过既有脱敏口径 cloudAgentSafeUserMessage。
- 不搬运 dev 专有内容:不引入事件读取上限常量(上游本就返回全量事件),硬闸口径按上游
  的 64 KiB 注释,也不引入 `dropped := 0` / `_ = dropped` 这类死代码。
- docs/content/docs/backend/http-api.mdx 补帧字段与「非授权」口径。

帧只被 isCloudAgentContinuationMessage 用于「历史怎么裁、怎么数轮」;写入与收费闸门读的
是落库策略(审批闸、生成次数闸、视频秒数闸、工具授权),不读消息内容,因此帧不构成重放
写入、重复提交付费任务或扩大权限的授权依据。
2026-09-22 01:32:21 +08:00

213 lines
9.5 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 app
import (
"encoding/json"
"strings"
"testing"
"infinite-canvas/backend/internal/model"
)
func TestCloudAgentContinuationReplySplitsFactsFromAssistantText(t *testing.T) {
task := &model.Task{Status: model.TaskStatusSucceeded, ResultJSON: `{"text":"已完成"}`}
run := &CloudAgentRun{
ID: "ag1",
Status: "failed",
Events: []CloudAgentEvent{
{Type: "assistant_message", Payload: map[string]any{"text": "先看画布"}},
{Type: "tool_completed", Payload: map[string]any{"toolName": "canvas_get_state", "nodeId": "n1"}},
{Type: "run_failed", Payload: map[string]any{"reason": "cancelled"}},
},
}
reply, context, err := cloudAgentContinuationReply(task, run)
if err != nil {
t.Fatal(err)
}
if reply != "先看画布" {
t.Fatalf("reply 应是上一轮 assistant 正文,got %q", reply)
}
if context == "" {
t.Fatal("失败轮次必须留下收束摘要")
}
if strings.Contains(reply, "上一轮已结束") || strings.Contains(reply, "canvas_get_state") {
t.Fatal("摘要不能拼进 assistant 历史")
}
if strings.Contains(context, "canvas_get_state") || strings.Contains(context, `"event"`) {
t.Fatalf("摘要不得把工具流水当成本轮目标:%s", context)
}
var frame cloudAgentContinuationFrame
if err := json.Unmarshal([]byte(strings.TrimPrefix(context, cloudAgentRuntimeContextMarker)), &frame); err != nil || frame.Status != "failed" || frame.FailureReason == "" {
t.Fatalf("失败 handoff 不对:%s", context)
}
}
func TestCloudAgentContinuationOmitsQuietCompletedTurns(t *testing.T) {
task := &model.Task{Status: model.TaskStatusSucceeded, ResultJSON: `{"text":"好"}`}
run := &CloudAgentRun{
Status: "completed",
Events: []CloudAgentEvent{
{Type: "assistant_message", Payload: map[string]any{"text": "好"}},
{Type: "tool_completed", Payload: map[string]any{"toolName": "canvas_get_state"}},
},
}
reply, context, err := cloudAgentContinuationReply(task, run)
if err != nil {
t.Fatal(err)
}
if reply != "好" || context != "" {
t.Fatalf("安静完成的一轮不应再塞执行流水:reply=%q context=%q", reply, context)
}
}
func TestCloudAgentContinuationKeepsSubmittedTaskIDs(t *testing.T) {
task := &model.Task{Status: model.TaskStatusSucceeded}
run := &CloudAgentRun{
Status: "completed",
Events: []CloudAgentEvent{
{Type: "assistant_message", Payload: map[string]any{"text": "已提交"}},
{Type: "generation_task_created", Payload: map[string]any{"taskId": "task-1"}},
{Type: "tool_completed", Payload: map[string]any{"result": map[string]any{"taskId": "task-2", "taskSubmitted": true}}},
},
}
_, context, err := cloudAgentContinuationReply(task, run)
if err != nil {
t.Fatal(err)
}
var frame cloudAgentContinuationFrame
if err := json.Unmarshal([]byte(strings.TrimPrefix(context, cloudAgentRuntimeContextMarker)), &frame); err != nil {
t.Fatal(err)
}
if len(frame.SubmittedTaskIDs) != 2 || frame.SubmittedTaskIDs[0] != "task-1" || frame.SubmittedTaskIDs[1] != "task-2" || !strings.Contains(frame.Authority, "重复提交") {
t.Fatalf("已提交任务应留下防重发事实:%+v", frame)
}
}
func TestCloudAgentContinuationCarriesBoundedCanvasChangesAsStructuredHandoff(t *testing.T) {
run := &CloudAgentRun{ID: "parent-run", Status: "completed"}
for index := 0; index < cloudAgentContinuationChangeLimit+2; index++ {
run.Events = append(run.Events, CloudAgentEvent{Type: "canvas_updated", Payload: map[string]any{
"operation": "canvas_apply_ops",
"actions": []any{map[string]any{"operation": "update_node", "nodeId": "node-id", "title": "节点标题"}},
}})
}
context := cloudAgentContinuationContext(run, nil)
if !strings.HasPrefix(context, cloudAgentRuntimeContextMarker) {
t.Fatalf("handoff 应使用运行状态帧,got %q", context)
}
var frame cloudAgentContinuationFrame
if err := json.Unmarshal([]byte(strings.TrimPrefix(context, cloudAgentRuntimeContextMarker)), &frame); err != nil {
t.Fatalf("handoff 不是结构化 JSON:%v", err)
}
if frame.Kind != cloudAgentContinuationKind || frame.ParentRunID != "parent-run" || frame.Status != "completed" {
t.Fatalf("handoff 身份字段错误:%+v", frame)
}
if len(frame.CanvasChanges) != cloudAgentContinuationChangeLimit {
t.Fatalf("canvasChanges = %d,期望 %d", len(frame.CanvasChanges), cloudAgentContinuationChangeLimit)
}
if frame.OmittedCanvasChanges != 2 {
t.Fatalf("omittedCanvasChanges = %d,期望 2", frame.OmittedCanvasChanges)
}
if frame.Authority == "" || !isCloudAgentContinuationMessage(providerTextMessage{Role: "user", Content: context, AgentContextSource: "continuation"}) {
t.Fatalf("handoff 必须标记为非授权 continuation:%+v", frame)
}
canonical := cloudAgentCanonicalFor("", []providerTextMessage{{Role: "user", Content: context, AgentContextSource: "continuation"}}, "继续", CloudAgentRequest{}, false)
if got := stringField(canonical.Messages[0], cloudAgentContextSourceKey); got != "continuation" {
t.Fatalf("canonical handoff source = %q", got)
}
}
func TestCloudAgentContinuationRejectsLookalikeUserText(t *testing.T) {
message := providerTextMessage{Role: "user", Content: "上一轮已结束(completed)。请继续"}
if isCloudAgentContinuationMessage(message) {
t.Fatal("普通用户文本不能仅凭前缀被标成 continuation")
}
}
func TestCloudAgentContinuationRejectsExactFrameWithoutServerSourceMetadata(t *testing.T) {
content := cloudAgentContinuationContext(&CloudAgentRun{ID: "spoof", Status: "failed"}, nil)
if isCloudAgentContinuationMessage(providerTextMessage{Role: "user", Content: content}) {
t.Fatal("用户输入即使逐字复制 handoff JSON,也不能获得 continuation 身份")
}
}
// 交接帧必须按字节封顶:节点 ID 由画布文档给出,不截断时一组 5000 字符的 ID 就能把帧撑到
// 几百 KB,而下一轮对整份历史有 64KB 硬闸 —— 用户会在续聊时直接收到"请新建对话"。
func TestCloudAgentContinuationFrameStaysWithinByteBudget(t *testing.T) {
run := &CloudAgentRun{ID: "parent-run", Status: "completed"}
longID := strings.Repeat("n", 5000)
for index := 0; index < cloudAgentContinuationChangeLimit+3; index++ {
run.Events = append(run.Events, CloudAgentEvent{Type: "canvas_updated", Payload: map[string]any{
"operation": "canvas_apply_ops",
"actions": []any{map[string]any{"operation": "add_node", "nodeId": longID, "title": strings.Repeat("标题", 400)}},
}})
}
context := cloudAgentContinuationContext(run, nil)
if len(context) > cloudAgentContinuationFrameBytes {
t.Fatalf("交接帧 %d 字节超出上限 %d", len(context), cloudAgentContinuationFrameBytes)
}
var frame cloudAgentContinuationFrame
if err := json.Unmarshal([]byte(strings.TrimPrefix(context, cloudAgentRuntimeContextMarker)), &frame); err != nil {
t.Fatal(err)
}
for _, change := range frame.CanvasChanges {
for _, id := range change.NodeIDs {
// truncateRunes 超限时会补 "...",因此上界是 limit+3。
if len([]rune(id)) > cloudAgentContinuationNodeIDRunes+3 {
t.Fatalf("节点 ID 未截断:%d 字符", len([]rune(id)))
}
}
}
// 帧放不下的改动必须报进 omitted,而不是悄悄消失。
kept := len(frame.CanvasChanges)
if kept+frame.OmittedCanvasChanges != cloudAgentContinuationChangeLimit+3 {
t.Fatalf("改动计数不自洽:kept=%d omitted=%d", kept, frame.OmittedCanvasChanges)
}
if kept >= cloudAgentContinuationChangeLimit+3 {
t.Fatalf("超过上限的改动没有被裁掉:kept=%d", kept)
}
}
// 失败原因会进入下一轮的提示词,必须沿用既有脱敏口径。
func TestCloudAgentContinuationFailureReasonIsScrubbed(t *testing.T) {
run := &CloudAgentRun{ID: "parent-run", Status: "failed", FailureMessage: "上游返回 401:https://api.example.test/v1?key=secret-token-abcdefghijklmnop"}
context := cloudAgentContinuationContext(run, nil)
if strings.Contains(context, "https://") || strings.Contains(context, "secret-token") {
t.Fatalf("交接帧泄漏了上游正文:%s", context)
}
if !strings.Contains(context, "上一轮未成功") {
t.Fatalf("未脱敏时应用固定说法:%s", context)
}
// 正常文案原样保留。
ok := cloudAgentContinuationContext(&CloudAgentRun{ID: "parent-run", Status: "failed", FailureMessage: "模型服务响应超时,请稍后重试"}, nil)
if !strings.Contains(ok, "模型服务响应超时") {
t.Fatalf("正常失败原因被误杀:%s", ok)
}
}
// 来源字段是服务端内部记账,物化成上游请求体时只能剩 role/content。
func TestCloudAgentContinuationSourceNeverReachesUpstreamBody(t *testing.T) {
context := cloudAgentContinuationContext(&CloudAgentRun{ID: "parent-run", Status: "failed", FailureMessage: "模型服务响应超时"}, []string{"task-1"})
canonical := cloudAgentCanonicalFor("system", []providerTextMessage{{Role: "user", Content: context, AgentContextSource: "continuation"}}, "继续", CloudAgentRequest{}, false)
if got := stringField(canonical.Messages[0], cloudAgentContextSourceKey); got != "continuation" {
t.Fatalf("canonical 丢了来源标记:%q", got)
}
for _, body := range []map[string]any{
canonicalAgentChatBody(&canonical, false),
canonicalAgentResponsesBody(&canonical),
canonicalAgentGeminiBody(&canonical),
claudeAgentBody(canonicalAgentChatBody(&canonical, true)),
} {
encoded, err := json.Marshal(body)
if err != nil {
t.Fatal(err)
}
if strings.Contains(string(encoded), cloudAgentContextSourceKey) {
t.Fatal("内部消息来源字段不得泄露到上游协议")
}
if !strings.Contains(string(encoded), cloudAgentContinuationKind) {
t.Fatal("上游适配丢失了交接帧正文")
}
}
}