package runtime import ( "context" agentmemory "git-ssh.u1680832.nyat.app/weichen/Agent-Native.git/runtime/memory" "errors" "fmt" "strings" "testing" "time" agentllm "git-ssh.u1680832.nyat.app/weichen/Agent-Native.git/runtime/llm" core "git-ssh.u1680832.nyat.app/weichen/Agent-System-Core.git" ) type emittedEvent struct { typ core.StreamEventType payload any } // fakeRunCtx:RunCtx 最小实现——只收集事件与终态部件,其余 no-op。 type fakeRunCtx struct { ctx context.Context prof core.AgentProfile cardflow bool events []emittedEvent assistant []core.Part cards []core.ActivityCard streamCardID core.ID // 活跃流式卡(Begin/Append/Commit fake 语义) projectID string // Run().ProjectID(scope=project 断言用) toolDecls []agentllm.ToolDecl execArtifact *core.Artifact // ExecuteTool 返回的工件(非 nil 时走工件路径) execTruncated bool // ExecuteTool 返回的截断标记(门面卡触发) toolRuns int // ExecuteTool 被调用的次数 execArtifactContent []byte // Artifact(id) 回填内容(ctxasm D46 断言用) verify bool // runtime.verify;缺省关闭,避免改变既有流调用次数 compose bool // runtime.composeFinal;缺省关闭,避免多一次主模型调用 missingArtifact bool // 工具登记了工件,但 Artifact 查不到 emptyArtifact bool // Artifact 查到了,内容为空 history []core.Message // 非 nil 时覆盖默认历史 bgID string bgErr error sandbox string // 非空时 ConversationSandbox 返回之(N3 WP2 转录测试) memEntries []memEntry // 非空时 StoreMemory 返回之(N3 WP5 记忆注入断言用) usageTotal core.Usage // AddUsage 累计(N3 WP6 记账断言用) bgCalls int runtimeExtra map[string]any // 覆盖 runtime 组设置(双速提醒阈值等测试用) attachments []core.ParsedDocument // Attachments 返回值(双速入口前置提示测试用) spawnAgentFn func(agentID, name, task, hint string) (core.AgentResultPayload, error) execStatus string // 非空时覆盖 ExecuteTool 状态(R4 失败链测试) execOutput string // 非空时覆盖 ExecuteTool 输出(空命中/账本测试) } func (f *fakeRunCtx) Context() context.Context { return f.ctx } func (f *fakeRunCtx) Run() core.Run { return core.Run{ID: "run_t", ProjectID: core.ID(f.projectID)} } func (f *fakeRunCtx) Profile() core.AgentProfile { return f.prof } func (f *fakeRunCtx) Settings() core.EffectiveSettings { eff := core.EffectiveSettings{"models": map[string]any{"chat": map[string]any{"baseUrl": "http://test"}}} if f.cardflow { eff["ui"] = map[string]any{"cardflow": true} } runtimeMap := map[string]any{"verify": f.verify, "composeFinal": f.compose} for k, v := range f.runtimeExtra { runtimeMap[k] = v } eff["runtime"] = runtimeMap return eff } func (f *fakeRunCtx) Emit(typ core.StreamEventType, payload any) error { f.events = append(f.events, emittedEvent{typ, payload}) return nil } func (f *fakeRunCtx) SetAssistantParts(parts []core.Part) error { f.assistant = parts; return nil } func (f *fakeRunCtx) AddUsage(u core.Usage) { f.usageTotal.PromptTokens += u.PromptTokens f.usageTotal.CompletionTokens += u.CompletionTokens f.usageTotal.TotalTokens += u.TotalTokens f.usageTotal.LastPromptTokens = u.LastPromptTokens f.usageTotal.LastCompletionTokens = u.LastCompletionTokens f.usageTotal.CacheReadTokens += u.CacheReadTokens f.usageTotal.CacheWriteTokens += u.CacheWriteTokens } func (f *fakeRunCtx) AskHITL(card core.HITLCard) (core.HITLResponse, error) { return core.HITLResponse{}, fmt.Errorf("not implemented") } func (f *fakeRunCtx) SpawnSubagent(name, task, hint string) (core.AgentResultPayload, error) { return core.AgentResultPayload{}, fmt.Errorf("not implemented") } // SpawnAgent 供 delegate_task 直连分支(E1/F1 测试注入);未设置时与 // SpawnSubagent 一致报错。 func (f *fakeRunCtx) SpawnAgent(agentID, name, task, hint string) (core.AgentResultPayload, error) { if f.spawnAgentFn != nil { return f.spawnAgentFn(agentID, name, task, hint) } return core.AgentResultPayload{}, fmt.Errorf("not implemented") } func (f *fakeRunCtx) UpdatePlan(p core.PlanSnapshot) error { return nil } func (f *fakeRunCtx) UpdateTodos(l core.TodoList) error { return nil } func (f *fakeRunCtx) WriteArtifact(name, kind, mime string, content []byte) (core.Artifact, error) { return core.Artifact{}, fmt.Errorf("not implemented") } func (f *fakeRunCtx) Retrieve(q core.RetrievalQuery) ([]core.RetrievalHit, error) { return nil, nil } func (f *fakeRunCtx) ExecuteTool(call core.ToolCallRequest) core.ToolCallResult { f.toolRuns++ res := core.ToolCallResult{Status: "ok", Output: "done", Truncated: f.execTruncated} if f.execStatus != "" { res.Status = f.execStatus } if f.execOutput != "" { res.Output = f.execOutput } if f.execArtifact != nil { res.Artifact = f.execArtifact } return res } func (f *fakeRunCtx) History() []core.Message { if f.history != nil { return f.history } return []core.Message{{ID: "m1", Role: core.RoleUser, Parts: []core.Part{{Type: core.PartText, Text: "hi"}}}} } func (f *fakeRunCtx) SpawnBackground(agentID, name, task, hint string) (core.ID, error) { f.bgCalls++ if f.bgErr != nil { return "", f.bgErr } if f.bgID == "" { f.bgID = "run_bg" } return f.bgID, nil } func (f *fakeRunCtx) ConversationSandbox() string { if f.sandbox != "" { return f.sandbox } return "" } // memEntry 是测试用记忆条目(避免 fake 引入库类型)。 type memEntry struct{ typ, content string } type fakeMemStore struct{ entries []memEntry } func (s fakeMemStore) Recall(_ context.Context, q agentmemory.RecallQuery) ([]agentmemory.Entry, error) { out := make([]agentmemory.Entry, 0, len(s.entries)) for _, e := range s.entries { out = append(out, agentmemory.Entry{Type: e.typ, Content: e.content}) } return out, nil } func (s fakeMemStore) Append(_ context.Context, e agentmemory.Entry) error { return nil } func (f *fakeRunCtx) StoreMemory() agentmemory.Store { if f.memEntries == nil { return nil } return fakeMemStore{entries: f.memEntries} } func (f *fakeRunCtx) Attachments() []core.ParsedDocument { return f.attachments } func (f *fakeRunCtx) Tools() []core.ToolDesc { return nil } func (f *fakeRunCtx) Subtask() string { return "" } func (f *fakeRunCtx) CreateCard(card core.ActivityCard) (core.ActivityCard, error) { card.ID = core.ID(fmt.Sprintf("card_%d", len(f.cards)+1)) f.cards = append(f.cards, card) return card, nil } func (f *fakeRunCtx) Artifact(id string) (core.Artifact, []byte, bool) { if f.execArtifact != nil && f.execArtifact.ID == id { if f.missingArtifact { return core.Artifact{}, nil, false } if f.emptyArtifact { return *f.execArtifact, nil, true } return *f.execArtifact, f.execArtifactContent, true } return core.Artifact{}, nil, false } func (f *fakeRunCtx) SubmitCard(card core.ActivityCard) (core.ActivityCard, error) { return f.CreateCard(card) } // 流式三段式(D44/D45):fake 侧以 streamCardID 指针 + 尾部 markdown 合并模拟 // runmgr/cardsvc 语义,供工具编排层断言。 func (f *fakeRunCtx) BeginStreamCard(card core.ActivityCard) (core.ActivityCard, error) { card.Status = core.CardActive created, err := f.CreateCard(card) if err != nil { return created, err } f.streamCardID = created.ID return created, nil } func (f *fakeRunCtx) AppendStreamCard(text string) error { if f.streamCardID == "" { return errors.New("card: no active stream card (card_begin first)") } for i := range f.cards { if f.cards[i].ID != f.streamCardID { continue } if n := len(f.cards[i].Body); n > 0 && f.cards[i].Body[n-1].Type == core.UIMarkdown { f.cards[i].Body[n-1].Markdown += text } else { f.cards[i].Body = append(f.cards[i].Body, core.UINode{Type: core.UIMarkdown, Markdown: text}) } return nil } return errors.New("card: not found") } func (f *fakeRunCtx) CommitStreamCard() (core.ActivityCard, error) { if f.streamCardID == "" { return core.ActivityCard{}, errors.New("card: no active stream card (card_begin first)") } id := f.streamCardID f.streamCardID = "" for i := range f.cards { if f.cards[i].ID == id { f.cards[i].Status = core.CardDone return f.cards[i], nil } } return core.ActivityCard{}, errors.New("card: not found") } func (f *fakeRunCtx) Card(id string) *core.ActivityCard { for i := range f.cards { if string(f.cards[i].ID) == id { return &f.cards[i] } } return nil } func (f *fakeRunCtx) SetCardStatus(id string, status core.CardStatus, tone, note string) error { return nil } func (f *fakeRunCtx) SetContextObservation(o core.ContextObservation) {} // scriptStream:每次调用回放一轮(真实客户端语义:一个 message_end 后关通道)。 // 轮数耗尽后再调用返回空流(防御;正常终轮后 runner 不再调用)。 // declSink 非空时捕获一次工具声明清单(断言 submit_card 门控用)。 func scriptStream(turns [][]agentllm.Event, declSink *[]agentllm.ToolDecl) agentllm.StreamFunc { n := 0 return func(ctx context.Context, model string, msgs []agentllm.Message, tools []agentllm.ToolDecl, opts agentllm.StreamOptions) <-chan agentllm.Event { if declSink != nil && *declSink == nil { *declSink = tools } out := make(chan agentllm.Event) var evs []agentllm.Event if n < len(turns) { evs = turns[n] n++ } go func() { defer close(out) for _, e := range evs { select { case out <- e: case <-ctx.Done(): return } } }() return out } } // TestLLMRunnerToolTurnTextIsProcess 验证:工具轮的文本是过程解说(Process=true), // 终轮文本(无工具调用)才是对用户的回复(无标记)——中栏过程/回复分离的数据基础。 func TestLLMRunnerToolTurnTextIsProcess(t *testing.T) { turns := [][]agentllm.Event{ { // 第 1 轮:思考 + 解说文本 + 工具调用。 agentllm.Event{Kind: agentllm.KindReasoningDelta, ReasoningDelta: "先想想"}, agentllm.Event{Kind: agentllm.KindTextDelta, Text: "我先更新待办。"}, agentllm.Event{Kind: agentllm.KindMessageEnd, Message: agentllm.Message{ToolCalls: []agentllm.ToolCall{ {ID: "c1", Name: "update_todos", Arguments: "{}"}, }}}, }, { // 第 2 轮(终轮):最终回复文本。 agentllm.Event{Kind: agentllm.KindTextDelta, Text: "完成了。"}, agentllm.Event{Kind: agentllm.KindMessageEnd}, }, } r := &LLMRunner{Stream: scriptStream(turns, nil)} rc := &fakeRunCtx{ctx: context.Background(), prof: core.AgentProfile{Model: core.ModelRoute{Chat: "test-model"}}} if err := r.Execute(rc); err != nil { t.Fatalf("Execute: %v", err) } type want struct { typ core.PartType text string process bool } got := make([]want, 0, len(rc.assistant)) for _, p := range rc.assistant { got = append(got, want{p.Type, p.Text, p.Process}) } expected := []want{ {core.PartThinking, "先想想", true}, {core.PartText, "我先更新待办。", true}, // 工具轮文本 = 过程 {core.PartToolCall, "", true}, {core.PartToolResult, "", true}, {core.PartText, "完成了。", false}, // 终轮文本 = 回复 } if len(got) != len(expected) { t.Fatalf("parts 数量不符:got %d (%+v) want %d", len(got), got, len(expected)) } for i, g := range got { if g != expected[i] { t.Errorf("parts[%d] = %+v, want %+v", i, g, expected[i]) } } } // TestLLMRunnerPhaseEventsPerTurn 验证:每轮首个文本增量发 responding、工具轮开始发 // acting——前端「响应中折叠过程」的驱动信号(交错轮不粘滞)。 func TestLLMRunnerPhaseEventsPerTurn(t *testing.T) { turns := [][]agentllm.Event{ { agentllm.Event{Kind: agentllm.KindTextDelta, Text: "解说"}, agentllm.Event{Kind: agentllm.KindMessageEnd, Message: agentllm.Message{ToolCalls: []agentllm.ToolCall{ {ID: "c1", Name: "update_todos"}, }}}, }, { agentllm.Event{Kind: agentllm.KindTextDelta, Text: "最终回复"}, agentllm.Event{Kind: agentllm.KindMessageEnd}, }, } r := &LLMRunner{Stream: scriptStream(turns, nil)} rc := &fakeRunCtx{ctx: context.Background(), prof: core.AgentProfile{Model: core.ModelRoute{Chat: "test-model"}}} if err := r.Execute(rc); err != nil { t.Fatalf("Execute: %v", err) } var phases []string for _, e := range rc.events { if e.typ == core.EvRunPhase { p, ok := e.payload.(core.RunPhasePayload) if !ok { t.Fatalf("run.phase payload 类型错误: %T", e.payload) } phases = append(phases, string(p.Phase)) } } // 第 1 轮 responding(文本增量)→ acting(工具轮);第 2 轮再次 responding。 wantPhases := []string{"responding", "acting", "responding"} if len(phases) != len(wantPhases) { t.Fatalf("phase 序列 = %v, want %v", phases, wantPhases) } for i := range phases { if phases[i] != wantPhases[i] { t.Fatalf("phase 序列 = %v, want %v", phases, wantPhases) } } } // TestLLMRunnerDeliverablePinnedAfterText 验证:工具产生的工件引用作为内容部件 // (不进过程折叠区),且钉在最终正文之后——对话内直接给出交付物卡片。 func TestLLMRunnerDeliverablePinnedAfterText(t *testing.T) { turns := [][]agentllm.Event{ { // 第 1 轮:解说文本 + 工具调用(产生工件)。 agentllm.Event{Kind: agentllm.KindTextDelta, Text: "我生成一份报表。"}, agentllm.Event{Kind: agentllm.KindMessageEnd, Message: agentllm.Message{ToolCalls: []agentllm.ToolCall{ {ID: "c1", Name: "make_report", Arguments: "{}"}, }}}, }, { // 第 2 轮(终轮):最终回复文本。 agentllm.Event{Kind: agentllm.KindTextDelta, Text: "报表已生成。"}, agentllm.Event{Kind: agentllm.KindMessageEnd}, }, } r := &LLMRunner{Stream: scriptStream(turns, nil)} rc := &fakeRunCtx{ ctx: context.Background(), prof: core.AgentProfile{Model: core.ModelRoute{Chat: "test-model"}}, execArtifact: &core.Artifact{ID: "a1", Name: "report.csv", Kind: "csv"}, } if err := r.Execute(rc); err != nil { t.Fatalf("Execute: %v", err) } refIdx, lastTextIdx := -1, -1 for i, p := range rc.assistant { if p.Type == core.PartText { lastTextIdx = i } if p.Type == core.PartArtifactRef { if refIdx != -1 { t.Errorf("artifact_ref 部件重复: %+v", rc.assistant) } refIdx = i if p.Process { t.Errorf("artifact_ref 不得标记 Process(须为内容部件): %+v", p) } if p.Artifact == nil || p.Artifact.ArtifactID != "a1" { t.Errorf("artifact 引用不符: %+v", p.Artifact) } } } if refIdx == -1 { t.Fatalf("未找到 artifact_ref 部件: %+v", rc.assistant) } if lastTextIdx == -1 || refIdx < lastTextIdx { t.Errorf("artifact_ref 应钉在最终正文之后: refIdx=%d lastTextIdx=%d parts=%+v", refIdx, lastTextIdx, rc.assistant) } } // TestLLMRunnerSubmitCardGatedBySetting 验证 D39/D42:ui.cardflow 开启时 submit_card // 才声明并产 result 卡 + 引用部件;关闭时工具不声明、不产卡(行为等价现状)。 func TestLLMRunnerSubmitCardGatedBySetting(t *testing.T) { cardArgs := `{"title":"会议纪要","summary":"三项决议","body":"# 会议纪要\n全文……"}` turnsWithCard := [][]agentllm.Event{ { agentllm.Event{Kind: agentllm.KindTextDelta, Text: "已完成,详见卡片。"}, agentllm.Event{Kind: agentllm.KindMessageEnd, Message: agentllm.Message{ToolCalls: []agentllm.ToolCall{ {ID: "c1", Name: "submit_card", Arguments: cardArgs}, }}}, }, { agentllm.Event{Kind: agentllm.KindTextDelta, Text: "要点如上。"}, agentllm.Event{Kind: agentllm.KindMessageEnd}, }, } // 开启:声明工具、产卡、部件带 cardId。 var declsOn []agentllm.ToolDecl r := &LLMRunner{Stream: scriptStream(turnsWithCard, &declsOn)} rcOn := &fakeRunCtx{ctx: context.Background(), prof: core.AgentProfile{Model: core.ModelRoute{Chat: "test-model"}}, cardflow: true} if err := r.Execute(rcOn); err != nil { t.Fatalf("Execute(cardflow on): %v", err) } if !hasToolDecl(declsOn, "submit_card") { t.Error("submit_card 应在开启时声明") } if len(rcOn.cards) != 1 { t.Fatalf("开启时应恰好产 1 张卡,got %d", len(rcOn.cards)) } if rcOn.cards[0].Kind != core.CardKindResult || rcOn.cards[0].Status != core.CardDone { t.Errorf("卡 kind/status = %s/%s, want result/done", rcOn.cards[0].Kind, rcOn.cards[0].Status) } var refPart *core.Part for i := range rcOn.assistant { if rcOn.assistant[i].Type == core.PartCard { refPart = &rcOn.assistant[i] } } if refPart == nil || refPart.Card == nil || string(refPart.Card.CardID) != string(rcOn.cards[0].ID) { t.Errorf("消息应含 cardId=%s 的引用部件", rcOn.cards[0].ID) } // 关闭:不声明、不产卡(调用落入注册表 default 路径,fake 返回 ok 但无卡)。 var declsOff []agentllm.ToolDecl r2 := &LLMRunner{Stream: scriptStream(turnsWithCard, &declsOff)} rcOff := &fakeRunCtx{ctx: context.Background(), prof: core.AgentProfile{Model: core.ModelRoute{Chat: "test-model"}}} if err := r2.Execute(rcOff); err != nil { t.Fatalf("Execute(cardflow off): %v", err) } if hasToolDecl(declsOff, "submit_card") { t.Error("submit_card 不应在关闭时声明") } if len(rcOff.cards) != 0 { t.Errorf("关闭时不应产卡,got %d", len(rcOff.cards)) } for _, p := range rcOff.assistant { if p.Type == core.PartCard { t.Error("关闭时消息不应含卡片部件") } } } func hasToolDecl(decls []agentllm.ToolDecl, name string) bool { for _, d := range decls { if d.Name == name { return true } } return false } // TestLLMRunnerCardStreamTools 验证 D44/D45:cardflow 开启时三段式工具声明, // begin→append×N→commit 编排为一张 done 产物卡(正文逐段合并)+ cardId 引用 // 部件;commit 后活跃指针清空,再 append 被拒绝。 func TestLLMRunnerCardStreamTools(t *testing.T) { turns := [][]agentllm.Event{ { agentllm.Event{Kind: agentllm.KindTextDelta, Text: "开始生成周报。"}, agentllm.Event{Kind: agentllm.KindMessageEnd, Message: agentllm.Message{ToolCalls: []agentllm.ToolCall{ {ID: "c1", Name: "card_begin", Arguments: `{"title":"项目周报","summary":"流式演示"}`}, }}}, }, { agentllm.Event{Kind: agentllm.KindMessageEnd, Message: agentllm.Message{ToolCalls: []agentllm.ToolCall{ {ID: "c2", Name: "card_append", Arguments: `{"text":"## 进展\n- 全绿\n"}`}, }}}, }, { agentllm.Event{Kind: agentllm.KindMessageEnd, Message: agentllm.Message{ToolCalls: []agentllm.ToolCall{ {ID: "c3", Name: "card_append", Arguments: `{"text":"## 风险\n- 无\n"}`}, {ID: "c4", Name: "card_commit", Arguments: "{}"}, }}}, }, { agentllm.Event{Kind: agentllm.KindTextDelta, Text: "周报完成,详见卡片。"}, agentllm.Event{Kind: agentllm.KindMessageEnd}, }, } var declsOn []agentllm.ToolDecl r := &LLMRunner{Stream: scriptStream(turns, &declsOn)} rc := &fakeRunCtx{ctx: context.Background(), prof: core.AgentProfile{Model: core.ModelRoute{Chat: "test-model"}}, cardflow: true} if err := r.Execute(rc); err != nil { t.Fatalf("Execute: %v", err) } for _, n := range []string{"card_begin", "card_append", "card_commit", "submit_card"} { if !hasToolDecl(declsOn, n) { t.Errorf("%s 应在 ui.cardflow 开启时声明", n) } } if len(rc.cards) != 1 { t.Fatalf("应恰好产 1 张流式卡,got %d", len(rc.cards)) } card := rc.cards[0] if card.Status != core.CardDone || card.Kind != core.CardKindResult { t.Errorf("卡 status/kind = %s/%s, want done/result", card.Status, card.Kind) } full := "" for _, n := range card.Body { full += n.Markdown } if !strings.Contains(full, "## 进展") || !strings.Contains(full, "## 风险") { t.Errorf("流式正文未逐段合并齐全: %q", full) } var refPart *core.Part for i := range rc.assistant { if rc.assistant[i].Type == core.PartCard { refPart = &rc.assistant[i] } } if refPart == nil || refPart.Card == nil || string(refPart.Card.CardID) != string(card.ID) { t.Errorf("消息应含 cardId=%s 的引用部件", card.ID) } if err := rc.AppendStreamCard("late"); err == nil { t.Error("commit 后 append 应报无活跃卡") } } // TestLLMRunnerCardStreamGated 验证:ui.cardflow 关闭时三工具不声明;幻觉调用 // begin 被拒绝且不产卡。 func TestLLMRunnerCardStreamGated(t *testing.T) { turns := [][]agentllm.Event{ { agentllm.Event{Kind: agentllm.KindMessageEnd, Message: agentllm.Message{ToolCalls: []agentllm.ToolCall{ {ID: "c1", Name: "card_begin", Arguments: `{"title":"越权卡"}`}, }}}, }, { agentllm.Event{Kind: agentllm.KindTextDelta, Text: "好的。"}, agentllm.Event{Kind: agentllm.KindMessageEnd}, }, } var declsOff []agentllm.ToolDecl r := &LLMRunner{Stream: scriptStream(turns, &declsOff)} rc := &fakeRunCtx{ctx: context.Background(), prof: core.AgentProfile{Model: core.ModelRoute{Chat: "test-model"}}} if err := r.Execute(rc); err != nil { t.Fatalf("Execute: %v", err) } for _, n := range []string{"card_begin", "card_append", "card_commit"} { if hasToolDecl(declsOff, n) { t.Errorf("%s 不应在关闭时声明", n) } } if len(rc.cards) != 0 { t.Errorf("关闭时幻觉 begin 不应产卡,got %d", len(rc.cards)) } } // TestLLMRunnerArtifactFacadeCard 验证 D47:截断的工具大结果自动产门面卡 // (result/done,含来源 kv 与 doc-ref),消息同时携带 card 引用与 artifact_ref; // 未截断的结果不建卡(防收件箱噪音)。 func TestLLMRunnerArtifactFacadeCard(t *testing.T) { turns := [][]agentllm.Event{ { agentllm.Event{Kind: agentllm.KindTextDelta, Text: "检索完成。"}, agentllm.Event{Kind: agentllm.KindMessageEnd, Message: agentllm.Message{ToolCalls: []agentllm.ToolCall{ {ID: "c1", Name: "big_search", Arguments: `{"query":"报告"}`}, }}}, }, { agentllm.Event{Kind: agentllm.KindTextDelta, Text: "结论如上。"}, agentllm.Event{Kind: agentllm.KindMessageEnd}, }, } r := &LLMRunner{Stream: scriptStream(turns, nil)} rc := &fakeRunCtx{ ctx: context.Background(), prof: core.AgentProfile{Model: core.ModelRoute{Chat: "test-model"}}, cardflow: true, execTruncated: true, execArtifact: &core.Artifact{ID: "art_1", Name: "大结果.md", Kind: "document"}, } if err := r.Execute(rc); err != nil { t.Fatalf("Execute: %v", err) } if len(rc.cards) != 1 { t.Fatalf("截断结果应产 1 张门面卡,got %d", len(rc.cards)) } fc := rc.cards[0] if fc.Kind != core.CardKindResult || fc.Status != core.CardDone || fc.Title != "工具大结果 · 大结果.md" { t.Errorf("门面卡 kind/status/title = %s/%s/%q", fc.Kind, fc.Status, fc.Title) } hasKV, hasDocRef := false, false for _, n := range fc.Body { if n.Type == "kv" { hasKV = true } if n.Type == "doc-ref" && n.DocID == "art_1" { hasDocRef = true } } if !hasKV || !hasDocRef { t.Errorf("门面卡 body 缺 kv/doc-ref: %+v", fc.Body) } hasRef, hasArt := false, false for _, p := range rc.assistant { if p.Type == core.PartCard && p.Card != nil && p.Card.CardID == fc.ID { hasRef = true } if p.Type == core.PartArtifactRef && p.Artifact != nil && p.Artifact.ArtifactID == "art_1" { hasArt = true } } if !hasRef || !hasArt { t.Errorf("消息应同时含门面卡引用与 artifact_ref(ref=%v art=%v)", hasRef, hasArt) } // 未截断:不建卡。 rc2 := &fakeRunCtx{ ctx: context.Background(), prof: core.AgentProfile{Model: core.ModelRoute{Chat: "test-model"}}, cardflow: true, execArtifact: &core.Artifact{ID: "art_2", Name: "小结果.md", Kind: "document"}, } r2 := &LLMRunner{Stream: scriptStream(turns, nil)} if err := r2.Execute(rc2); err != nil { t.Fatalf("Execute(not truncated): %v", err) } if len(rc2.cards) != 0 { t.Errorf("未截断结果不应建门面卡,got %d", len(rc2.cards)) } } // TestLLMRunnerArtifactFacadeSuppressed 验证 D47 防双卡:本 run 已显式 // submit_card 时,截断的工具大结果不再自动建门面卡。 func TestLLMRunnerArtifactFacadeSuppressed(t *testing.T) { turns := [][]agentllm.Event{ { agentllm.Event{Kind: agentllm.KindMessageEnd, Message: agentllm.Message{ToolCalls: []agentllm.ToolCall{ {ID: "c1", Name: "submit_card", Arguments: `{"title":"正式产物","summary":"摘要","body":"# 正文"}`}, }}}, }, { agentllm.Event{Kind: agentllm.KindMessageEnd, Message: agentllm.Message{ToolCalls: []agentllm.ToolCall{ {ID: "c2", Name: "big_search", Arguments: `{"query":"x"}`}, }}}, }, { agentllm.Event{Kind: agentllm.KindTextDelta, Text: "完成。"}, agentllm.Event{Kind: agentllm.KindMessageEnd}, }, } r := &LLMRunner{Stream: scriptStream(turns, nil)} rc := &fakeRunCtx{ ctx: context.Background(), prof: core.AgentProfile{Model: core.ModelRoute{Chat: "test-model"}}, cardflow: true, execTruncated: true, execArtifact: &core.Artifact{ID: "art_9", Name: "附属.md", Kind: "document"}, } if err := r.Execute(rc); err != nil { t.Fatalf("Execute: %v", err) } if len(rc.cards) != 1 || rc.cards[0].Title != "正式产物" { t.Errorf("显式卡存在时不应有门面卡,got %+v", rc.cards) } } // TestLLMRunnerCardScopeProject 验证 D48:submit_card scope=project 产出项目级卡 // (ProjectID 预置、ConversationID 留空),归属由 runmgr CreateCard 保持。 func TestLLMRunnerCardScopeProject(t *testing.T) { turns := [][]agentllm.Event{ { agentllm.Event{Kind: agentllm.KindMessageEnd, Message: agentllm.Message{ToolCalls: []agentllm.ToolCall{ {ID: "c1", Name: "submit_card", Arguments: `{"title":"项目产物","summary":"跨会话","body":"# 正文","scope":"project"}`}, }}}, }, { agentllm.Event{Kind: agentllm.KindTextDelta, Text: "已入项目收件箱。"}, agentllm.Event{Kind: agentllm.KindMessageEnd}, }, } r := &LLMRunner{Stream: scriptStream(turns, nil)} rc := &fakeRunCtx{ctx: context.Background(), prof: core.AgentProfile{Model: core.ModelRoute{Chat: "test-model"}}, cardflow: true, projectID: "prj_9"} if err := r.Execute(rc); err != nil { t.Fatalf("Execute: %v", err) } if len(rc.cards) != 1 { t.Fatalf("应产 1 张卡,got %d", len(rc.cards)) } if rc.cards[0].ProjectID != "prj_9" { t.Errorf("scope=project 应预置 ProjectID, got %q", rc.cards[0].ProjectID) } if rc.cards[0].ConversationID != "" { t.Errorf("项目级卡 ConversationID 应留空, got %q", rc.cards[0].ConversationID) } } // TestLLMRunnerRecordsThinkingAndToolDuration 验证:思考部件带上推理增量的时长, // 工具调用带上执行时长,成功的工具结果也留下摘要(刷新后仍能展开)。 func TestLLMRunnerRecordsThinkingAndToolDuration(t *testing.T) { turns := [][]agentllm.Event{ { agentllm.Event{Kind: agentllm.KindReasoningDelta, ReasoningDelta: "先"}, agentllm.Event{Kind: agentllm.KindReasoningDelta, ReasoningDelta: "想想"}, agentllm.Event{Kind: agentllm.KindMessageEnd, Message: agentllm.Message{ToolCalls: []agentllm.ToolCall{ {ID: "c1", Name: "update_todos", Arguments: "{}"}, }}}, }, { agentllm.Event{Kind: agentllm.KindTextDelta, Text: "完成了。"}, agentllm.Event{Kind: agentllm.KindMessageEnd}, }, } var tick int64 r := &LLMRunner{ Stream: scriptStream(turns, nil), Now: func() time.Time { tick++ return time.Unix(0, tick*int64(time.Millisecond)) }, } rc := &fakeRunCtx{ctx: context.Background(), prof: core.AgentProfile{Model: core.ModelRoute{Chat: "test-model"}}} if err := r.Execute(rc); err != nil { t.Fatalf("Execute: %v", err) } var thinking *core.Part var call *core.Part var result *core.Part for i := range rc.assistant { p := &rc.assistant[i] switch p.Type { case core.PartThinking: thinking = p case core.PartToolCall: call = p case core.PartToolResult: result = p } } if thinking == nil || thinking.DurationMs != 1 { t.Fatalf("thinking durationMs = %v, want 1 (two reasoning deltas, 1ms apart)", thinking) } if call == nil || call.ToolCall == nil || call.ToolCall.Duration != 1 { t.Fatalf("tool durationMs = %+v, want 1", call) } if result == nil || result.ToolResult == nil || result.ToolResult.Summary != "待办已更新" { t.Fatalf("success summary = %+v, want 待办已更新", result) } } func textParts(parts []core.Part) []core.Part { var out []core.Part for _, p := range parts { if p.Type == core.PartText { out = append(out, p) } } return out } // TestLLMRunnerFalseDeliverableGuard 验证:终轮声称已生成文件但没有工件时, // 这段正文降为过程,纠正一次;下一轮如实说明则作为最终回复。 func TestLLMRunnerFalseDeliverableGuard(t *testing.T) { turns := [][]agentllm.Event{ { agentllm.Event{Kind: agentllm.KindTextDelta, Text: "文档已生成:\n\n研发智能体方案评审_冲突点分析.docx\n工件 ID:art_4f9d2c1a8b3e"}, agentllm.Event{Kind: agentllm.KindMessageEnd}, }, { agentllm.Event{Kind: agentllm.KindTextDelta, Text: "这一轮没有生成文件。"}, agentllm.Event{Kind: agentllm.KindMessageEnd}, }, } var calls int var lastUser string base := scriptStream(turns, nil) r := &LLMRunner{Stream: func(ctx context.Context, model string, msgs []agentllm.Message, tools []agentllm.ToolDecl, opts agentllm.StreamOptions) <-chan agentllm.Event { calls++ if n := len(msgs); n > 0 && msgs[n-1].Role == agentllm.RoleUser { lastUser = msgs[n-1].Content } return base(ctx, model, msgs, tools, opts) }} rc := &fakeRunCtx{ctx: context.Background(), prof: core.AgentProfile{Model: core.ModelRoute{Chat: "test-model"}}} if err := r.Execute(rc); err != nil { t.Fatalf("Execute: %v", err) } if calls != 2 { t.Fatalf("stream calls = %d, want 2", calls) } if !strings.Contains(lastUser, "register_artifact") { t.Fatalf("correction missing, got %q", lastUser) } texts := textParts(rc.assistant) if len(texts) != 2 || !texts[0].Process || texts[1].Process { t.Fatalf("text parts = %+v, want claimed text process then honest reply", texts) } } // TestLLMRunnerForgedToolMarkerTriggersGuard 验证:正文里的伪造调用标记也会触发守卫, // 即使没有工件编号和扩展名。回灌给模型的是占位,不是伪造正文。 func TestLLMRunnerForgedToolMarkerTriggersGuard(t *testing.T) { turns := [][]agentllm.Event{ { agentllm.Event{Kind: agentllm.KindTextDelta, Text: "好的。\n[调用工具 run_python({\"script\":\"print(1)\"})]\n[工具结果 ok 完成]"}, agentllm.Event{Kind: agentllm.KindMessageEnd}, }, { agentllm.Event{Kind: agentllm.KindTextDelta, Text: "这一轮没有生成文件。"}, agentllm.Event{Kind: agentllm.KindMessageEnd}, }, } var calls int var replay []agentllm.Message base := scriptStream(turns, nil) r := &LLMRunner{Stream: func(ctx context.Context, model string, msgs []agentllm.Message, tools []agentllm.ToolDecl, opts agentllm.StreamOptions) <-chan agentllm.Event { calls++ if calls == 2 { replay = append([]agentllm.Message(nil), msgs...) } return base(ctx, model, msgs, tools, opts) }} rc := &fakeRunCtx{ctx: context.Background(), prof: core.AgentProfile{Model: core.ModelRoute{Chat: "test-model"}}} if err := r.Execute(rc); err != nil { t.Fatalf("Execute: %v", err) } if calls != 2 { t.Fatalf("stream calls = %d, want 2", calls) } // N6(N3 WP1):伪造正文不得作为 assistant 的话术回放;更正以 user // 消息出现(不再改写 assistant)。 for _, m := range replay { if m.Role != agentllm.RoleAssistant { continue } if strings.Contains(m.Content, "run_python") { t.Fatalf("forged body was replayed: %q", m.Content) } } var correction bool for _, m := range replay { if m.Role == agentllm.RoleUser && strings.Contains(m.Content, "已作废") { correction = true } } if !correction { t.Fatalf("correction replay missing user note, got %#v", replay) } texts := textParts(rc.assistant) if len(texts) != 2 || !texts[0].Process || texts[1].Process { t.Fatalf("text parts = %+v, want forged text process then honest reply", texts) } } // TestLLMRunnerUndeclaredFileClaimTriggersGuard 验证:不带扩展名的「文件已生成/登记」也算谎称。 func TestLLMRunnerUndeclaredFileClaimTriggersGuard(t *testing.T) { turns := [][]agentllm.Event{ { agentllm.Event{Kind: agentllm.KindTextDelta, Text: "文件已正常生成并登记。工件卡片应该出现在对话下方。"}, agentllm.Event{Kind: agentllm.KindMessageEnd}, }, { agentllm.Event{Kind: agentllm.KindTextDelta, Text: "这一轮没有生成文件。"}, agentllm.Event{Kind: agentllm.KindMessageEnd}, }, } var calls int base := scriptStream(turns, nil) r := &LLMRunner{Stream: func(ctx context.Context, model string, msgs []agentllm.Message, tools []agentllm.ToolDecl, opts agentllm.StreamOptions) <-chan agentllm.Event { calls++ return base(ctx, model, msgs, tools, opts) }} rc := &fakeRunCtx{ctx: context.Background(), prof: core.AgentProfile{Model: core.ModelRoute{Chat: "test-model"}}} if err := r.Execute(rc); err != nil { t.Fatalf("Execute: %v", err) } if calls != 2 { t.Fatalf("stream calls = %d, want 2", calls) } } // TestLLMRunnerFalseDeliverableGuardTwiceThenNotice 验证:纠正两次后仍谎称, // 不再请求模型,终轮正文降为过程,并追加一条非过程的系统提示。 func TestLLMRunnerFalseDeliverableGuardTwiceThenNotice(t *testing.T) { turns := [][]agentllm.Event{ { agentllm.Event{Kind: agentllm.KindTextDelta, Text: "文档已生成:甲.docx"}, agentllm.Event{Kind: agentllm.KindMessageEnd}, }, { agentllm.Event{Kind: agentllm.KindTextDelta, Text: "文档已保存:乙.docx"}, agentllm.Event{Kind: agentllm.KindMessageEnd}, }, { agentllm.Event{Kind: agentllm.KindTextDelta, Text: "文档已导出:丙.docx"}, agentllm.Event{Kind: agentllm.KindMessageEnd}, }, } var calls int base := scriptStream(turns, nil) r := &LLMRunner{Stream: func(ctx context.Context, model string, msgs []agentllm.Message, tools []agentllm.ToolDecl, opts agentllm.StreamOptions) <-chan agentllm.Event { calls++ return base(ctx, model, msgs, tools, opts) }} rc := &fakeRunCtx{ctx: context.Background(), prof: core.AgentProfile{Model: core.ModelRoute{Chat: "test-model"}}} if err := r.Execute(rc); err != nil { t.Fatalf("Execute: %v", err) } if calls != 3 { t.Fatalf("stream calls = %d, want 3", calls) } texts := textParts(rc.assistant) if len(texts) != 4 { t.Fatalf("text parts = %d, want 3 demoted claims plus a notice", len(texts)) } for i := 0; i < 3; i++ { if !texts[i].Process { t.Fatalf("claim %d should be process, got %+v", i, texts[i]) } } if texts[3].Process || !strings.Contains(texts[3].Text, "本轮未实际生成文件") { t.Fatalf("notice = %+v, want a non-process fallback", texts[3]) } } // TestLLMRunnerExistingArtifactCitationNotGuarded 验证:引用项目里已有的工件不算谎称。 func TestLLMRunnerExistingArtifactCitationNotGuarded(t *testing.T) { turns := [][]agentllm.Event{ { agentllm.Event{Kind: agentllm.KindTextDelta, Text: "上次已生成的报告.docx 在这里:art_aaaaaaaaaaaa"}, agentllm.Event{Kind: agentllm.KindMessageEnd}, }, } var calls int base := scriptStream(turns, nil) r := &LLMRunner{Stream: func(ctx context.Context, model string, msgs []agentllm.Message, tools []agentllm.ToolDecl, opts agentllm.StreamOptions) <-chan agentllm.Event { calls++ return base(ctx, model, msgs, tools, opts) }} rc := &fakeRunCtx{ ctx: context.Background(), prof: core.AgentProfile{Model: core.ModelRoute{Chat: "test-model"}}, execArtifact: &core.Artifact{ID: "art_aaaaaaaaaaaa", Name: "报告.docx", Kind: "document"}, } if err := r.Execute(rc); err != nil { t.Fatalf("Execute: %v", err) } if calls != 1 { t.Fatalf("stream calls = %d, want 1", calls) } texts := textParts(rc.assistant) if len(texts) != 1 || texts[0].Process { t.Fatalf("citation should stay the final reply, got %+v", texts) } } // TestLLMRunnerToolCapKeepsParts 验证:超限的那一批工具在执行前被整批拒绝, // 已经完成的工具轮部件仍然落库。内核按整批计数,不再执行批内第 N+1 个调用。 func TestLLMRunnerToolCapKeepsParts(t *testing.T) { one := 1 turns := [][]agentllm.Event{ { agentllm.Event{Kind: agentllm.KindTextDelta, Text: "先做一件。"}, agentllm.Event{Kind: agentllm.KindMessageEnd, Message: agentllm.Message{ToolCalls: []agentllm.ToolCall{ {ID: "c1", Name: "make_report", Arguments: "{}"}, }}}, }, { agentllm.Event{Kind: agentllm.KindMessageEnd, Message: agentllm.Message{ToolCalls: []agentllm.ToolCall{ {ID: "c2", Name: "make_report", Arguments: "{}"}, {ID: "c3", Name: "make_report", Arguments: "{}"}, }}}, }, } r := &LLMRunner{Stream: scriptStream(turns, nil)} rc := &fakeRunCtx{ctx: context.Background(), prof: core.AgentProfile{ Model: core.ModelRoute{Chat: "test-model"}, Budget: core.RunBudget{MaxToolCalls: &one}, }} err := r.Execute(rc) if err == nil || !strings.Contains(err.Error(), "工具调用超过上限") { t.Fatalf("err = %v", err) } if rc.toolRuns != 1 { t.Fatalf("tool runs = %d, want 1 (the over-cap batch is rejected whole)", rc.toolRuns) } var calls int for _, p := range rc.assistant { if p.Type == core.PartToolCall { calls++ } } if calls != 1 { t.Fatalf("saved tool-call parts = %d, want the completed turn only", calls) } } // TestLLMRunnerContinuesWhenTruncated 验证:finish_reason=length 且没有工具调用时续写。 func TestLLMRunnerContinuesWhenTruncated(t *testing.T) { turns := [][]agentllm.Event{ { agentllm.Event{Kind: agentllm.KindTextDelta, Text: "写到一半"}, agentllm.Event{Kind: agentllm.KindMessageEnd, FinishReason: "length", Message: agentllm.Message{Content: "写到一半"}}, }, { agentllm.Event{Kind: agentllm.KindTextDelta, Text: "后半写完了。"}, agentllm.Event{Kind: agentllm.KindMessageEnd, FinishReason: "stop", Message: agentllm.Message{Content: "后半写完了。"}}, }, } var calls int var second string base := scriptStream(turns, nil) r := &LLMRunner{Stream: func(ctx context.Context, model string, msgs []agentllm.Message, tools []agentllm.ToolDecl, opts agentllm.StreamOptions) <-chan agentllm.Event { calls++ if calls == 2 { second = transcriptOf(msgs) } return base(ctx, model, msgs, tools, opts) }} rc := &fakeRunCtx{ctx: context.Background(), prof: core.AgentProfile{Model: core.ModelRoute{Chat: "test-model"}}} if err := r.Execute(rc); err != nil { t.Fatalf("Execute: %v", err) } if calls != 2 { t.Fatalf("stream calls = %d, want 2", calls) } if !strings.Contains(second, "上一段回复因为达到长度上限被截断") { t.Fatalf("continuation prompt missing: %s", second) } texts := textParts(rc.assistant) if len(texts) != 2 || texts[0].Text != "写到一半" || texts[1].Text != "后半写完了。" || texts[1].Process { t.Fatalf("texts = %+v", texts) } } func TestToolCallDeltaBeforeExecution(t *testing.T) { call := toolCall("echo_tool", `{"script":"print(1)"}`) turns := [][]agentllm.Event{ { {Kind: agentllm.KindTextDelta, Text: "我来跑一下。"}, {Kind: agentllm.KindToolCallDelta, ToolIndex: 0, ToolID: call.ID, ToolName: call.Name, ArgumentsDelta: `{"script":`}, {Kind: agentllm.KindToolCallDelta, ToolIndex: 0, ArgumentsDelta: `"print(1)"}`}, {Kind: agentllm.KindMessageEnd, Message: agentllm.Message{ToolCalls: []agentllm.ToolCall{call}}}, }, textDeltaTurn("跑完了。"), } rc := &fakeRunCtx{ctx: context.Background(), prof: core.AgentProfile{Model: core.ModelRoute{Chat: "test-model"}}} r := &LLMRunner{Stream: scriptStream(turns, nil)} if err := r.Execute(rc); err != nil { t.Fatal(err) } var order []core.StreamEventType for _, ev := range rc.events { switch ev.typ { case core.EvToolCallDelta, core.EvToolCall, core.EvToolResult: order = append(order, ev.typ) } } want := []core.StreamEventType{core.EvToolCallDelta, core.EvToolCallDelta, core.EvToolCall, core.EvToolResult} if len(order) != len(want) { t.Fatalf("order = %v", order) } for i := range want { if order[i] != want[i] { t.Fatalf("order = %v", order) } } var delta core.ToolCallDeltaPayload for _, ev := range rc.events { if ev.typ == core.EvToolCallDelta { delta = ev.payload.(core.ToolCallDeltaPayload) break } } if delta.Name != call.Name || delta.Index != 0 || delta.Delta == "" { t.Fatalf("first delta = %+v", delta) } var result core.ToolResultPayload for _, ev := range rc.events { if ev.typ == core.EvToolResult { result = ev.payload.(core.ToolResultPayload) } } if result.CallID != call.ID || result.DurationMs < 0 { t.Fatalf("result = %+v", result) } } func transcriptOf(msgs []agentllm.Message) string { var b strings.Builder for _, m := range msgs { b.WriteString(m.Content) b.WriteByte('\n') } return b.String() } func (f *fakeRunCtx) Surfaces() []core.SurfaceSnapshot { return nil }