ci / vet-and-test (pull_request) Successful in 1m2s
Why: Card registration or transcript persistence failures must not orphan paid work or discard a generated result, and ordinary questions must not match summary requests. Evidence: Registration/promote and message-insert fault regressions, implicit-question checks and scoped race tests pass; legacy projection caching preserves historical times. Impact: Fail before execution when tracking is unavailable, preserve full public final text with an explicit failure state, cache legacy projections once, and repair the concurrent material test fixture. Refs: feature/workbench-flow-completion independent review closure
303 lines
11 KiB
Go
303 lines
11 KiB
Go
package runmgr
|
||
|
||
import (
|
||
"fmt"
|
||
"strings"
|
||
"sync/atomic"
|
||
"testing"
|
||
|
||
"agent-system-backend/internal/runtime"
|
||
core "agent-system-core"
|
||
)
|
||
|
||
// holdRunner 阻塞到 release 才完成——材料整理去重需要子任务仍在运行的窗口。
|
||
type holdRunner struct {
|
||
release chan struct{}
|
||
id atomic.Value
|
||
}
|
||
|
||
func (h *holdRunner) Name() string { return "hold" }
|
||
|
||
func (h *holdRunner) Execute(rc runtime.RunCtx) error {
|
||
if src, ok := rc.(interface{ SubagentID() string }); ok {
|
||
h.id.Store(src.SubagentID())
|
||
}
|
||
<-h.release
|
||
return rc.SetAssistantParts([]core.Part{{Type: core.PartText, Text: "整理完成:三份材料的要点与冲突已汇总。"}})
|
||
}
|
||
|
||
func materialDocs() []core.ParsedDocument {
|
||
return []core.ParsedDocument{
|
||
{Meta: core.DocMeta{Filename: "会议纪要.docx", Parser: "docx"}, Blocks: []core.DocBlock{{Type: core.BlockPara, Text: "决定上线新模块,预算 50 万,2026-11 前完成。"}}},
|
||
{Meta: core.DocMeta{Filename: "评审意见.pdf", Parser: "pdf"}, Blocks: []core.DocBlock{{Type: core.BlockPara, Text: "对预算规模存疑,建议分两期。"}}},
|
||
{Meta: core.DocMeta{Filename: "需求清单.md", Parser: "md"}, Blocks: []core.DocBlock{{Type: core.BlockPara, Text: "共 12 项需求,3 项依赖外部接口。"}}},
|
||
}
|
||
}
|
||
|
||
func secondRunCtx(c *runCtx, docs []core.ParsedDocument, userText string) *runCtx {
|
||
msg := core.Message{ConversationID: c.run.ConversationID}
|
||
if userText != "" {
|
||
msg.Parts = []core.Part{{Type: core.PartText, Text: userText}}
|
||
}
|
||
return &runCtx{
|
||
mgr: c.mgr, run: core.Run{ID: "run_second", ConversationID: c.run.ConversationID, ProjectID: c.run.ProjectID, Owner: c.run.Owner},
|
||
eff: c.eff, userMessage: msg, attachments: docs,
|
||
}
|
||
}
|
||
|
||
// 验收主用例的后端形态:三附件、无文字、直接发送 → 立即预启动、建卡、
|
||
// 进行中去重、完成后按内容键不重跑。
|
||
func TestMaterialIntakeAttachmentOnlyLifecycle(t *testing.T) {
|
||
hold := &holdRunner{release: make(chan struct{})}
|
||
c, events := newCardSpawnEnv(t, hold)
|
||
c.mgr.runner = noopRunner{}
|
||
c.attachments = materialDocs()
|
||
|
||
note := c.mgr.materialIntake(c)
|
||
if !strings.Contains(note, "系统已自动启动材料整理后台任务") {
|
||
t.Fatalf("pre-spawn note = %q", note)
|
||
}
|
||
waitUntil(t, func() bool {
|
||
cards, err := c.mgr.cards.List(c.run.Owner, c.run.ConversationID)
|
||
return err == nil && len(cards) == 1 && cards[0].Title == materialCardTitle && cards[0].Status == core.CardActive
|
||
})
|
||
if got := cardEvents(events); len(got) == 0 {
|
||
t.Fatal("card events missing on spawn")
|
||
}
|
||
|
||
// 同批材料重复发送:进行中 → 待回投说明,不重建子任务。
|
||
c2 := secondRunCtx(c, materialDocs(), "")
|
||
note2 := c.mgr.materialIntake(c2)
|
||
if !strings.Contains(note2, "此前已为这批材料启动") {
|
||
t.Fatalf("dedup note = %q", note2)
|
||
}
|
||
ids, err := c.mgr.st.SubagentRunIDs(c.run.ConversationID)
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if len(ids) != 1 {
|
||
t.Fatalf("subagent runs = %d, want 1 (no respawn on resend)", len(ids))
|
||
}
|
||
|
||
close(hold.release)
|
||
// 等完成去重门禁本身(CardDone + materialActive 已释放),不要等 notice
|
||
// 副作用——deliverNotices 早于 defer materialKeyRelease,systemTexts 先可见会 race。
|
||
key := materialKey(materialDocs())
|
||
waitUntil(t, func() bool {
|
||
cards, err := c.mgr.cards.List(c.run.Owner, c.run.ConversationID)
|
||
if err != nil || len(cards) != 1 {
|
||
return false
|
||
}
|
||
card := cards[0]
|
||
if card.Title != materialCardTitle || card.Status != core.CardDone || card.Tone == "danger" {
|
||
return false
|
||
}
|
||
c.mgr.materialMu.Lock()
|
||
active := c.mgr.materialActive != nil && c.mgr.materialActive[string(c.run.ConversationID)][key]
|
||
c.mgr.materialMu.Unlock()
|
||
return !active
|
||
})
|
||
|
||
// 完成后同批再发:已完成说明,同样不重跑;卡片回投保留材料键。
|
||
c3 := secondRunCtx(c, materialDocs(), "")
|
||
note3 := c.mgr.materialIntake(c3)
|
||
if !strings.Contains(note3, "已整理完成") {
|
||
t.Fatalf("done-dedup note = %q", note3)
|
||
}
|
||
ids, err = c.mgr.st.SubagentRunIDs(c.run.ConversationID)
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if len(ids) != 1 {
|
||
t.Fatalf("subagent runs after completion = %d, want 1", len(ids))
|
||
}
|
||
cards, err := c.mgr.cards.List(c.run.Owner, c.run.ConversationID)
|
||
if err != nil || len(cards) != 1 {
|
||
t.Fatalf("cards = %v err = %v", cards, err)
|
||
}
|
||
if materialKVOf(cards[0]) == "" {
|
||
t.Fatal("material key KV lost on finish backfill")
|
||
}
|
||
if got := hold.id.Load(); got != materialSubagentID {
|
||
t.Fatalf("material subagent id = %q, want %q", got, materialSubagentID)
|
||
}
|
||
}
|
||
|
||
// 文字+小材料:预启动跳过(模型先答),ensureMaterial 兜底启动且幂等。
|
||
func TestMaterialIntakeDeferredThenEnsure(t *testing.T) {
|
||
hold := &holdRunner{release: make(chan struct{})}
|
||
c, _ := newCardSpawnEnv(t, hold)
|
||
small := []core.ParsedDocument{
|
||
{Meta: core.DocMeta{Filename: "说明.txt", Parser: "txt"}, Blocks: []core.DocBlock{{Type: core.BlockPara, Text: "短说明"}}},
|
||
}
|
||
c.attachments = small
|
||
c.userMessage = core.Message{Parts: []core.Part{{Type: core.PartText, Text: "这份文件第三行说的对吗?"}}}
|
||
|
||
if note := c.mgr.materialIntake(c); note != "" {
|
||
t.Fatalf("small text+attachment should defer, note = %q", note)
|
||
}
|
||
if got := c.ensureMaterial(); !strings.Contains(got, "系统已自动启动材料整理后台任务") {
|
||
t.Fatalf("ensure note = %q", got)
|
||
}
|
||
if again := c.ensureMaterial(); again != "" {
|
||
t.Fatalf("ensureMaterial not idempotent: %q", again)
|
||
}
|
||
close(hold.release)
|
||
waitBackgroundIdle(t, c)
|
||
}
|
||
|
||
// runtime.materialIntake=false:入口与兜底都关闭(逃生开关)。
|
||
func TestMaterialIntakeDisabled(t *testing.T) {
|
||
c, _ := newCardSpawnEnv(t, &captureRunner{})
|
||
c.eff = core.EffectiveSettings{"runtime": map[string]any{"materialIntake": false}}
|
||
c.attachments = materialDocs()
|
||
if note := c.mgr.materialIntake(c); note != "" {
|
||
t.Fatalf("disabled intake note = %q", note)
|
||
}
|
||
if got := c.ensureMaterial(); got != "" {
|
||
t.Fatalf("disabled ensure note = %q", got)
|
||
}
|
||
}
|
||
|
||
// 任务正文必须内联材料清单与解析文本;超预算截断并给出检索指引。
|
||
func TestBuildMaterialTaskInlineBudget(t *testing.T) {
|
||
long := strings.Repeat("字", 600)
|
||
eff := core.EffectiveSettings{"context": map[string]any{"maxAttachmentChars": float64(100)}}
|
||
task := buildMaterialTask(eff, []core.ParsedDocument{
|
||
{Meta: core.DocMeta{Filename: "长文.docx", Parser: "docx"}, Blocks: []core.DocBlock{{Type: core.BlockPara, Text: long}}},
|
||
})
|
||
if !strings.Contains(task, "### 长文.docx") {
|
||
t.Fatal("manifest entry missing")
|
||
}
|
||
if !strings.Contains(task, "超长截断") || !strings.Contains(task, "knowledge_search") {
|
||
t.Fatal("truncation marker / retrieval hint missing")
|
||
}
|
||
full := buildMaterialTask(core.EffectiveSettings{}, materialDocs())
|
||
for _, name := range []string{"会议纪要.docx", "评审意见.pdf", "需求清单.md"} {
|
||
if !strings.Contains(full, name) {
|
||
t.Fatalf("manifest missing %s", name)
|
||
}
|
||
}
|
||
if !strings.Contains(full, "决定上线新模块") {
|
||
t.Fatal("parsed text not inlined")
|
||
}
|
||
}
|
||
|
||
func TestMaterialKeyStableAcrossOrder(t *testing.T) {
|
||
docs := materialDocs()
|
||
a := materialKey(docs)
|
||
reversed := []core.ParsedDocument{docs[2], docs[1], docs[0]}
|
||
if b := materialKey(reversed); a != b {
|
||
t.Fatal("key should be order-insensitive")
|
||
}
|
||
changed := materialDocs()
|
||
changed[0].Blocks[0].Text = "内容变了"
|
||
if b := materialKey(changed); a == b {
|
||
t.Fatal("key should change with content")
|
||
}
|
||
}
|
||
|
||
// 通知契约:材料整理完成的通知正文含交付物选择指令(confirm_choice);
|
||
// 普通后台任务的通知不含。
|
||
func TestMaterialNoticeContract(t *testing.T) {
|
||
c, _ := newCardSpawnEnv(t, &captureRunner{parts: []core.Part{{Type: core.PartText, Text: "整理完成:要点已汇总。"}}})
|
||
c.mgr.runner = noopRunner{}
|
||
c.attachments = materialDocs()
|
||
if note := c.mgr.materialIntake(c); !strings.Contains(note, "系统已自动启动材料整理后台任务") {
|
||
t.Fatalf("intake note = %q", note)
|
||
}
|
||
waitBackgroundIdle(t, c)
|
||
if len(systemTexts(t, c)) != 0 {
|
||
t.Fatal("material completion inserted blocking notice")
|
||
}
|
||
cards, _ := c.mgr.cards.List(c.run.Owner, c.run.ConversationID)
|
||
if len(cards) != 1 || cards[0].Work == nil || cards[0].Work.Decision == nil || cards[0].Work.Decision.State != "open" {
|
||
t.Fatalf("optional choice missing from original card: %+v", cards)
|
||
}
|
||
|
||
plain, _ := newCardSpawnEnv(t, &captureRunner{parts: []core.Part{{Type: core.PartText, Text: "完成"}}})
|
||
plain.mgr.runner = noopRunner{}
|
||
if _, err := plain.SpawnBackground("agent.sub.general", "普通任务", "做点事", ""); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
waitBackgroundIdle(t, plain)
|
||
if got := strings.Join(systemTexts(t, plain), "\n"); strings.Contains(got, "confirm_choice") {
|
||
t.Fatal("plain background notice must not carry the choice instruction")
|
||
}
|
||
}
|
||
|
||
// 容量排队:满 3 时材料任务建排队卡不占位;槽位释放按 FIFO 启动并回填状态。
|
||
func TestMaterialQueuedWhenFull(t *testing.T) {
|
||
c, _ := newCardSpawnEnv(t, nil)
|
||
c.mgr.runner = noopRunner{}
|
||
c.mgr.subRunner = &holdRunner{release: make(chan struct{})} // 占位用
|
||
fills := make([]*holdRunner, 0, maxBackgroundChildren)
|
||
for i := 0; i < maxBackgroundChildren; i++ {
|
||
h := &holdRunner{release: make(chan struct{})}
|
||
fills = append(fills, h)
|
||
c.mgr.subRunner = h
|
||
if _, err := c.SpawnBackground("agent.sub.general", fmt.Sprintf("占位%d", i), "占位", ""); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
}
|
||
c.attachments = materialDocs()
|
||
if note := c.mgr.materialIntake(c); !strings.Contains(note, "系统已自动启动材料整理后台任务") {
|
||
t.Fatalf("queued intake note = %q", note)
|
||
}
|
||
// 排队卡:active + 状态=排队中,且没有第 4 个子 run。
|
||
waitUntil(t, func() bool {
|
||
cards, err := c.mgr.cards.List(c.run.Owner, c.run.ConversationID)
|
||
if err != nil {
|
||
return false
|
||
}
|
||
for _, card := range cards {
|
||
if card.Title == materialCardTitle && strings.Contains(cardBodyText(card), "排队中") {
|
||
return true
|
||
}
|
||
}
|
||
return false
|
||
})
|
||
if ids, err := c.mgr.st.SubagentRunIDs(c.run.ConversationID); err == nil && len(ids) != maxBackgroundChildren {
|
||
t.Fatalf("subagent runs = %d, want %d (queued task must not start yet)", len(ids), maxBackgroundChildren)
|
||
}
|
||
// 模型路径仍硬拒绝。
|
||
if _, err := c.SpawnBackground("agent.sub.general", "第四个", "任务", ""); err == nil {
|
||
t.Fatal("model-path spawn should hard-reject at capacity")
|
||
}
|
||
// 释放一个槽位 → 排队任务启动(第 4 个子 run),卡片转执行态。
|
||
close(fills[0].release)
|
||
waitUntil(t, func() bool {
|
||
ids, err := c.mgr.st.SubagentRunIDs(c.run.ConversationID)
|
||
return err == nil && len(ids) == maxBackgroundChildren+1
|
||
})
|
||
waitUntil(t, func() bool {
|
||
cards, err := c.mgr.cards.List(c.run.Owner, c.run.ConversationID)
|
||
if err != nil {
|
||
return false
|
||
}
|
||
for _, card := range cards {
|
||
if card.Title == materialCardTitle && card.SourceRunID != "" && strings.Contains(cardBodyText(card), core.TaskSummaryStatusRunning) {
|
||
return true
|
||
}
|
||
}
|
||
return false
|
||
})
|
||
for _, h := range fills[1:] {
|
||
close(h.release)
|
||
}
|
||
waitBackgroundIdle(t, c)
|
||
}
|
||
|
||
func cardBodyText(card core.ActivityCard) string {
|
||
var b strings.Builder
|
||
for _, node := range card.Body {
|
||
if node.Type == core.UIMarkdown {
|
||
b.WriteString(node.Markdown)
|
||
}
|
||
for _, kv := range node.KV {
|
||
b.WriteString(kv[0] + kv[1])
|
||
}
|
||
}
|
||
return b.String()
|
||
}
|