Files
ttstd-infinite-canvas/backend/internal/app/cloud_agent_context_pressure_test.go

690 lines
32 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"
)
// 计量用的估算尺子:ASCII 每 4 字节 1 token 向上取整,非 ASCII 每字符 1 token。
func TestEstimateCloudAgentTokensTreatsCJKConservatively(t *testing.T) {
if got := estimateCloudAgentTokens([]byte("abcd中文")); got != 3 {
t.Fatalf("estimate = %d, want 3", got)
}
if got := estimateCloudAgentTokens([]byte("hello")); got != 2 {
t.Fatalf("estimate = %d, want 2", got)
}
if got := estimateCloudAgentTokens(nil); got != 0 {
t.Fatalf("empty estimate = %d, want 0", got)
}
}
// 两把尺子不混画:本地估算描述下一次请求,上游实测量的是上一次请求。
func TestCloudAgentContextPressureSeparatesNextRequestEstimateFromPreviousProviderMeasurement(t *testing.T) {
pressure := cloudAgentContextPressure{EstimatedInputTokens: 20000, UsableInputTokens: 100000}
state := &cloudAgentRuntime{TokenAnchor: &cloudAgentTokenAnchor{
Step: 2, InputTokens: 10500, EstimatedTokens: 19500, Accepted: true,
}}
payload := cloudAgentContextPressurePayload(pressure, state)
if payload["readingScope"] != "next_request" || payload["estimateMethod"] != "local_v1" {
t.Fatalf("next request scope missing: %+v", payload)
}
if payload["providerMeasurementScope"] != "previous_request" {
t.Fatalf("provider scope = %v", payload["providerMeasurementScope"])
}
if payload["estimatedInputTokens"] != 20000 || payload["projectedTokens"] != 11000 {
t.Fatalf("estimate/projected readings were mixed: %+v", payload)
}
}
// 快照字段:读数必须带版本、相位、测量来源、窗口与锚点状态,消费方不该靠字段名猜。
func TestCloudAgentContextPressurePayloadCarriesSnapshotFields(t *testing.T) {
pressure := cloudAgentContextPressure{
EstimatedInputTokens: 20000, UsableInputTokens: 100000, ContextWindowTokens: 128000,
ReservedOutputTokens: 16000, OverheadTokens: 5120, InputBudgetTokens: 100000,
CompactAtTokens: 85000, BudgetSource: "channel-model", ModelLimitConfigured: true, Estimate: true,
}
state := &cloudAgentRuntime{
Step: 6,
TokenAnchor: &cloudAgentTokenAnchor{TaskID: "task-anchor", Step: 4, InputTokens: 10500, CachedTokens: 2000, OutputTokens: 300, EstimatedTokens: 19500, Accepted: true},
}
payload := cloudAgentContextPressurePayload(pressure, state)
if payload["schemaVersion"] != 2 || payload["phase"] != "before_request" {
t.Fatalf("snapshot version/phase 缺失:%+v", payload)
}
if payload["measurementSource"] != "provider" || payload["normalizedInputTokens"] != int64(10500) || payload["projectedNextInputTokens"] != 11000 {
t.Fatalf("测量来源与两种量纲没分开:%+v", payload)
}
if payload["contextWindowTokens"] != 128000 || payload["usableInputTokens"] != 100000 ||
payload["reservedOutputTokens"] != 16000 || payload["overheadTokens"] != 5120 ||
payload["inputBudgetTokens"] != 100000 || payload["compactAtTokens"] != 85000 ||
payload["budgetSource"] != "channel-model" || payload["modelLimitConfigured"] != true {
t.Fatalf("窗口口径字段不齐:%+v", payload)
}
anchor, ok := payload["anchor"].(map[string]any)
if !ok || anchor["id"] != "task-anchor" || anchor["valid"] != true || anchor["ageSteps"] != 2 {
t.Fatalf("anchor 快照不对:%+v", payload["anchor"])
}
usage, ok := payload["providerUsage"].(map[string]any)
if !ok || usage["cacheReadTokens"] != int64(2000) || usage["uncachedInputTokens"] != int64(8500) {
t.Fatalf("providerUsage 不对:%+v", payload["providerUsage"])
}
if payload["tokenSource"] != "provider" || payload["tokenScale"] != 0.5385 {
t.Fatalf("tokenSource/tokenScale 不对:%+v", payload)
}
}
// 窗口未配置时不假装有百分比:既不下发窗口字段,也不下发占用率,只留可用的本地估算。
func TestCloudAgentContextPressureWithoutWindowDoesNotFakeRatio(t *testing.T) {
// 没有 repository:解析不到任何模型声明的窗口。
service := &Service{}
canonical := canonicalAgentRequest{
SystemPrompt: "系统提示",
Messages: []map[string]interface{}{{"role": "user", "content": "你好"}},
}
pressure := service.cloudAgentContextPressure(canonical, "你好", CloudAgentRequest{})
if pressure.ModelLimitConfigured || pressure.UsableInputTokens != 0 || pressure.PressureRatio != 0 {
t.Fatalf("未声明窗口时不该声称配置了模型上限:%+v", pressure)
}
payload := cloudAgentContextPressurePayload(pressure, &cloudAgentRuntime{})
for _, key := range []string{
"contextWindowTokens", "usableInputTokens", "reservedOutputTokens", "overheadTokens",
"inputBudgetTokens", "compactAtTokens", "budgetSource", "pressureRatio", "projectedPressureRatio",
} {
if _, exists := payload[key]; exists {
t.Fatalf("窗口未确认时不该下发 %s:%+v", key, payload)
}
}
if payload["modelLimitConfigured"] != false {
t.Fatalf("modelLimitConfigured = %v", payload["modelLimitConfigured"])
}
if payload["tokenSource"] != "estimate" || payload["measurementSource"] != "estimate" {
t.Fatalf("没有实测时必须如实标注估算:%+v", payload)
}
if payload["estimatedInputTokens"] != pressure.EstimatedInputTokens || payload["normalizedInputTokens"] != pressure.EstimatedInputTokens {
t.Fatalf("估算读数缺失:%+v", payload)
}
}
// 声明了窗口的渠道模型:窗口/输出预留/overhead/输入预算按同一算式给出,读数才有百分比。
func TestCloudAgentContextPressureReportsWindowBudgetFromChannelCapability(t *testing.T) {
service, _, _, _ := creationTestService(t)
canonical := canonicalAgentRequest{
SystemPrompt: "系统提示",
Messages: []map[string]interface{}{{"role": "user", "content": "分析剧情"}},
}
pressure := service.cloudAgentContextPressure(canonical, "分析剧情", agentTestRequest())
if !pressure.ModelLimitConfigured {
t.Fatalf("渠道模型声明了窗口,读数必须按窗口口径:%+v", pressure)
}
if pressure.ContextWindowTokens != defaultCloudAgentContextWindowTokens {
t.Fatalf("contextWindowTokens = %d", pressure.ContextWindowTokens)
}
// overhead = 窗口的 4% 夹在 4096–32768;输入预算 = 窗口 − 输出预留 − overhead。
wantOverhead := defaultCloudAgentContextWindowTokens / 25
if pressure.OverheadTokens != wantOverhead {
t.Fatalf("overheadTokens = %d, want %d", pressure.OverheadTokens, wantOverhead)
}
if pressure.InputBudgetTokens != pressure.ContextWindowTokens-defaultCloudAgentMaxOutputTokens-wantOverhead {
t.Fatalf("inputBudgetTokens = %d", pressure.InputBudgetTokens)
}
if pressure.UsableInputTokens != pressure.InputBudgetTokens || pressure.BudgetSource != "channel-model" {
t.Fatalf("预算来源/可用输入不一致:%+v", pressure)
}
payload := cloudAgentContextPressurePayload(pressure, &cloudAgentRuntime{})
if payload["budgetSource"] != "channel-model" || payload["usableInputTokens"] != pressure.InputBudgetTokens {
t.Fatalf("载荷缺少窗口口径:%+v", payload)
}
if _, exists := payload["pressureRatio"]; !exists {
t.Fatalf("已确认窗口时必须给出占用率:%+v", payload)
}
}
// 窗口从未确认变为已确认:要落且只落一条 context_transition。
func TestCloudAgentNoteContextWindowResolvedEmitsOnce(t *testing.T) {
state := &cloudAgentRuntime{}
pressure := cloudAgentContextPressure{ModelLimitConfigured: true, ContextWindowTokens: 1000000, UsableInputTokens: 967232, BudgetSource: "channel-model"}
cloudAgentNoteContextWindowResolved("run-1", state, pressure)
cloudAgentNoteContextWindowResolved("run-1", state, pressure)
transitions := 0
for _, event := range state.Events {
if event.Type == "context_transition" {
transitions++
if event.Payload["kind"] != "window_resolved" {
t.Fatalf("过渡类型不对:%+v", event.Payload)
}
}
}
if transitions != 1 {
t.Fatalf("context_transition 条数 = %d,期望 1", transitions)
}
if !state.ContextWindowKnown {
t.Fatal("窗口识别标记没落下")
}
}
// 第一步没有实测:读数是估算;上一步的 provider 用量到达后,第二步改用实测锚点,
// 并给出 tokenScale 与投影(锚点 + 本地有符号增量)。
func TestCloudAgentContextPressureUsesEstimateOnFirstStepThenProviderAnchor(t *testing.T) {
service, db, _, _ := creationTestService(t)
request := agentTestRequest()
budget := service.cloudAgentContextBudgetForRequest(request)
if !budget.Configured {
t.Fatal("测试夹具的渠道模型应当声明了窗口")
}
state := &cloudAgentRuntime{Request: request, Step: 1, Canonical: canonicalAgentRequest{SystemPrompt: "系统提示"}}
// 第一步:还没有任何上游实测,只能如实标注估算。
first := cloudAgentContextPressurePayload(cloudAgentContextPressure{EstimatedInputTokens: 20000, ModelLimitConfigured: true, UsableInputTokens: budget.InputBudgetTokens}, state)
if first["tokenSource"] != "estimate" || first["measurementSource"] != "estimate" {
t.Fatalf("第一步必须标估算:%+v", first)
}
if _, exists := first["tokenScale"]; exists {
t.Fatalf("没有锚点就不该有 tokenScale:%+v", first)
}
// 上一步的模型调用回来了,上游上报了用量(qwen 系实测过本地估算偏高 ~18%)。
if err := db.Create(&model.ApiCallLog{
ID: "log-step-1", UserID: "user", TaskID: "task-step-1", Capability: "text", Model: "text-test",
Status: model.ApiCallStatusSucceeded, InputTokens: 10500, CachedTokens: 2000, OutputTokens: 300, UsageAvailable: true,
}).Error; err != nil {
t.Fatal(err)
}
state.Step = 2
state.LastStepTaskID = "task-step-1"
state.LastStepOperation = cloudAgentStepOperation
state.LastStepEstimate = 20000
state.LastStepSourceBytes = 40000
state.LastStepSignature = cloudAgentStepSignature(state)
service.recordCloudAgentTokenAnchor("user", state)
if state.TokenAnchor == nil || !state.TokenAnchor.Accepted {
t.Fatalf("合理的上游用量必须被采信:%+v", state.TokenAnchor)
}
// 第二步:主读数换成"锚点实测 + 本地增量",并带上换算比例。
second := cloudAgentContextPressurePayload(cloudAgentContextPressure{EstimatedInputTokens: 22000, ModelLimitConfigured: true, UsableInputTokens: budget.InputBudgetTokens}, state)
if second["tokenSource"] != "provider" || second["measurementSource"] != "provider" {
t.Fatalf("拿到实测后主读数必须换成 provider:%+v", second)
}
if second["pressureTokens"] != int64(10500) || second["normalizedInputTokens"] != int64(10500) {
t.Fatalf("锚点实测值不对:%+v", second)
}
if second["projectedNextInputTokens"] != 12500 || second["projectedTokens"] != 12500 {
t.Fatalf("投影应为 10500 + (22000-20000):%+v", second)
}
if second["tokenScale"] != 0.525 || second["anchorDeltaTokens"] != 2000 {
t.Fatalf("换算比例/增量不对:%+v", second)
}
if second["estimatedInputTokens"] != 22000 {
t.Fatalf("本地估算不能被实测覆盖:%+v", second)
}
}
// 锚点跨步沿用,但超过 3 步未刷新就作废(真机踩过"锚点冻结")。
func TestCloudAgentTokenAnchorSurvivesWithinMaxAgeAndExpiresAfterwards(t *testing.T) {
state := &cloudAgentRuntime{Request: agentTestRequest(), Step: 2, TokenAnchor: &cloudAgentTokenAnchor{TaskID: "task-1", Step: 2, Accepted: true, InputTokens: 10500, EstimatedTokens: 20000}}
state.TokenAnchor.Signature = cloudAgentStepSignature(state)
state.Step = 2 + cloudAgentAnchorMaxAgeSteps
cloudAgentExpireTokenAnchor("run-1", state, 0)
if !state.TokenAnchor.Accepted || state.TokenAnchor.RejectReason != "" {
t.Fatalf("第 %d 步仍在有效期内,不该作废:%+v", cloudAgentAnchorMaxAgeSteps, state.TokenAnchor)
}
pressure := cloudAgentContextPressurePayload(cloudAgentContextPressure{EstimatedInputTokens: 20000}, state)
if pressure["tokenSource"] != "provider" {
t.Fatalf("沿用中的锚点必须继续当主读数:%+v", pressure)
}
anchor, ok := pressure["anchor"].(map[string]any)
if !ok || anchor["valid"] != true || anchor["ageSteps"] != cloudAgentAnchorMaxAgeSteps {
t.Fatalf("anchor 快照不对:%+v", pressure["anchor"])
}
state.Step++
cloudAgentExpireTokenAnchor("run-1", state, 0)
if state.TokenAnchor.Accepted || !strings.Contains(state.TokenAnchor.RejectReason, "未刷新") {
t.Fatalf("超期锚点必须作废:%+v", state.TokenAnchor)
}
pressure = cloudAgentContextPressurePayload(cloudAgentContextPressure{EstimatedInputTokens: 20000}, state)
if pressure["tokenSource"] != "estimate" || pressure["anchorRejected"] == nil {
t.Fatalf("作废后必须退回估算并给出原因:%+v", pressure)
}
}
// 窗口变化后锚点作废:同一个 token 数在 8K 窗口和 256K 窗口下的含义完全不同。
func TestCloudAgentTokenAnchorExpiresWhenWindowChanges(t *testing.T) {
state := &cloudAgentRuntime{
Request: agentTestRequest(), Step: 4,
TokenAnchor: &cloudAgentTokenAnchor{TaskID: "task-1", Step: 1, Accepted: true, InputTokens: 10500, EstimatedTokens: 20000, ContextWindowTokens: 128000},
}
state.TokenAnchor.Signature = cloudAgentStepSignature(state)
cloudAgentExpireTokenAnchor("run-1", state, 256000)
if state.TokenAnchor.Accepted || !strings.Contains(state.TokenAnchor.RejectReason, "窗口已变化") {
t.Fatalf("换窗口后锚点必须作废:%+v", state.TokenAnchor)
}
transitions := 0
for _, event := range state.Events {
if event.Type == "context_transition" && event.Payload["kind"] == "window_changed" {
transitions++
before, _ := event.Payload["before"].(map[string]any)
after, _ := event.Payload["after"].(map[string]any)
if before["contextWindowTokens"] != 128000 || after["contextWindowTokens"] != 256000 {
t.Fatalf("窗口过渡事件缺少前后值:%+v", event.Payload)
}
}
}
if transitions != 1 {
t.Fatalf("窗口变化应落一条 context_transition(window_changed),实际 %d", transitions)
}
// 窗口未知(未解析到真实能力)时不比较,也不该据此作废。
unknown := &cloudAgentRuntime{
Request: agentTestRequest(), Step: 2,
TokenAnchor: &cloudAgentTokenAnchor{TaskID: "task-2", Step: 1, Accepted: true, InputTokens: 10500, EstimatedTokens: 20000, ContextWindowTokens: 128000},
}
unknown.TokenAnchor.Signature = cloudAgentStepSignature(unknown)
cloudAgentExpireTokenAnchor("run-1", unknown, 0)
if !unknown.TokenAnchor.Accepted {
t.Fatal("窗口未知时不该作废锚点")
}
}
func TestCloudAgentTokenAnchorExpiresOnlyWhenRequestSignatureChanges(t *testing.T) {
for _, testCase := range []struct {
name, model, channelID, system, kind string
changeTools bool
}{
{name: "model", model: "other-model", channelID: "channel", system: "policy", kind: "model_changed"},
{name: "route", model: "text-test", channelID: "other-channel", system: "policy", kind: "route_changed"},
{name: "system", model: "text-test", channelID: "channel", system: "new policy"},
{name: "tools", model: "text-test", channelID: "channel", system: "policy", changeTools: true},
} {
t.Run(testCase.name, func(t *testing.T) {
canonical := canonicalAgentRequest{SystemPrompt: "policy", Tools: []map[string]interface{}{{"name": "a"}}}
state := &cloudAgentRuntime{Request: agentTestRequest(), Step: 2}
signature := cloudAgentRequestSignature(state, canonical, "channel", "text-test")
state.TokenAnchor = &cloudAgentTokenAnchor{
TaskID: "task", Step: 1, Accepted: true, InputTokens: 10000, EstimatedTokens: 18000,
Signature: signature, Model: "text-test", ChannelID: "channel",
}
canonical.Messages = []map[string]interface{}{{"role": "user", "content": "changed message"}}
cloudAgentExpireTokenAnchorForRequest("run", state, 0, cloudAgentRequestSignature(state, canonical, "channel", "text-test"), "text-test", "channel")
if !state.TokenAnchor.Accepted {
t.Fatal("messages alone must not invalidate the tokenizer anchor")
}
canonical.SystemPrompt = testCase.system
if testCase.changeTools {
canonical.Tools = []map[string]interface{}{{"name": "b"}}
}
cloudAgentExpireTokenAnchorForRequest("run", state, 0, cloudAgentRequestSignature(state, canonical, testCase.channelID, testCase.model), testCase.model, testCase.channelID)
if state.TokenAnchor.Accepted || state.TokenAnchor.RejectReason == "" {
t.Fatalf("signature changed without invalidation: %+v", state.TokenAnchor)
}
if testCase.kind != "" && (len(state.Events) != 1 || state.Events[0].Payload["kind"] != testCase.kind) {
t.Fatalf("missing transition %s: %+v", testCase.kind, state.Events)
}
})
}
}
// 上游实测与本地估算差出一个量级(<0.5× / >2×)时不采信,并说明原因。
func TestCloudAgentTokenAnchorRejectsImplausibleProviderUsage(t *testing.T) {
cases := []struct {
name string
usage int64
found string
}{
{name: "远低于本地估算", usage: 5000, found: "远低于"},
{name: "远高于本地估算", usage: 45000, found: "远高于"},
}
for _, testCase := range cases {
t.Run(testCase.name, func(t *testing.T) {
service, db, _, _ := creationTestService(t)
request := agentTestRequest()
taskID := "task-" + testCase.name
if err := db.Create(&model.ApiCallLog{
ID: "log-" + testCase.name, UserID: "user", TaskID: taskID, Capability: "text", Model: "text-test",
Status: model.ApiCallStatusSucceeded, InputTokens: testCase.usage, OutputTokens: 300, UsageAvailable: true,
}).Error; err != nil {
t.Fatal(err)
}
state := &cloudAgentRuntime{
Request: request, Step: 2, LastStepTaskID: taskID, LastStepOperation: cloudAgentStepOperation,
LastStepEstimate: 20000, LastStepSourceBytes: 40000,
}
state.LastStepSignature = cloudAgentStepSignature(state)
service.recordCloudAgentTokenAnchor("user", state)
if state.TokenAnchor == nil || state.TokenAnchor.Accepted {
t.Fatalf("离谱的实测不该被采信:%+v", state.TokenAnchor)
}
if !strings.Contains(state.TokenAnchor.RejectReason, testCase.found) {
t.Fatalf("拒绝原因不对:%q", state.TokenAnchor.RejectReason)
}
payload := cloudAgentContextPressurePayload(cloudAgentContextPressure{EstimatedInputTokens: 20000}, state)
if payload["tokenSource"] != "estimate" {
t.Fatalf("被拒绝的锚点不得当主读数:%+v", payload)
}
if reason, _ := payload["anchorRejected"].(string); !strings.Contains(reason, testCase.found) {
t.Fatalf("事件必须说明为什么不用这个实测:%+v", payload)
}
})
}
}
// 没有上游用量(usage_available=false)时不瞎猜:退回估算,且不产生锚点。
func TestCloudAgentTokenAnchorRequiresProviderReportedUsage(t *testing.T) {
service, db, _, _ := creationTestService(t)
request := agentTestRequest()
if err := db.Create(&model.ApiCallLog{
ID: "log-no-usage", UserID: "user", TaskID: "task-no-usage", Capability: "text", Model: "text-test",
Status: model.ApiCallStatusSucceeded, InputTokens: 10500, OutputTokens: 300, UsageAvailable: false,
}).Error; err != nil {
t.Fatal(err)
}
state := &cloudAgentRuntime{
Request: request, Step: 2, LastStepTaskID: "task-no-usage", LastStepOperation: cloudAgentStepOperation,
LastStepEstimate: 20000, LastStepSourceBytes: 40000,
}
service.recordCloudAgentTokenAnchor("user", state)
if state.TokenAnchor != nil {
t.Fatalf("上游没报用量就不该定锚:%+v", state.TokenAnchor)
}
payload := cloudAgentContextPressurePayload(cloudAgentContextPressure{EstimatedInputTokens: 20000}, state)
if payload["tokenSource"] != "estimate" || payload["normalizedInputTokens"] != 20000 {
t.Fatalf("没有实测时必须退回估算:%+v", payload)
}
}
// 媒体任务不是"模型调用"这一步:拿它的用量当锚点会把压力算到错误的信封上。
func TestCloudAgentTokenAnchorIgnoresNonStepOperations(t *testing.T) {
service, db, _, _ := creationTestService(t)
request := agentTestRequest()
if err := db.Create(&model.ApiCallLog{
ID: "log-media", UserID: "user", TaskID: "task-media", Capability: "text", Model: "text-test",
Status: model.ApiCallStatusSucceeded, InputTokens: 10500, OutputTokens: 300, UsageAvailable: true,
}).Error; err != nil {
t.Fatal(err)
}
state := &cloudAgentRuntime{
Request: request, Step: 2, LastStepTaskID: "task-media", LastStepOperation: "cloud_agent_media",
LastStepEstimate: 20000, LastStepSourceBytes: 40000,
}
service.recordCloudAgentTokenAnchor("user", state)
if state.TokenAnchor != nil {
t.Fatalf("非模型调用步骤不该定锚:%+v", state.TokenAnchor)
}
}
func TestCloudAgentTokenAnchorOnlyUsesOwnedSuccessfulTextCall(t *testing.T) {
service, db, _, _ := creationTestService(t)
state := &cloudAgentRuntime{
Request: agentTestRequest(), Step: 2, LastStepTaskID: "task-owned",
LastStepOperation: cloudAgentStepOperation, LastStepEstimate: 20000,
LastStepChannelID: "channel",
}
state.LastStepSignature = cloudAgentStepSignature(state)
for _, log := range []model.ApiCallLog{
{ID: "other-user", UserID: "other", TaskID: "task-owned", Capability: "text", Status: model.ApiCallStatusSucceeded, UsageAvailable: true, InputTokens: 11000},
{ID: "failed", UserID: "user", TaskID: "task-owned", Capability: "text", Status: model.ApiCallStatusFailed, UsageAvailable: true, InputTokens: 11000},
{ID: "video", UserID: "user", TaskID: "task-owned", Capability: "video", Status: model.ApiCallStatusSucceeded, UsageAvailable: true, InputTokens: 11000},
} {
if err := db.Create(&log).Error; err != nil {
t.Fatal(err)
}
}
service.recordCloudAgentTokenAnchor("user", state)
if state.TokenAnchor != nil {
t.Fatalf("foreign/failed/non-text log must not become an anchor: %+v", state.TokenAnchor)
}
valid := model.ApiCallLog{ID: "owned", UserID: "user", TaskID: "task-owned", ChannelID: "channel", Capability: "text", Status: model.ApiCallStatusSucceeded, UsageAvailable: true, InputTokens: 11000}
if err := db.Create(&valid).Error; err != nil {
t.Fatal(err)
}
service.recordCloudAgentTokenAnchor("user", state)
if state.TokenAnchor == nil || !state.TokenAnchor.Accepted {
t.Fatalf("owned successful text usage must be accepted: %+v", state.TokenAnchor)
}
state.TokenAnchor = nil
state.LastStepChannelID = "different-channel"
service.recordCloudAgentTokenAnchor("user", state)
if state.TokenAnchor != nil {
t.Fatalf("route changed after request construction: %+v", state.TokenAnchor)
}
}
// 占用分布:分段合计必须与 system 桶严格对得上(字节逐字节、token 逐 token)。
func TestCloudAgentContextBreakdownSystemSegmentsFillSystemBucket(t *testing.T) {
system, policy, err := compileCloudAgentPolicies(agentTestRequest(), nil, "", cloudAgentProfileSnapshot{Revision: agentProfileRevision(nil), Hash: agentProfileHash("")})
if err != nil {
t.Fatal(err)
}
if len(policy.SystemSegments) == 0 {
t.Fatal("编译器必须登记系统提示分段")
}
// 编译之后拼接的个人记忆块:不登记就会让分段合计小于 system 桶。
canonical := canonicalAgentRequest{
SystemPrompt: system + "\n\n## 个人记忆\n\n- 标题:适用场景\n",
Messages: []map[string]interface{}{{"role": "user", "content": "分析剧情"}},
Tools: []map[string]interface{}{{"type": "function", "function": map[string]interface{}{"name": "canvas_get_state"}}},
}
cloudAgentRecordMemorySegment(&policy, canonical.SystemPrompt)
state := &cloudAgentRuntime{Canonical: canonical, Policy: policy}
breakdown := cloudAgentContextBreakdownPayload(state)
segments, ok := breakdown["systemSegments"].([]cloudAgentContextSegment)
if !ok || len(segments) == 0 {
t.Fatalf("缺少系统提示分段:%+v", breakdown["systemSegments"])
}
buckets, ok := breakdown["buckets"].([]map[string]any)
if !ok || len(buckets) != 3 {
t.Fatalf("buckets 不对:%+v", breakdown["buckets"])
}
systemBucket := buckets[0]
if systemBucket["key"] != "system" {
t.Fatalf("第一个桶必须是 system:%+v", systemBucket)
}
segmentBytes, segmentTokens := 0, 0
keys := []string{}
for _, segment := range segments {
segmentBytes += segment.Bytes
segmentTokens += segment.Tokens
keys = append(keys, segment.Key)
if segment.Bytes <= 0 {
t.Fatalf("分段 %s 的字节数必须为正:%+v", segment.Key, segment)
}
}
if segmentBytes != systemBucket["bytes"] || segmentBytes != len(canonical.SystemPrompt) {
t.Fatalf("分段字节合计 %d 与 system 桶 %v(真实 %d)对不上:%+v", segmentBytes, systemBucket["bytes"], len(canonical.SystemPrompt), keys)
}
if segmentTokens != systemBucket["tokens"] {
t.Fatalf("分段 token 合计 %d 与 system 桶 %v 对不上:%+v", segmentTokens, systemBucket["tokens"], keys)
}
if !strings.Contains(strings.Join(keys, ","), "memory") {
t.Fatalf("个人记忆必须登记为分段:%+v", keys)
}
if buckets[1]["key"] != "tools" || buckets[2]["key"] != "messages" {
t.Fatalf("三桶顺序不对:%+v", buckets)
}
// 未登记的内容归入"其它",合计仍然对得上,不会出现"少算一截"。
state.Canonical.SystemPrompt += "\n未登记的一段拼接内容"
breakdown = cloudAgentContextBreakdownPayload(state)
segments = breakdown["systemSegments"].([]cloudAgentContextSegment)
buckets = breakdown["buckets"].([]map[string]any)
segmentBytes, segmentTokens, hasOther := 0, 0, false
for _, segment := range segments {
segmentBytes += segment.Bytes
segmentTokens += segment.Tokens
if segment.Key == "other" {
hasOther = true
}
}
if !hasOther {
t.Fatalf("零头必须归入 other:%+v", segments)
}
if segmentBytes != buckets[0]["bytes"] || segmentTokens != buckets[0]["tokens"] {
t.Fatalf("有零头时分段合计仍须等于 system 桶:%d/%v %d/%v", segmentBytes, buckets[0]["bytes"], segmentTokens, buckets[0]["tokens"])
}
}
// 有锚点时,占用分布另给一份按换算比例校准的读数,原值不覆盖。
func TestCloudAgentContextBreakdownScalesBucketsWithAnchor(t *testing.T) {
state := &cloudAgentRuntime{
Canonical: canonicalAgentRequest{SystemPrompt: "系统提示内容", Messages: []map[string]interface{}{{"role": "user", "content": "你好"}}},
TokenAnchor: &cloudAgentTokenAnchor{
TaskID: "task-1", Step: 1, Accepted: true, InputTokens: 10000, EstimatedTokens: 20000,
},
}
breakdown := cloudAgentContextBreakdownPayload(state)
if breakdown["tokenScale"] != 0.5 {
t.Fatalf("tokenScale = %v,期望 0.5", breakdown["tokenScale"])
}
buckets := breakdown["buckets"].([]map[string]any)
systemTokens := buckets[0]["tokens"].(int)
if buckets[0]["scaledTokens"].(int) != systemTokens/2 {
t.Fatalf("scaledTokens 应按锚点比例换算:%+v", buckets[0])
}
}
// 走真实运行路径(调度器推进 + 任务接缝注入模型输出):每一步都落一条 context_pressure,
// 第一步是估算、第二步换成上游实测锚点。这条用例守的是运行期接线(登记的是哪一步、
// 哪一次调用、什么时候配锚点),而不是载荷函数本身。
func TestCloudAgentRuntimeEmitsContextPressurePerStep(t *testing.T) {
s, db, _, _ := creationTestService(t)
if err := db.Create(&model.CanvasProject{ID: "agent-canvas", UserID: "user", PayloadJSON: `{"nodes":[],"connections":[]}`}).Error; err != nil {
t.Fatal(err)
}
root, err := s.CreateCloudAgentRun("user", agentTestRequest(), "")
if err != nil {
t.Fatal(err)
}
load := func() (*model.CloudAgentExecution, cloudAgentRuntime) {
t.Helper()
run, err := s.repo.CloudAgent("user", root.ID)
if err != nil {
t.Fatal(err)
}
state, err := cloudAgentDecode(run)
if err != nil {
t.Fatal(err)
}
return run, state
}
advance := func() {
t.Helper()
if err := s.advanceCloudAgentByID("user", root.ID); err != nil {
t.Fatal(err)
}
}
pressures := func(state cloudAgentRuntime) []CloudAgentEvent {
events := []CloudAgentEvent{}
for _, event := range state.Events {
if event.Type == "context_pressure" {
events = append(events, event)
}
}
return events
}
// 建运行:第一步的模型调用就是根任务,读数在发出请求之前就落下了。
advance()
_, state := load()
if state.ActiveTaskID != root.ID || state.LastStepTaskID != root.ID || state.LastStepOperation != cloudAgentStepOperation {
t.Fatalf("第一步的记账不对:active=%s last=%s op=%s", state.ActiveTaskID, state.LastStepTaskID, state.LastStepOperation)
}
first := pressures(state)
if len(first) != 1 {
t.Fatalf("第一步应落一条 context_pressure,实际 %d", len(first))
}
if first[0].Payload["tokenSource"] != "estimate" || first[0].Payload["measurementSource"] != "estimate" {
t.Fatalf("第一步没有实测,必须标估算:%+v", first[0].Payload)
}
if first[0].Payload["requestId"] != root.ID || first[0].Payload["readingScope"] != "next_request" {
t.Fatalf("读数必须钉在本次请求上:%+v", first[0].Payload)
}
if _, ok := first[0].Payload["breakdown"].(map[string]any); !ok {
t.Fatalf("读数必须带占用分布:%+v", first[0].Payload)
}
assertMatchesTask := func(taskID string, event CloudAgentEvent) {
t.Helper()
task, err := s.repo.TaskForUser("user", taskID)
if err != nil {
t.Fatal(err)
}
var input map[string]any
if err := json.Unmarshal([]byte(task.InputJSON), &input); err != nil {
t.Fatal(err)
}
canonical, ok := canonicalAgentRequestFromInput(input)
if !ok {
t.Fatalf("task %s missing canonical envelope", taskID)
}
raw, err := json.Marshal(canonical)
if err != nil {
t.Fatal(err)
}
breakdown := event.Payload["breakdown"].(map[string]any)
if breakdown["totalBytes"] != float64(len(raw)) || event.Payload["sourceBytes"] != float64(len(raw)) {
t.Fatalf("pressure/breakdown not based on actual task envelope: task=%s event=%+v", taskID, event.Payload)
}
buckets := breakdown["buckets"].([]any)
messages := buckets[2].(map[string]any)
messageBytes, _ := json.Marshal(canonical.Messages)
if messages["bytes"] != float64(len(messageBytes)) {
t.Fatalf("message bucket not based on task input: %v != %d", messages["bytes"], len(messageBytes))
}
}
assertMatchesTask(root.ID, first[0])
// 第一步的模型调用回来了,上游上报了用量。
if err := db.Create(&model.ApiCallLog{
ID: "log-root", UserID: "user", TaskID: root.ID, Capability: "text", Model: "text-test",
Status: model.ApiCallStatusSucceeded, InputTokens: 10500, CachedTokens: 2000, OutputTokens: 300, UsageAvailable: true,
}).Error; err != nil {
t.Fatal(err)
}
// 在任务接缝注入模型输出:一次只读工具调用,让这一轮进入第二步。
call := cloudAgentCall{ID: "call-1"}
call.Function.Name, call.Function.Arguments = "canvas_get_state", `{}`
body, _ := json.Marshal(map[string]any{"toolCalls": []cloudAgentCall{call}})
if err := db.Model(&model.Task{}).Where("id = ?", state.ActiveTaskID).Updates(map[string]any{"status": model.TaskStatusSucceeded, "result_json": string(body)}).Error; err != nil {
t.Fatal(err)
}
// 模型结果 → 执行工具 → 记锚点 → 发下一步:推进到第二步的读数落下为止(有界)。
for attempt := 0; attempt < 6; attempt++ {
advance()
if _, current := load(); len(pressures(current)) == 2 {
break
}
}
run, state := load()
if run.Status != "running" {
t.Fatalf("这一步应当继续(工具已执行、下一步已提交):status=%s failure=%s", run.Status, run.FailureMessage)
}
if state.TokenAnchor == nil || !state.TokenAnchor.Accepted || state.TokenAnchor.TaskID != root.ID {
t.Fatalf("根任务的上游用量必须配成锚点:%+v", state.TokenAnchor)
}
if state.LastStepTaskID == root.ID || state.LastStepOperation != cloudAgentStepOperation {
t.Fatalf("第二步的记账不对:last=%s op=%s", state.LastStepTaskID, state.LastStepOperation)
}
second := pressures(state)
if len(second) != 2 {
t.Fatalf("第二步应再落一条 context_pressure,实际 %d", len(second))
}
payload := second[1].Payload
// 事件是经 JSON 落库再读回来的,数字到这一层都是 float64(消费方也不该假设整型)。
if payload["tokenSource"] != "provider" || payload["measurementSource"] != "provider" {
t.Fatalf("拿到实测后主读数必须换成 provider:%+v", payload)
}
if payload["normalizedInputTokens"] != float64(10500) || payload["requestId"] != state.LastStepTaskID {
t.Fatalf("实测值与请求绑定不对:%+v", payload)
}
assertMatchesTask(state.LastStepTaskID, second[1])
if _, exists := payload["tokenScale"]; !exists {
t.Fatalf("provider 口径必须带换算比例:%+v", payload)
}
anchor, ok := payload["anchor"].(map[string]any)
if !ok || anchor["valid"] != true || anchor["ageSteps"] != float64(0) || anchor["id"] != root.ID {
t.Fatalf("anchor 快照不对:%+v", payload["anchor"])
}
}