package runmgr import ( "fmt" "strings" "testing" "git-ssh.u1680832.nyat.app/weichen/Agent-System-Backend.git/internal/runtime" core "git-ssh.u1680832.nyat.app/weichen/Agent-System-Core.git" ) // holdRunner 阻塞到 release 才完成——材料整理去重需要子任务仍在运行的窗口。 type holdRunner struct { release chan struct{} id string } 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 = 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 hold.id != materialSubagentID { t.Fatalf("material subagent id = %q, want %q", hold.id, 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) waitUntil(t, func() bool { return len(systemTexts(t, c)) >= 1 }) } // 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) } waitUntil(t, func() bool { return len(systemTexts(t, c)) >= 1 }) joined := strings.Join(systemTexts(t, c), "\n") if !strings.Contains(joined, "「材料整理」已结束") { t.Fatalf("notice text = %q", joined) } if !strings.Contains(joined, "confirm_choice") { t.Fatal("deliverable-choice instruction missing from material notice") } 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) } waitUntil(t, func() bool { return len(systemTexts(t, plain)) >= 1 }) 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) } } 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() }