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).
311 lines
9.8 KiB
Go
311 lines
9.8 KiB
Go
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 投影卡应拒绝留档")
|
||
}
|
||
}
|