Why: the dual-speed entry hint is advisory only; real runs (MiniMax, 3 attachments, no text) end with a clarifying question and zero delegation, so background cards and result backfill never start. Evidence: new unit tests in runmgr/material_test.go and runtime/material_flow_test.go all pass; full go test ./... exit 0 (local, SELF). Impact: attachment-carrying runs now guarantee a material-organization background task (pre-spawn on attachment-only/heavy sends, stop-hook backstop otherwise when the model did not delegate); subagent receives inline parsed text with budget plus knowledge_search guidance; content-key dedup prevents duplicate spawns (in-flight, completed, resend); model-authored delegate_task gains a materials appendix; settings runtime.materialIntake (default on) and runtime.materialIntakeChars added. Refs: Dev task .task/feature-material-intake-flow.md I1; user ruling 2026-10-11.
122 lines
4.3 KiB
Go
122 lines
4.3 KiB
Go
package runtime
|
|
|
|
import (
|
|
"context"
|
|
"strings"
|
|
"testing"
|
|
|
|
agentllm "agent-native/runtime/llm"
|
|
core "agent-system-core"
|
|
)
|
|
|
|
// matCtx 在 fakeRunCtx 之上实现材料整理的两个可选接口(附件即保证),
|
|
// 记录 EnsureMaterial 调用次数以断言 stop 兜底的幂等。
|
|
type matCtx struct {
|
|
*fakeRunCtx
|
|
note string
|
|
ensureN int
|
|
lastNote string
|
|
}
|
|
|
|
func (m *matCtx) MaterialNote() string { return m.note }
|
|
|
|
func (m *matCtx) EnsureMaterial() string {
|
|
m.ensureN++
|
|
if m.ensureN > 1 {
|
|
return ""
|
|
}
|
|
return m.lastNote
|
|
}
|
|
|
|
func matAttachments() []core.ParsedDocument {
|
|
return []core.ParsedDocument{
|
|
{Meta: core.DocMeta{Filename: "纪要.docx"}, Blocks: []core.DocBlock{{Type: core.BlockPara, Text: "决定 11 月上线"}}},
|
|
{Meta: core.DocMeta{Filename: "意见.pdf"}, Blocks: []core.DocBlock{{Type: core.BlockPara, Text: "预算存疑"}}},
|
|
{Meta: core.DocMeta{Filename: "清单.md"}, Blocks: []core.DocBlock{{Type: core.BlockPara, Text: "12 项需求"}}},
|
|
}
|
|
}
|
|
|
|
func runMaterial(t *testing.T, turns [][]agentllm.Event, rc *matCtx) *dualSpeedRecorder {
|
|
t.Helper()
|
|
rec := &dualSpeedRecorder{clock: &stepClock{}, turns: turns}
|
|
r := &LLMRunner{Stream: rec.stream(scriptStream(turns, nil)), Now: rec.clock.now}
|
|
if err := r.Execute(rc); err != nil {
|
|
t.Fatalf("Execute: %v", err)
|
|
}
|
|
return rec
|
|
}
|
|
|
|
func requestsContaining(rec *dualSpeedRecorder, needle string) int {
|
|
n := 0
|
|
for _, req := range rec.request {
|
|
if strings.Contains(req, needle) {
|
|
n++
|
|
}
|
|
}
|
|
return n
|
|
}
|
|
|
|
// 预启动说明替换建议式提示:第 0 回合注入事实说明而非「可能属于重任务」。
|
|
func TestTurn0MaterialNoteReplacesAdvisory(t *testing.T) {
|
|
rc := &matCtx{fakeRunCtx: &fakeRunCtx{ctx: context.Background(), prof: verifyOn()}, note: "系统已自动启动材料整理后台任务(收件箱进度卡「材料整理」已创建)。"}
|
|
rc.attachments = matAttachments()
|
|
rec := runMaterial(t, [][]agentllm.Event{textDeltaTurn("收到,先给每份材料一句话概览。")}, rc)
|
|
if requestsContaining(rec, "系统已自动启动材料整理后台任务") == 0 {
|
|
t.Fatal("material note not injected at turn 0")
|
|
}
|
|
if requestsContaining(rec, "可能属于重任务") != 0 {
|
|
t.Fatal("advisory text should be replaced when material note present")
|
|
}
|
|
}
|
|
|
|
// 未预启动(文字+小材料):保留建议式入口提示。
|
|
func TestTurn0AdvisoryWhenNoNote(t *testing.T) {
|
|
rc := &matCtx{fakeRunCtx: &fakeRunCtx{ctx: context.Background(), prof: verifyOn()}}
|
|
rc.attachments = matAttachments()
|
|
rec := runMaterial(t, [][]agentllm.Event{textDeltaTurn("概览如下。")}, rc)
|
|
if requestsContaining(rec, "可能属于重任务") == 0 {
|
|
t.Fatal("advisory hint missing when no material note")
|
|
}
|
|
}
|
|
|
|
// stop 兜底:模型问一句澄清就收场(用户实测现象)→ 系统续跑一轮要求告知;
|
|
// ensureMaterial 幂等保证只续跑一次。
|
|
func TestStopMaterialBackstopContinuesOnce(t *testing.T) {
|
|
rc := &matCtx{
|
|
fakeRunCtx: &fakeRunCtx{ctx: context.Background(), prof: verifyOn()},
|
|
lastNote: "系统已自动启动材料整理后台任务",
|
|
}
|
|
rc.attachments = matAttachments()
|
|
turns := [][]agentllm.Event{
|
|
textDeltaTurn("请问你需要哪种产出?"),
|
|
textDeltaTurn("材料正在后台整理,完成后自动汇报;先给概览。"),
|
|
}
|
|
rec := runMaterial(t, turns, rc)
|
|
if requestsContaining(rec, "系统已自动启动材料整理后台任务") == 0 {
|
|
t.Fatal("backstop reason not injected as continuation")
|
|
}
|
|
if rc.ensureN != 1 {
|
|
t.Fatalf("EnsureMaterial calls = %d, want exactly 1", rc.ensureN)
|
|
}
|
|
}
|
|
|
|
// 模型已自行委派后台(delegatedBackground)→ 尊重模型判断,不触发兜底。
|
|
func TestStopMaterialBackstopSkippedWhenDelegated(t *testing.T) {
|
|
rc := &matCtx{
|
|
fakeRunCtx: &fakeRunCtx{ctx: context.Background(), prof: verifyOn(), bgID: "run_bg_1"},
|
|
}
|
|
rc.attachments = matAttachments()
|
|
delegate := toolCall("delegate_task", `{"name":"深度解析","task":"解析材料","background":true}`)
|
|
turns := [][]agentllm.Event{
|
|
textDeltaTurn("", delegate),
|
|
textDeltaTurn("已在后台解析,先给即时反馈。"),
|
|
}
|
|
rec := runMaterial(t, turns, rc)
|
|
if rc.ensureN != 0 {
|
|
t.Fatalf("EnsureMaterial calls = %d, want 0 (model delegated)", rc.ensureN)
|
|
}
|
|
if requestsContaining(rec, "自动启动材料整理后台任务") != 0 {
|
|
t.Fatal("backstop should not fire when model delegated")
|
|
}
|
|
}
|