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

321 lines
13 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"
"yingce/backend/internal/model"
"yingce/backend/internal/repository"
"gorm.io/gorm"
)
// 运行详情的事件分页契约。事件全量在 cloud_agent_event_records(一行一条 EventJSON,
// 主键 run_id + sequence),内存只保留最近一窗,因此读路径必须自己说明"这一页在整条
// 日志里的位置"。本用例守住四件事:
// - 载入(hydrate + decode)只取一窗,水位落在窗口之前,窗口末条等于 EventCount;
// - 默认视图是尾部一窗,events[i].Seq == EventSeqBase + i + 1 成立;
// - sinceSeq 只返回增量,eventLimit 决定页大小(大于窗口时从事件表往前补);
// - eventCount / latestSeq / eventsTruncated 三个字段互相自洽。
func TestCloudAgentRunDetailPagesJournalBySeq(t *testing.T) {
s, _, root := reliableAgentRoot(t)
// 事件数刻意跨过内存窗口与 HTTP 单次上限,才能同时验证"尾部窗口"、"从表里往前补页"
// 和"内部调用方(续轮收束)可以要更大的页"三条路径。
total := cloudAgentRunEventDeltaLimit + repository.CloudAgentJournalWindow*2
growAgentJournal(t, s, root.ID, total)
stored, err := s.repo.CloudAgentEventRecordCount("user", root.ID)
if err != nil || int(stored) != total {
t.Fatalf("入库条数 = %d(err=%v),期望 %d", stored, err, total)
}
persisted, err := s.repo.CloudAgent("user", root.ID)
if err != nil {
t.Fatal(err)
}
if persisted.EventCount != total {
t.Fatalf("事件水位 = %d,期望 %d", persisted.EventCount, total)
}
rebuilt, err := cloudAgentDecode(persisted)
if err != nil {
t.Fatalf("重建失败: %v", err)
}
if len(rebuilt.Events) != repository.CloudAgentJournalWindow {
t.Fatalf("载入窗口 = %d 条,期望 %d", len(rebuilt.Events), repository.CloudAgentJournalWindow)
}
if rebuilt.EventSeqBase != total-repository.CloudAgentJournalWindow {
t.Fatalf("窗口水位 = %d,期望 %d", rebuilt.EventSeqBase, total-repository.CloudAgentJournalWindow)
}
if rebuilt.Events[len(rebuilt.Events)-1].Seq != total {
t.Fatalf("窗口末条序号 = %d,期望 %d", rebuilt.Events[len(rebuilt.Events)-1].Seq, total)
}
// 默认视图:尾部一窗,并且如实报告"还有更早的记录"。
view, err := s.CloudAgentRun("user", root.ID)
if err != nil {
t.Fatal(err)
}
assertAgentEventPage(t, view, total-repository.CloudAgentJournalWindow, total, true)
if len(view.Events) != cloudAgentRunEventPageLimit {
t.Fatalf("默认页 = %d 条,期望 %d", len(view.Events), cloudAgentRunEventPageLimit)
}
// eventLimit 是页大小而不是忽略:小于窗口时只返回尾部这么多条。
narrowed, err := s.CloudAgentRun("user", root.ID, CloudAgentRunViewOptions{EventLimit: 10})
if err != nil {
t.Fatal(err)
}
assertAgentEventPage(t, narrowed, total-10, total, true)
if len(narrowed.Events) != 10 {
t.Fatalf("eventLimit=10 返回 %d 条", len(narrowed.Events))
}
// eventLimit 大于窗口:必须从事件表往前补齐,而不是只给窗口里那 40 条。
deeper, err := s.CloudAgentRun("user", root.ID, CloudAgentRunViewOptions{EventLimit: repository.CloudAgentJournalWindow + 10})
if err != nil {
t.Fatal(err)
}
assertAgentEventPage(t, deeper, total-repository.CloudAgentJournalWindow-10, total, true)
if len(deeper.Events) != repository.CloudAgentJournalWindow+10 {
t.Fatalf("补齐后页大小 = %d,期望 %d", len(deeper.Events), repository.CloudAgentJournalWindow+10)
}
// HTTP 单次上限(500)刚好覆盖不到整条日志:页对齐到 500,并如实标记还有更早记录。
capped, err := s.CloudAgentRun("user", root.ID, CloudAgentRunViewOptions{EventLimit: cloudAgentRunEventDeltaLimit})
if err != nil {
t.Fatal(err)
}
assertAgentEventPage(t, capped, total-cloudAgentRunEventDeltaLimit, total, true)
if len(capped.Events) != cloudAgentRunEventDeltaLimit {
t.Fatalf("上限页 = %d 条,期望 %d", len(capped.Events), cloudAgentRunEventDeltaLimit)
}
// 内部调用方(续轮收束)不受 HTTP 上限约束:更大的页必须能拿到整条日志,
// 否则交接帧会因为窗口而漏掉上一轮的真实改动。
full, err := s.CloudAgentRun("user", root.ID, CloudAgentRunViewOptions{EventLimit: cloudAgentContinuationEventLimit})
if err != nil {
t.Fatal(err)
}
assertAgentEventPage(t, full, 0, total, false)
if len(full.Events) != total {
t.Fatalf("全量页 = %d 条,期望 %d", len(full.Events), total)
}
// 增量:sinceSeq 之后只返回更新的部分,且不再声称截断(客户端自己拿着游标)。
delta, err := s.CloudAgentRun("user", root.ID, CloudAgentRunViewOptions{SinceSeq: total - 2})
if err != nil {
t.Fatal(err)
}
assertAgentEventPage(t, delta, total-2, total, false)
if len(delta.Events) != 2 || delta.Events[0].Seq != total-1 {
t.Fatalf("增量视图不对:%+v", delta.Events)
}
// 增量页也必须尊重 eventLimit;不能先取满默认上限,再把内存尾窗追加到页尾。
limitedDelta, err := s.CloudAgentRun("user", root.ID, CloudAgentRunViewOptions{SinceSeq: 1, EventLimit: 10})
if err != nil {
t.Fatal(err)
}
if len(limitedDelta.Events) != 10 || limitedDelta.Events[0].Seq != 2 || limitedDelta.Events[9].Seq != 11 {
t.Fatalf("受限增量页不连续或超出上限:%+v", limitedDelta.Events)
}
// 跨窗口边界:游标落在窗口之前,表与窗口要共同补齐且序号连续。
crossing, err := s.CloudAgentRun("user", root.ID, CloudAgentRunViewOptions{SinceSeq: rebuilt.EventSeqBase - 2})
if err != nil {
t.Fatal(err)
}
assertAgentEventPage(t, crossing, rebuilt.EventSeqBase-2, total, false)
if len(crossing.Events) != repository.CloudAgentJournalWindow+2 || crossing.Events[0].Seq != rebuilt.EventSeqBase-1 {
t.Fatalf("跨边界增量不对:%d 条,首条 %d", len(crossing.Events), crossing.Events[0].Seq)
}
// 游标已经追平(甚至越界):返回空页而不是报错,客户端据此停止轮询。
caughtUp, err := s.CloudAgentRun("user", root.ID, CloudAgentRunViewOptions{SinceSeq: total})
if err != nil {
t.Fatal(err)
}
assertAgentEventPage(t, caughtUp, total-repository.CloudAgentJournalWindow, total, false)
if len(caughtUp.Events) != 0 || caughtUp.LatestSeq != 0 {
t.Fatalf("追平后仍返回事件:%+v", caughtUp.Events)
}
beyond, err := s.CloudAgentRun("user", root.ID, CloudAgentRunViewOptions{SinceSeq: total + 100})
if err != nil || len(beyond.Events) != 0 {
t.Fatalf("越界游标应返回空页:events=%d err=%v", len(beyond.Events), err)
}
// 追加一条:增量视图只看得到它,latestSeq 就是下一次的游标。
appended, err := s.repo.CloudAgent("user", root.ID)
if err != nil {
t.Fatal(err)
}
grown, err := cloudAgentDecode(appended)
if err != nil {
t.Fatal(err)
}
grown.event(root.ID, "assistant_message", map[string]any{"text": "追加一条"})
if err := s.repo.MutateCloudAgent("user", root.ID, appended.Revision, func(current *model.CloudAgentExecution, _ *repository.Repository) error {
return cloudAgentSave(current, &grown)
}); err != nil {
t.Fatal(err)
}
next, err := s.CloudAgentRun("user", root.ID, CloudAgentRunViewOptions{SinceSeq: total})
if err != nil {
t.Fatal(err)
}
assertAgentEventPage(t, next, total, total+1, false)
if len(next.Events) != 1 || next.Events[0].Seq != total+1 {
t.Fatalf("追加后的增量不对:%+v", next.Events)
}
}
func TestCloudAgentRunPaginationFieldsAreStableJSON(t *testing.T) {
raw, err := json.Marshal(CloudAgentRun{})
if err != nil {
t.Fatal(err)
}
var encoded map[string]any
if err := json.Unmarshal(raw, &encoded); err != nil {
t.Fatal(err)
}
for _, field := range []string{"eventSeqBase", "eventCount", "latestSeq", "eventsTruncated"} {
if _, ok := encoded[field]; !ok {
t.Fatalf("分页字段 %q 在零值响应中缺失:%s", field, raw)
}
}
}
// growAgentJournal 按真实保存路径把事件追加到 total 条。一次转移不可能产生几百条事件
// (超过 cloudAgentEventWindowSanityLimit 会被判"窗口异常"),因此分多轮追加:每轮重新
// 载入(窗口右移)后再写,顺带覆盖"窗口左移之后继续追加,历史不被截断"。
func growAgentJournal(t *testing.T, s *Service, runID string, total int) {
t.Helper()
for {
run, err := s.repo.CloudAgent("user", runID)
if err != nil {
t.Fatal(err)
}
state, err := cloudAgentDecode(run)
if err != nil {
t.Fatal(err)
}
missing := total - (state.EventSeqBase + len(state.Events))
if missing <= 0 {
return
}
for index := 0; index < min(missing, cloudAgentEventWindowSanityLimit/2); index++ {
state.event(runID, "tool_completed", map[string]any{"toolName": "canvas_get_state", "text": strings.Repeat("读", 20)})
}
if err := s.repo.MutateCloudAgent("user", runID, run.Revision, func(current *model.CloudAgentExecution, _ *repository.Repository) error {
return cloudAgentSave(current, &state)
}); err != nil {
t.Fatal(err)
}
}
}
// assertAgentEventPage 校验分页契约本身:条数、水位、序号不变量、latestSeq 与截断标记。
func assertAgentEventPage(t *testing.T, run *CloudAgentRun, wantBase, wantCount int, wantTruncated bool) {
t.Helper()
if run.EventSeqBase != wantBase {
t.Fatalf("eventSeqBase = %d,期望 %d", run.EventSeqBase, wantBase)
}
if run.EventCount != wantCount {
t.Fatalf("eventCount = %d,期望 %d", run.EventCount, wantCount)
}
if run.EventsTruncated != wantTruncated {
t.Fatalf("eventsTruncated = %v,期望 %v", run.EventsTruncated, wantTruncated)
}
for index, event := range run.Events {
if event.Seq != run.EventSeqBase+index+1 {
t.Fatalf("分页不变量被破坏:events[%d].seq = %d,期望 eventSeqBase(%d)+%d+1", index, event.Seq, run.EventSeqBase, index)
}
}
if len(run.Events) > 0 && run.LatestSeq != run.Events[len(run.Events)-1].Seq {
t.Fatalf("latestSeq = %d,期望末条 %d", run.LatestSeq, run.Events[len(run.Events)-1].Seq)
}
// 截断标记只表示"水位之前还有记录",不能凭空出现。
if run.EventsTruncated && run.EventSeqBase <= 0 {
t.Fatalf("eventsTruncated 与 eventSeqBase 不自洽:base=%d", run.EventSeqBase)
}
}
// 事件表里没有该运行的行时(升级前的旧检查点把事件放在 state_json 里),运行详情必须
// 仍能读到这些事件,且水位为 0(它们就是全部)。
func TestCloudAgentRunDetailFallsBackToLegacyStateEvents(t *testing.T) {
s, db, root := reliableAgentRoot(t)
run, err := s.repo.CloudAgent("user", root.ID)
if err != nil {
t.Fatal(err)
}
state, err := cloudAgentDecode(run)
if err != nil {
t.Fatal(err)
}
for index := 0; index < 5; index++ {
state.event(root.ID, "assistant_message", map[string]any{"text": "legacy"})
}
legacyJSON, err := json.Marshal(state)
if err != nil {
t.Fatal(err)
}
// 还原成升级前的形态:事件在 state_json 里、事件表为空、水位为 0。
if err := db.Transaction(func(tx *gorm.DB) error {
if err := tx.Where("run_id = ?", root.ID).Delete(&model.CloudAgentEventRecord{}).Error; err != nil {
return err
}
return tx.Model(&model.CloudAgentExecution{}).Where("id = ?", root.ID).Updates(map[string]any{
"checkpoint_version": 0, "event_count": 0, "message_count": 0, "state_json": string(legacyJSON),
}).Error
}); err != nil {
t.Fatal(err)
}
view, err := s.CloudAgentRun("user", root.ID)
if err != nil {
t.Fatal(err)
}
assertAgentEventPage(t, view, 0, len(state.Events), false)
if len(view.Events) != len(state.Events) {
t.Fatalf("旧检查点事件回退失败:%d 条,期望 %d", len(view.Events), len(state.Events))
}
// 增量也要能从内存窗口过滤出来(表里没有行)。
cursor := view.Events[1].Seq
delta, err := s.CloudAgentRun("user", root.ID, CloudAgentRunViewOptions{SinceSeq: cursor})
if err != nil {
t.Fatal(err)
}
if len(delta.Events) != len(state.Events)-2 || delta.Events[0].Seq != cursor+1 {
t.Fatalf("旧检查点增量回退失败:cursor=%d events=%+v", cursor, delta.Events)
}
}
// 事件行缺失但水位不为 0:不能把"一行都没读到"当成空日志,否则运行详情会给出
// 一个水位虚高、事件却为空的页,客户端会以为历史已经被清理。
func TestCloudAgentDecodeRejectsMissingJournalRows(t *testing.T) {
s, db, root := reliableAgentRoot(t)
run, err := s.repo.CloudAgent("user", root.ID)
if err != nil {
t.Fatal(err)
}
state, err := cloudAgentDecode(run)
if err != nil {
t.Fatal(err)
}
state.event(root.ID, "tool_completed", map[string]any{"toolName": "canvas_get_state"})
if err := s.repo.MutateCloudAgent("user", root.ID, run.Revision, func(current *model.CloudAgentExecution, _ *repository.Repository) error {
return cloudAgentSave(current, &state)
}); err != nil {
t.Fatal(err)
}
if err := db.Where("run_id = ?", root.ID).Delete(&model.CloudAgentEventRecord{}).Error; err != nil {
t.Fatal(err)
}
orphaned, err := s.repo.CloudAgent("user", root.ID)
if err != nil {
t.Fatal(err)
}
if _, err = cloudAgentDecode(orphaned); err == nil || !strings.Contains(err.Error(), "journal is incomplete") {
t.Fatalf("事件行全缺时仍被接受:%v", err)
}
}