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
623 lines
23 KiB
Go
623 lines
23 KiB
Go
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)
|
||
}
|
||
}
|