Files
Agent-System-Backend/internal/runmgr/material_test.go
T
weichen 7d6490feee
ci / vet-and-test (pull_request) Successful in 1m2s
Stop untracked background execution and retain results on record failures
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
2026-10-15 07:37:10 +00:00

303 lines
11 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 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()
}