690 lines
32 KiB
Go
690 lines
32 KiB
Go
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"])
|
||
}
|
||
}
|