Files
weichen 73769f3a07 Land project inbox: aggregation, archive-to-project, project SSE (CF3)
Why: D48-D51 approved — cross-session inbox so todos are not lost across
conversations.
Evidence: store v12 index + ListProjectCards aggregate (D49 verdict:
project cards any status + per-conversation action_required only);
cardsvc project-stream emit/mirror via project:<id> pseudo-key (helpers
in core PR #11/#12) and ToProject (idempotent via ref, dismisses origin,
refuses flow-bound hitl/cmd cards); runmgr scope=project ownership;
llm.go scope param on submit_card/card_begin; server routes
GET /api/projects/{id}/cards, POST /api/cards/{id}/to_project, events
scope=project; vendored core synced; new tests ToProject/,
TestLLMRunnerCardScopeProject, e2e TestProjectInbox; e2e server now
wires production card emitter (no-op before).
Impact: additive; no change to existing conversation flows.
Refs: documents proposal 2026-09-30-card-stream-artifact-unify-project-inbox.md (D48-D52).
2026-09-30 19:11:11 +00:00

311 lines
9.8 KiB
Go
Raw Permalink 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 cardsvc
import (
"errors"
"sync"
"testing"
"time"
"agent-system-backend/internal/store"
core "agent-system-core"
)
func openTest(t *testing.T) (*store.Store, *Service, func() []core.StreamEvent) {
t.Helper()
st, err := store.OpenSQLite(t.TempDir() + "/test.db")
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { st.Close() })
var mu sync.Mutex
var events []core.StreamEvent
emit := func(convID, runID string, typ core.StreamEventType, payload any) error {
mu.Lock()
events = append(events, core.StreamEvent{ConversationID: convID, Type: typ})
mu.Unlock()
return nil
}
return st, New(st, emit), func() []core.StreamEvent {
mu.Lock()
defer mu.Unlock()
return append([]core.StreamEvent(nil), events...)
}
}
func sampleCard() core.ActivityCard {
return core.ActivityCard{
ProjectID: "prj_1", ConversationID: "cnv_1", Kind: core.CardKindConfirm,
Title: "相关性确认", Status: core.CardActionRequired, Tone: "warn",
Actions: []core.UIAction{{ID: "keep", Label: "相关 · 入库"}, {ID: "drop", Label: "无关 · 仅归档"}},
}
}
// TestAppendStreamThrottleAndMerge 验证 D45:Append 并入尾部 markdown 节点
// (尾节点非 markdown 则新增),card.updated 按窗口节流(窗口内只落库不发布),
// SetStatus 终态恒发布。
func TestAppendStreamThrottleAndMerge(t *testing.T) {
st, svc, evsFn := openTest(t)
if _, err := st.CreateProject(store.OwnerDefault, "p"); err != nil {
t.Fatal(err)
}
base := sampleCard()
base.Kind = core.CardKindResult
base.Status = core.CardActive
base.Body = []core.UINode{{Type: core.UIKV, KV: [][2]string{{"摘要", "流式"}}}}
card, err := svc.Create(store.OwnerDefault, base, "")
if err != nil {
t.Fatal(err)
}
// 受控时钟:t0 起步,逐次 +100ms(< 250ms 窗口),最后一步跨窗口。
now := time.Now()
svc.now = func() time.Time { return now }
if err := svc.Append(store.OwnerDefault, card.ID, "## 进展\n"); err != nil {
t.Fatal(err)
}
now = now.Add(100 * time.Millisecond)
if err := svc.Append(store.OwnerDefault, card.ID, "- 全绿\n"); err != nil {
t.Fatal(err)
}
now = now.Add(100 * time.Millisecond)
if err := svc.Append(store.OwnerDefault, card.ID, "## 风险\n"); err != nil {
t.Fatal(err)
}
// 前三次都在 250ms 窗口内:只有第一次发布 card.updated(会话流 + 项目流镜像
// 各一次,D50)。
countUpdated := func(channel string) int {
n := 0
for _, e := range evsFn() {
if e.Type == core.EvCardUpdated && string(e.ConversationID) == channel {
n++
}
}
return n
}
if updated := countUpdated("cnv_1"); updated != 1 {
t.Fatalf("窗口内会话流 card.updated 应节流为 1 次,got %d", updated)
}
if mirrored := countUpdated("project:prj_1"); mirrored != 1 {
t.Fatalf("窗口内项目流镜像应为 1 次,got %d", mirrored)
}
now = now.Add(300 * time.Millisecond)
if err := svc.Append(store.OwnerDefault, card.ID, "- 无\n"); err != nil {
t.Fatal(err)
}
if _, err := svc.SetStatus(store.OwnerDefault, card.ID, core.CardDone, "ok", ""); err != nil {
t.Fatal(err)
}
if updated := countUpdated("cnv_1"); updated != 3 { // 首次 + 跨窗口一次 + SetStatus 的 Update 恒发布
t.Fatalf("终态后会话流 card.updated 应为 3 次,got %d", updated)
}
if mirrored := countUpdated("project:prj_1"); mirrored != 3 {
t.Fatalf("终态后项目流镜像应为 3 次,got %d", mirrored)
}
got, err := svc.Get(store.OwnerDefault, card.ID)
if err != nil {
t.Fatal(err)
}
if got.Status != core.CardDone {
t.Errorf("status = %s, want done", got.Status)
}
full := ""
for _, n := range got.Body {
if n.Type == core.UIMarkdown {
full += n.Markdown
}
}
if full != "## 进展\n- 全绿\n## 风险\n- 无\n" {
t.Errorf("流式正文合并结果不符: %q", full)
}
}
func TestCardLifecycle(t *testing.T) {
st, svc, evsFn := openTest(t)
if _, err := st.CreateProject(store.OwnerDefault, "p"); err != nil {
t.Fatal(err)
}
card, err := svc.Create(store.OwnerDefault, sampleCard(), "")
if err != nil {
t.Fatal(err)
}
if card.ID == "" || card.CreatedAt.IsZero() {
t.Fatal("create must fill id/timestamps")
}
// 未裁决卡不可再点不存在动作
if _, _, err := svc.Action(store.OwnerDefault, card.ID, "nonexistent", ""); !errors.Is(err, ErrNoAction) {
t.Fatalf("unknown action: %v", err)
}
got, label, err := svc.Action(store.OwnerDefault, card.ID, "keep", "")
if err != nil || label != "相关 · 入库" || got.Status != core.CardResolved {
t.Fatalf("action: %v %q %s", err, label, got.Status)
}
// 已决卡幂等拒绝重复裁决
if _, _, err := svc.Action(store.OwnerDefault, card.ID, "keep", ""); !errors.Is(err, ErrNotAwaiting) {
t.Fatalf("re-action: %v", err)
}
evs := evsFn()
if len(evs) < 2 || evs[0].Type != core.EvCardCreated || evs[len(evs)-1].Type != core.EvCardResolved {
t.Fatalf("events: %+v", evs)
}
}
func TestPinDismissAndList(t *testing.T) {
st, svc, _ := openTest(t)
if _, err := st.CreateProject(store.OwnerDefault, "p"); err != nil {
t.Fatal(err)
}
a, _ := svc.Create(store.OwnerDefault, core.ActivityCard{ProjectID: "prj_1", ConversationID: "cnv_1", Kind: core.CardKindStatus, Title: "A"}, "")
b, _ := svc.Create(store.OwnerDefault, core.ActivityCard{ProjectID: "prj_1", ConversationID: "cnv_1", Kind: core.CardKindStatus, Title: "B"}, "")
if _, err := svc.SetPinned(store.OwnerDefault, a.ID, true); err != nil {
t.Fatal(err)
}
got, _ := svc.Get(store.OwnerDefault, a.ID)
if !got.Pinned {
t.Fatal("pin failed")
}
if _, err := svc.Dismiss(store.OwnerDefault, b.ID); err != nil {
t.Fatal(err)
}
gotB, _ := svc.Get(store.OwnerDefault, b.ID)
if !gotB.Status.Terminal() {
t.Fatal("dismiss should reach terminal state")
}
list, err := svc.List(store.OwnerDefault, "cnv_1")
if err != nil || len(list) != 2 {
t.Fatalf("list: %v %d", err, len(list))
}
// 租户隔离:他人不可见
if other, err := svc.List("someone-else", "cnv_1"); err != nil || len(other) != 0 {
t.Fatalf("owner isolation broken: %v %d", err, len(other))
}
}
func TestResolveByRefIdempotent(t *testing.T) {
st, svc, _ := openTest(t)
prj, err := st.CreateProject(store.OwnerDefault, "p")
if err != nil {
t.Fatal(err)
}
conv, err := st.CreateConversation(store.OwnerDefault, prj.ID, "c", "")
if err != nil {
t.Fatal(err)
}
card := sampleCard()
card.ProjectID, card.ConversationID = prj.ID, conv.ID
proj, err := svc.Create(store.OwnerDefault, card, "hitl:x1")
if err != nil {
t.Fatal(err)
}
if err := svc.ResolveByRef(store.OwnerDefault, "hitl:x1", "approve", "同意"); err != nil {
t.Fatal(err)
}
if err := svc.ResolveByRef(store.OwnerDefault, "hitl:x1", "approve", "again"); err != nil {
t.Fatalf("second resolve must be idempotent no-op: %v", err)
}
got, _ := svc.Get(store.OwnerDefault, proj.ID)
if got.Status != core.CardResolved {
t.Fatal("should be resolved")
}
// 未知 ref:静默
if err := svc.ResolveByRef(store.OwnerDefault, "hitl:none", "", ""); err != nil {
t.Fatalf("unknown ref: %v", err)
}
}
// TestToProjectArchiveAndIdempotent 验证 D48/D49:会话卡留档为项目级卡(新 ID、
// 锚点指原卡、body 附留档来源、原卡 dismissed);重复调用幂等;HITL 投影卡拒绝。
func TestToProjectArchiveAndIdempotent(t *testing.T) {
st, svc, _ := openTest(t)
prj, err := st.CreateProject(store.OwnerDefault, "p")
if err != nil {
t.Fatal(err)
}
conv, err := st.CreateConversation(store.OwnerDefault, prj.ID, "c", "")
if err != nil {
t.Fatal(err)
}
card := sampleCard()
card.ProjectID, card.ConversationID = prj.ID, conv.ID
orig, err := svc.Create(store.OwnerDefault, card, "")
if err != nil {
t.Fatal(err)
}
copy1, err := svc.ToProject(store.OwnerDefault, orig.ID)
if err != nil {
t.Fatal(err)
}
if copy1.ID == orig.ID || copy1.ConversationID != "" || copy1.ProjectID != prj.ID {
t.Fatalf("副本归属不符: %+v", copy1)
}
if copy1.AnchorMessageID != orig.ID {
t.Errorf("副本锚点应指向原卡: %q", copy1.AnchorMessageID)
}
hasNote := false
for _, n := range copy1.Body {
if n.Type == core.UIKV {
for _, kv := range n.KV {
if kv[0] == "留档" {
hasNote = true
}
}
}
}
if !hasNote {
t.Error("副本 body 缺留档来源 kv")
}
origNow, _ := svc.Get(store.OwnerDefault, orig.ID)
if origNow.Status != core.CardDismissed {
t.Errorf("原卡应 dismissed 留痕, got %s", origNow.Status)
}
// 幂等:重复留档返回既有副本,不再新造。
copy2, err := svc.ToProject(store.OwnerDefault, orig.ID)
if err != nil || copy2.ID != copy1.ID {
t.Fatalf("重复留档应幂等: %v %+v", err, copy2)
}
// 聚合口径(D49):项目视图 = 项目级卡 ∪ 各会话未决卡;done/dismissed 不聚合。
// (原卡已 dismissed → 不聚合;copy 项目级 → 聚合。)
other := sampleCard()
other.ProjectID, other.ConversationID = prj.ID, conv.ID
other.Title = "他会话未决"
if _, err := svc.Create(store.OwnerDefault, other, ""); err != nil {
t.Fatal(err)
}
doneCard := sampleCard()
doneCard.ProjectID, doneCard.ConversationID = prj.ID, conv.ID
doneCard.Status = core.CardDone
doneCard.Title = "他会话已完成"
if _, err := svc.Create(store.OwnerDefault, doneCard, ""); err != nil {
t.Fatal(err)
}
cards, lerr := st.ListProjectCards(store.OwnerDefault, prj.ID)
if lerr != nil {
t.Fatal(lerr)
}
if len(cards) != 2 {
t.Fatalf("project list 应为 副本+他会话未决 共 2 张,got %d", len(cards))
}
for _, c := range cards {
if c.ConversationID != "" && c.Status != core.CardActionRequired {
t.Errorf("会话卡仅未决可聚合: %+v", c)
}
}
// HITL 投影卡拒绝留档(流程状态机绑定)。
hc := sampleCard()
hc.ProjectID, hc.ConversationID = prj.ID, conv.ID
h, err := svc.Create(store.OwnerDefault, hc, "hitl:x9")
if err != nil {
t.Fatal(err)
}
if _, err := svc.ToProject(store.OwnerDefault, h.ID); err == nil {
t.Error("HITL 投影卡应拒绝留档")
}
}