Files
ttstd-infinite-canvas/backend/internal/app/cloud_agent_working_context_test.go
AetherNo2332 58f663bbcc 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

109 lines
4.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 app
import (
"strconv"
"strings"
"testing"
)
func TestTrimCloudAgentTextHistoryKeepsRecentRounds(t *testing.T) {
history := make([]providerTextMessage, 0, 36)
handoff := cloudAgentContinuationContext(&CloudAgentRun{ID: "parent", Status: "failed"}, nil)
for round := 1; round <= 12; round++ {
history = append(history,
providerTextMessage{Role: "user", Content: "目标" + strconv.Itoa(round)},
providerTextMessage{Role: "assistant", Content: "回复" + strconv.Itoa(round)},
providerTextMessage{Role: "user", Content: handoff, AgentContextSource: "continuation"},
)
}
trimmed := trimCloudAgentTextHistory(history, 10, cloudAgentHistoryMaxBytes)
if cloudAgentHistoryUserInstructionCount(trimmed) != 10 {
t.Fatalf("应只留最近 10 轮用户目标,got %d / %d msgs", cloudAgentHistoryUserInstructionCount(trimmed), len(trimmed))
}
if trimmed[0].Content != "目标3" {
t.Fatalf("最旧保留轮次不对:%q", trimmed[0].Content)
}
last := trimmed[len(trimmed)-1]
if last.Role != "user" || last.Content != handoff {
t.Fatalf("最近一轮摘要应还在:%+v", last)
}
}
func TestTrimCloudAgentTextHistoryDropsOldestToFitBytes(t *testing.T) {
history := []providerTextMessage{
{Role: "user", Content: "旧目标" + string(make([]byte, 40000))},
{Role: "assistant", Content: "旧回复"},
{Role: "user", Content: "新目标"},
{Role: "assistant", Content: "新回复"},
}
trimmed := trimCloudAgentTextHistory(history, 10, 8000)
if cloudAgentHistoryUserInstructionCount(trimmed) != 1 || trimmed[0].Content != "新目标" {
t.Fatalf("超字节时应丢掉旧轮:%+v", trimmed)
}
}
func TestTrimCloudAgentTextHistoryCannotHideSingleOversizedRound(t *testing.T) {
history := []providerTextMessage{
{Role: "user", Content: strings.Repeat("x", 70000)},
{Role: "assistant", Content: "ok"},
}
trimmed := trimCloudAgentTextHistory(history, 10, cloudAgentHistoryMaxBytes)
if cloudAgentHistoryJSONSize(trimmed) <= cloudAgentHistoryMaxBytes {
t.Fatal("只剩一轮且本身超过 64KB 时不能假装压进去")
}
}
// 旧数据兜底:部署前落库的交接消息没有 agentContextSource 字段(内容是纯文本
// "上一轮已结束(…)")。裁剪与轮次计数必须按**结构位置**把它认出来,否则"最近 10 轮"
// 会被幽灵轮次占满,更早的真人轮次被提前裁掉。
func TestTrimCloudAgentTextHistoryKeepsRoundsWithLegacyHandoffText(t *testing.T) {
history := make([]providerTextMessage, 0, 36)
for round := 1; round <= 12; round++ {
history = append(history,
providerTextMessage{Role: "user", Content: "目标" + strconv.Itoa(round)},
providerTextMessage{Role: "assistant", Content: "回复" + strconv.Itoa(round)},
// 旧格式:没有来源标记,只有纯文本约定。
providerTextMessage{Role: "user", Content: "上一轮已结束(completed)。 已提交生成任务:task-" + strconv.Itoa(round)},
)
}
if got := cloudAgentHistoryUserInstructionCount(history); got != 12 {
t.Fatalf("真人轮次计数 = %d,期望 12(旧格式交接不能被算成真人轮次)", got)
}
trimmed := trimCloudAgentTextHistory(history, 10, cloudAgentHistoryMaxBytes)
if len(trimmed) == 0 || trimmed[0].Content != "目标3" {
t.Fatalf("最旧保留轮次不对:%+v", trimmed[0])
}
// 结构判据不能把真人消息误判:紧跟 assistant 的 user 才是运行时帧。
mixed := []providerTextMessage{
{Role: "user", Content: "目标"},
{Role: "assistant", Content: "回复"},
{Role: "user", Content: "接着做"},
}
if got := cloudAgentHistoryUserInstructionCount(mixed); got != 2 {
t.Fatalf("真人轮次计数 = %d,期望 2(\"接着做\"是真人消息)", got)
}
}
// 来源标记必须在历史重建里活下来:旧会话走 cloudAgentLegacyHistory 重建,
// 压缩走检查点重建,两边都只拷 role/content 的话身份就丢了。
func TestCloudAgentHistoryRebuildKeepsContextSource(t *testing.T) {
// cloudAgentLegacyHistory 只接受 user/assistant 交替、且当前目标是偶数位的形态。
messages := []map[string]interface{}{
{"role": "user", "content": "目标"},
{"role": "assistant", "content": "回复"},
{"role": "user", "content": "【运行状态】{\"source\":\"server\",\"kind\":\"run_handoff\"}", cloudAgentContextSourceKey: "continuation"},
{"role": "assistant", "content": "回复2"},
{"role": "user", "content": "当前目标"},
}
history := cloudAgentLegacyHistory(messages, "当前目标")
if len(history) != 4 {
t.Fatalf("legacy history 长度 = %d,期望 4", len(history))
}
if history[2].AgentContextSource != "continuation" {
t.Fatalf("重建后丢了来源标记:%+v", history[2])
}
if !isCloudAgentRuntimeFrame(history, 2) {
t.Fatal("重建后的交接帧必须仍被认成运行时帧")
}
}