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 用于「历史怎么裁、怎么数轮」;写入与收费闸门读的
是落库策略(审批闸、生成次数闸、视频秒数闸、工具授权),不读消息内容,因此帧不构成重放
写入、重复提交付费任务或扩大权限的授权依据。
This commit is contained in:
AetherNo2332
2026-09-22 01:32:17 +08:00
parent 6f45240599
commit 9d0bce6bb5
8 changed files with 395 additions and 34 deletions

View File

@@ -1,6 +1,7 @@
package app
import (
"encoding/json"
"strings"
"testing"
@@ -34,8 +35,9 @@ func TestCloudAgentContinuationReplySplitsFactsFromAssistantText(t *testing.T) {
if strings.Contains(context, "canvas_get_state") || strings.Contains(context, `"event"`) {
t.Fatalf("摘要不得把工具流水当成本轮目标:%s", context)
}
if !strings.Contains(context, "上一轮已结束(failed)") {
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)
}
}
@@ -71,7 +73,140 @@ func TestCloudAgentContinuationKeepsSubmittedTaskIDs(t *testing.T) {
if err != nil {
t.Fatal(err)
}
if !strings.Contains(context, "task-1") || !strings.Contains(context, "task-2") || !strings.Contains(context, "已提交生成任务") {
t.Fatalf("已提交任务应留下防重发提示:%s", context)
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("上游适配丢失了交接帧正文")
}
}
}