feat(activity): 任务会话历史反向分页 + SSE 可靠性整改
后端:
- ActivityPage(会话过滤 main/plan/intent + before 反向分页)、ActivityMaxID
(任务级快照游标)、ListByKindPage(Intent 列表分页,突破固定 300 上限)
- 新增 GET /api/exploration/activity/history(items/snapshot_cursor/
earliest_cursor/has_more);snapshot_cursor 为任务级游标
- streamActivity:一次 2000 → 循环补偿追平;输出标准 SSE id 行;支持
Last-Event-ID;补偿查询错误记录日志并关闭连接
- intents 支持分页形态(?before/?page → {items,has_more}),无参数保持旧裸数组
- 新增 idx_act_worker(exploration_id, worker, id) 支撑 Main/Plan 反向翻页
(schema.sql 幂等,旧库启动自动补建,无迁移)
前端:
- sessions-tab 单一 allActivity → 分会话缓存;首屏只加载 Main 最新页并据其
快照游标建唯一任务级 SSE;Plan/Worker 点击懒加载
- 统一 mergeBySeq 合并去重;未激活会话只维护 lastTs/unread;向上滚动分页
加载旧历史并保持滚动位置;每会话内存上限裁剪
- 按 session_key 写各自缓存 + 每 key 请求令牌防会话切换串数据;SSE 连接状态
可见、加载失败可重试,异常不再静默为"暂无消息"
- api 新增 activityHistory / intentsPage
测试:db/activity_page_test.go 覆盖多 Agent 过滤、反向翻页无重复无遗漏、
快照游标、Intent 超页分页(已对真实 PostgreSQL 通过)
This commit is contained in:
1 parent
95aba396c2
commit
79493d5a4a
6 files changed
+906
-130
No files matched your search
@@ -0,0 +1,178 @@
|
||||
package db
|
||||
|
||||
import "testing"
|
||||
|
||||
// TestActivityPageSessions covers the reverse-paginated, per-session history added
|
||||
// for the SSE remediation: Main/Plan/Worker filtering, before-cursor paging without
|
||||
// gaps/overlap, hasMore, and the task-level snapshot cursor. Mirrors docs §11.1.
|
||||
func TestActivityPageSessions(t *testing.T) {
|
||||
d, err := Open(testDSN(t))
|
||||
if err != nil {
|
||||
t.Skipf("postgres unavailable (%v) — skipping", err)
|
||||
}
|
||||
defer d.Close()
|
||||
|
||||
expID, err := d.CreateExploration("test", "分页历史")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer d.Exec(`DELETE FROM explorations WHERE id=$1`, expID)
|
||||
es := d.Exploration(expID)
|
||||
|
||||
intentA, err := es.AddIntent(map[string]any{"summary": "intent A"}, 5, nil, "planner")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
intentB, err := es.AddIntent(map[string]any{"summary": "intent B"}, 5, nil, "planner")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// Interleave a mix of agents so a session filter must actually discriminate.
|
||||
// 25 main, 25 planner (Goal+Planner share worker=planner), 30 workerA, 5 workerB.
|
||||
appendN := func(n int, a Activity) {
|
||||
for range n {
|
||||
if _, err := es.AppendActivity(a); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
}
|
||||
// Interleaving order matters: emit round-robin-ish so ids of one session are
|
||||
// scattered, proving the WHERE filter (not a contiguous range) is what selects.
|
||||
for range 25 {
|
||||
appendN(1, Activity{Worker: "mainagent", Kind: "text", Summary: "m"})
|
||||
appendN(1, Activity{Worker: "planner", Kind: "text", Summary: "p"})
|
||||
appendN(1, Activity{NodeID: &intentA, Worker: "work#1", Kind: "text", Summary: "a"})
|
||||
}
|
||||
appendN(5, Activity{NodeID: &intentA, Worker: "work#1", Kind: "text", Summary: "a2"}) // workerA → 30 total
|
||||
appendN(5, Activity{NodeID: &intentB, Worker: "work#2", Kind: "text", Summary: "b"})
|
||||
|
||||
// snapshot cursor = max id across the whole task.
|
||||
snap, err := es.ActivityMaxID()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// Helper: page through a whole session backward and assert coverage.
|
||||
collect := func(f ActivitySessionFilter, pageSize int) []Activity {
|
||||
var all []Activity
|
||||
before := int64(0)
|
||||
seen := map[int64]bool{}
|
||||
for {
|
||||
items, hasMore, err := es.ActivityPage(f, before, pageSize)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// ascending order within a page
|
||||
for i := 1; i < len(items); i++ {
|
||||
if items[i-1].ID >= items[i].ID {
|
||||
t.Fatalf("page not ascending: %d >= %d", items[i-1].ID, items[i].ID)
|
||||
}
|
||||
}
|
||||
// no overlap across pages
|
||||
for _, a := range items {
|
||||
if seen[a.ID] {
|
||||
t.Fatalf("duplicate id %d across pages", a.ID)
|
||||
}
|
||||
seen[a.ID] = true
|
||||
}
|
||||
all = append([]Activity{}, append(items, all...)...) // prepend older page
|
||||
if !hasMore || len(items) == 0 {
|
||||
break
|
||||
}
|
||||
before = items[0].ID
|
||||
}
|
||||
return all
|
||||
}
|
||||
|
||||
main := collect(ActivitySessionFilter{Worker: "mainagent"}, 10)
|
||||
if len(main) != 25 {
|
||||
t.Fatalf("main count = %d, want 25", len(main))
|
||||
}
|
||||
plan := collect(ActivitySessionFilter{Worker: "planner"}, 7)
|
||||
if len(plan) != 25 {
|
||||
t.Fatalf("plan count = %d, want 25", len(plan))
|
||||
}
|
||||
wa := collect(ActivitySessionFilter{NodeID: &intentA}, 8)
|
||||
if len(wa) != 30 {
|
||||
t.Fatalf("workerA count = %d, want 30", len(wa))
|
||||
}
|
||||
wb := collect(ActivitySessionFilter{NodeID: &intentB}, 8)
|
||||
if len(wb) != 5 {
|
||||
t.Fatalf("workerB count = %d, want 5", len(wb))
|
||||
}
|
||||
// full ascending order across the reconstructed session
|
||||
for i := 1; i < len(wa); i++ {
|
||||
if wa[i-1].ID >= wa[i].ID {
|
||||
t.Fatalf("reconstructed session not ascending at %d", i)
|
||||
}
|
||||
}
|
||||
// latest page (before=0) must include the session's newest record.
|
||||
latest, hasMore, err := es.ActivityPage(ActivitySessionFilter{NodeID: &intentA}, 0, 10)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !hasMore {
|
||||
t.Fatalf("workerA should have more than one page")
|
||||
}
|
||||
if latest[len(latest)-1].ID != wa[len(wa)-1].ID {
|
||||
t.Fatalf("latest page missing newest record")
|
||||
}
|
||||
// snapshot cursor is the whole-task max, ≥ any session's max.
|
||||
if snap < wa[len(wa)-1].ID {
|
||||
t.Fatalf("snapshot %d < workerA max %d", snap, wa[len(wa)-1].ID)
|
||||
}
|
||||
}
|
||||
|
||||
// TestListByKindPage covers the paged worker(intent) list that lets the session list
|
||||
// reach past the old fixed 300 cap (docs §8 / §11.1 item 10).
|
||||
func TestListByKindPage(t *testing.T) {
|
||||
d, err := Open(testDSN(t))
|
||||
if err != nil {
|
||||
t.Skipf("postgres unavailable (%v) — skipping", err)
|
||||
}
|
||||
defer d.Close()
|
||||
|
||||
expID, err := d.CreateExploration("test", "意图分页")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer d.Exec(`DELETE FROM explorations WHERE id=$1`, expID)
|
||||
es := d.Exploration(expID)
|
||||
|
||||
const total = 25
|
||||
for range total {
|
||||
if _, err := es.AddIntent(map[string]any{"summary": "i"}, 1, nil, "planner"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
// Page backward in chunks of 10; expect 10,10,5 and hasMore false on last.
|
||||
seen := map[int64]bool{}
|
||||
before := int64(0)
|
||||
pages := 0
|
||||
for {
|
||||
items, hasMore, err := es.ListByKindPage(KindIntent, before, 10)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
pages++
|
||||
for _, n := range items {
|
||||
if seen[n.ID] {
|
||||
t.Fatalf("dup intent %d across pages", n.ID)
|
||||
}
|
||||
seen[n.ID] = true
|
||||
}
|
||||
if len(items) == 0 || !hasMore {
|
||||
break
|
||||
}
|
||||
// newest-first within a page → the oldest (smallest id) is last; page older
|
||||
// history before it next.
|
||||
before = items[len(items)-1].ID
|
||||
}
|
||||
if len(seen) != total {
|
||||
t.Fatalf("paged intents = %d, want %d", len(seen), total)
|
||||
}
|
||||
if pages < 3 {
|
||||
t.Fatalf("expected ≥3 pages for %d items at size 10, got %d", total, pages)
|
||||
}
|
||||
}
|
||||
@@ -267,6 +267,32 @@ WHERE exploration_id=$1 AND kind=$2 ORDER BY id DESC LIMIT $3`, s.expID, kind, l
|
||||
return scanNodes(rows)
|
||||
}
|
||||
|
||||
// ListByKindPage returns one newest-first page of nodes of a kind for reverse
|
||||
// pagination: up to `limit` nodes with id < `before` (before<=0 = the newest page).
|
||||
// hasMore reports whether still-older nodes exist, so a client (e.g. the worker
|
||||
// session list) can page past the old fixed cap instead of losing older intents.
|
||||
func (s *ExplorationStore) ListByKindPage(kind string, before int64, limit int) ([]*Node, bool, error) {
|
||||
if limit <= 0 {
|
||||
limit = 300
|
||||
}
|
||||
rows, err := s.db.Query(`SELECT `+nodeCols+` FROM exploration_nodes
|
||||
WHERE exploration_id=$1 AND kind=$2 AND ($3 <= 0 OR id < $3)
|
||||
ORDER BY id DESC LIMIT $4`, s.expID, kind, before, limit+1)
|
||||
if err != nil {
|
||||
return nil, false, err
|
||||
}
|
||||
defer rows.Close()
|
||||
nodes, err := scanNodes(rows)
|
||||
if err != nil {
|
||||
return nil, false, err
|
||||
}
|
||||
hasMore := len(nodes) > limit
|
||||
if hasMore {
|
||||
nodes = nodes[:limit]
|
||||
}
|
||||
return nodes, hasMore, nil
|
||||
}
|
||||
|
||||
// GetNode returns one node of this exploration by id (nil, nil if not found).
|
||||
func (s *ExplorationStore) GetNode(id int64) (*Node, error) {
|
||||
n, err := scanNode(s.db.QueryRow(`SELECT `+nodeCols+` FROM exploration_nodes WHERE id=$1 AND exploration_id=$2`, id, s.expID))
|
||||
@@ -560,6 +586,90 @@ FROM activity WHERE exploration_id=$1 AND id>$2 ORDER BY id LIMIT $3`, s.expID,
|
||||
return out, cursor, rows.Err()
|
||||
}
|
||||
|
||||
// ActivitySessionFilter selects one UI "session" within a task's activity stream.
|
||||
// Exactly one field is meaningful:
|
||||
// - Worker != "" → filter by worker name (Main = "mainagent", Plan = "planner").
|
||||
// Goal Agent 的第 0 轮拆解也以 worker="planner" 落库,故 Plan 会话完整覆盖 Goal+Planner。
|
||||
// - NodeID != nil → a Worker session, filtered by node_id (= intent id).
|
||||
// A zero value (both empty) matches the whole task (no session filter).
|
||||
type ActivitySessionFilter struct {
|
||||
Worker string
|
||||
NodeID *int64
|
||||
}
|
||||
|
||||
func (f ActivitySessionFilter) cond(argStart int) (string, []any) {
|
||||
switch {
|
||||
case f.NodeID != nil:
|
||||
return fmt.Sprintf(" AND node_id=$%d", argStart), []any{*f.NodeID}
|
||||
case f.Worker != "":
|
||||
return fmt.Sprintf(" AND worker=$%d", argStart), []any{f.Worker}
|
||||
default:
|
||||
return "", nil
|
||||
}
|
||||
}
|
||||
|
||||
// ActivityPage returns one page for reverse (newest-first) pagination of a session:
|
||||
// up to `limit` steps ending before id `before` (exclusive; before<=0 = the latest
|
||||
// page), returned in ASCENDING id order for direct display. hasMore reports whether
|
||||
// still-older steps exist before the returned window (so the client can stop loading
|
||||
// on scroll-up). Summary-only columns — detail is lazy-loaded via ActivityDetail.
|
||||
func (s *ExplorationStore) ActivityPage(f ActivitySessionFilter, before int64, limit int) ([]Activity, bool, error) {
|
||||
if limit <= 0 {
|
||||
limit = 200
|
||||
}
|
||||
const cols = `id, node_id, COALESCE(worker,''), COALESCE(kind,''), COALESCE(tool,''), COALESCE(tool_use_id,''), is_error, COALESCE(summary,''), created_at, input_tokens, output_tokens, cache_read_tokens, cache_write_tokens`
|
||||
args := []any{s.expID}
|
||||
cond, cargs := f.cond(len(args) + 1)
|
||||
args = append(args, cargs...)
|
||||
beforeArg := len(args) + 1
|
||||
args = append(args, before)
|
||||
limitArg := len(args) + 1
|
||||
args = append(args, limit+1) // one extra row to detect older history
|
||||
q := fmt.Sprintf(`SELECT `+cols+`
|
||||
FROM activity WHERE exploration_id=$1%s AND ($%d <= 0 OR id < $%d)
|
||||
ORDER BY id DESC LIMIT $%d`, cond, beforeArg, beforeArg, limitArg)
|
||||
rows, err := s.db.Query(q, args...)
|
||||
if err != nil {
|
||||
return nil, false, err
|
||||
}
|
||||
defer rows.Close()
|
||||
desc := []Activity{}
|
||||
for rows.Next() {
|
||||
var a Activity
|
||||
if err := rows.Scan(&a.ID, &a.NodeID, &a.Worker, &a.Kind, &a.Tool, &a.ToolUseID, &a.IsError, &a.Summary, &a.CreatedAt,
|
||||
&a.InputTokens, &a.OutputTokens, &a.CacheReadTokens, &a.CacheWriteTokens); err != nil {
|
||||
return nil, false, err
|
||||
}
|
||||
desc = append(desc, a)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, false, err
|
||||
}
|
||||
hasMore := len(desc) > limit
|
||||
if hasMore {
|
||||
desc = desc[:limit]
|
||||
}
|
||||
// reverse the newest-first window into ascending id order for display.
|
||||
out := make([]Activity, len(desc))
|
||||
for i, a := range desc {
|
||||
out[len(desc)-1-i] = a
|
||||
}
|
||||
return out, hasMore, nil
|
||||
}
|
||||
|
||||
// ActivityMaxID returns the task's current maximum activity id (0 when empty). It is
|
||||
// the task-level snapshot cursor handed to the SSE stream so history (id<=cursor) and
|
||||
// the live tail (id>cursor) meet with no gap — deliberately task-level, not per
|
||||
// session, since one SSE covers the whole task.
|
||||
func (s *ExplorationStore) ActivityMaxID() (int64, error) {
|
||||
var max sql.NullInt64
|
||||
err := s.db.QueryRow(`SELECT MAX(id) FROM activity WHERE exploration_id=$1`, s.expID).Scan(&max)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return max.Int64, nil
|
||||
}
|
||||
|
||||
// ActivityDetail lazily returns the full detail blob for one step.
|
||||
func (s *ExplorationStore) ActivityDetail(id int64) (string, error) {
|
||||
var d sql.NullString
|
||||
|
||||
@@ -187,6 +187,9 @@ CREATE TABLE IF NOT EXISTS activity (
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_act_node ON activity(exploration_id, node_id, id);
|
||||
CREATE INDEX IF NOT EXISTS idx_act_since ON activity(exploration_id, id);
|
||||
-- Main/Plan history pages filter by worker (both carry NULL node_id, so idx_act_node
|
||||
-- can't distinguish them); this covers reverse pagination of those sessions.
|
||||
CREATE INDEX IF NOT EXISTS idx_act_worker ON activity(exploration_id, worker, id);
|
||||
|
||||
-- =====================================================================
|
||||
-- C. LLM profiles
|
||||
|
||||
Reference in new issue
Block a user