Files
Agent-System-Backend/internal/runtime/llm_test.go
T
weichen 370ae1a280 Generate styled Word deliverables and pin artifact cards in chat
Why: docx deliverables were plain text saved with a .docx name (no
headings/tables/styles), and the artifact_ref part was marked Process
so the deliverable vanished into the collapsed process section — the
user saw only model-written text like "📄 xxx.docx 工件 ID …" instead
of an openable, formatted document delivered in the conversation.

Evidence:
- new internal/docgen: Markdown -> styled OOXML (Title/Heading1-4,
  numbered+bullet lists with numbering.xml, GFM tables with shaded
  header, quotes, code blocks, bold/italic/strike, real hyperlinks);
  zero third-party deps; tests cover zip structure, XML well-formedness,
  numbering refs, hyperlink rels, escaping, plain-text fallback
- ingest round-trip test proves generated docx parses back to blocks
- new write_docx builtin tool (markdown in -> styled .docx artifact);
  write_artifact prompt now routes Word docs to it; task.doc-minutes
  output contract requires write_docx delivery
- llm.go: artifact refs collected as deliverables and appended as
  content parts AFTER the final text (pinned tail, not process); mock
  #dogfood path generates a real styled docx; runmgr skips string
  indexing for binary office mimes
- go test ./... 19 packages ok (incl. new e2e asserting <w:tbl> and
  title text inside the artifact served by the API)

Impact: model-written documents open in Word/WPS with real layout and
preview with formatting (mammoth maps Heading1-4 to h1-h4); every tool
artifact now lands as a visible in-chat card; no behavior change for
text artifacts.

Refs: task feature/deliverable-docx-r1
2026-10-01 02:48:49 +00:00

623 lines
23 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"
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 返回的截断标记(门面卡触发)
execArtifactContent []byte // Artifact(id) 回填内容(ctxasm D46 断言用)
}
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}
}
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 {
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 {
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)
}
}