Files
Agent-System-Backend/internal/runtime/llm_test.go
T
weichenandCursor a3ae1b986d Check final replies before the direct run stops
Why: A direct run could stop on a missing file, a failed lint, or an unverified claim. The final turn now checks registered artifacts and asks a tool-less verifier, and nudges stale plans and todos.
Evidence: go test ./... passed. New runtime tests cover missing and empty artifacts, docx lint errors, verifier reject-then-pass, unparseable and failed verifier calls, nil verifier tools, todo reminders, and the false-deliverable fixture.
Impact: Direct runs with runtime.verify (default on for new runs) spend one extra lightweight call on every final turn. Setting it off keeps the previous stop behavior. Squad and SubLLMRunner are unchanged. The agent-native pin stays a34689c.
Refs: feature/agent-harness-p2
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-01 13:54:15 +00:00

981 lines
36 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 runtime
import (
"context"
"errors"
"fmt"
"strings"
"testing"
"time"
agentllm "agent-native/runtime/llm"
core "agent-system-core"
)
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;缺省关闭,避免改变既有流调用次数
missingArtifact bool // 工具登记了工件,但 Artifact 查不到
emptyArtifact bool // Artifact 查到了,内容为空
}
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}
}
if f.verify {
eff["runtime"] = map[string]any{"verify": true}
}
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) 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")
}
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.execArtifact != nil {
res.Artifact = f.execArtifact
}
return res
}
func (f *fakeRunCtx) History() []core.Message {
return []core.Message{{ID: "m1", Role: core.RoleUser, Parts: []core.Part{{Type: core.PartText, Text: "hi"}}}}
}
func (f *fakeRunCtx) Attachments() []core.ParsedDocument { return nil }
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)
}
var placeholder bool
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)
}
if strings.Contains(m.Content, "已作废") {
placeholder = true
}
}
if !placeholder {
t.Fatalf("correction replay missing placeholder, 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 transcriptOf(msgs []agentllm.Message) string {
var b strings.Builder
for _, m := range msgs {
b.WriteString(m.Content)
b.WriteByte('\n')
}
return b.String()
}