From 61731cb2882b6818c8ced6135a927e56e9f299cb Mon Sep 17 00:00:00 2001 From: weichen Date: Wed, 30 Sep 2026 18:40:50 +0000 Subject: [PATCH] Unify artifact context backfill and facade cards (CF-A) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Why: D46/D47 approved — artifact_ref parts left only a name line in model context (next-turn amnesia on big tool results), and artifacts had no inbox presence unlike submit_card products. Evidence: ctxasm ArtifactResolver backfills UTF-8 artifact text with the card char limit (Truncations + artifact(n) observable; binary keeps placeholder); runmgr runCtx.Artifact; llm.go auto-creates a result facade card (summary kv + doc-ref) when a tool result is truncated, suppressed when the run already submitted an explicit card; mock #doc demos the facade; new tests TestAssembleInputArtifactBackfill, TestLLMRunnerArtifactFacadeCard/Suppressed, e2e TestArtifactFacadeCard. Impact: frontend unchanged — artifact chips and doc-ref already resolve via docBridge; no wire protocol change. Refs: documents proposal 2026-09-30-card-stream-artifact-unify-project-inbox.md (D46/D47). --- internal/ctxasm/ctxasm.go | 50 +++++++++++-- internal/ctxasm/ctxasm_test.go | 73 ++++++++++++++++-- internal/runmgr/runmgr.go | 14 ++++ internal/runtime/llm.go | 34 ++++++++- internal/runtime/llm_test.go | 133 ++++++++++++++++++++++++++++++--- internal/runtime/mock.go | 6 ++ internal/runtime/runner.go | 2 + internal/server/e2e_test.go | 58 ++++++++++++++ 8 files changed, 345 insertions(+), 25 deletions(-) diff --git a/internal/ctxasm/ctxasm.go b/internal/ctxasm/ctxasm.go index 3820ca4..a9bd3bc 100644 --- a/internal/ctxasm/ctxasm.go +++ b/internal/ctxasm/ctxasm.go @@ -4,6 +4,7 @@ package ctxasm import ( "fmt" "strings" + "unicode/utf8" agentllm "agent-native/runtime/llm" core "agent-system-core" @@ -26,12 +27,17 @@ const DefaultMaxCardChars = 12000 // CardResolver 按 ID 取收件箱卡(产物卡全文回填;nil = 无卡服务/无此卡,跳过)。 type CardResolver func(id core.ID) *core.ActivityCard +// ArtifactResolver 按 ID 取工件及其内容(artifact_ref 正文回填;D46 与卡片回填 +// 对称)。ok=false = 无工件服务/无此卡/内容不可读,部件留占位行。 +type ArtifactResolver func(id core.ID) (art core.Artifact, content []byte, ok bool) + // AssembleInput 组装模型输入消息。sys 为提示词库组装出的 system 段(可为多段; // 调用方经 prompts.Service.ResolveSystem 产出),promptObs 为对应可观测条目。 // prof.SystemPrompt 非空时作为画像自定义段追加(向后兼容旧画像)。 // resolveCard 用于 card 引用部件回填产物卡正文(submit_card;D35:卡片为产物权威 -// 载体,下一轮模型不失忆),nil 时 card 部件只留占位。 -func AssembleInput(prof core.AgentProfile, eff core.EffectiveSettings, history []core.Message, currentUser core.Message, attachments []core.ParsedDocument, knowledge []core.RetrievalHit, sys []agentllm.Message, promptObs []string, resolveCard CardResolver) ([]agentllm.Message, *Report) { +// 载体,下一轮模型不失忆),nil 时 card 部件只留占位。resolveArtifact 同理回填 +// artifact_ref 工件正文(D46 统一;仅 UTF-8 文本,超 limit 截断留痕)。 +func AssembleInput(prof core.AgentProfile, eff core.EffectiveSettings, history []core.Message, currentUser core.Message, attachments []core.ParsedDocument, knowledge []core.RetrievalHit, sys []agentllm.Message, promptObs []string, resolveCard CardResolver, resolveArtifact ArtifactResolver) ([]agentllm.Message, *Report) { rep := &Report{} msgs := make([]agentllm.Message, 0, len(sys)+1) msgs = append(msgs, sys...) @@ -59,11 +65,12 @@ func AssembleInput(prof core.AgentProfile, eff core.EffectiveSettings, history [ limitCard = DefaultMaxCardChars } cardsBackfilled := 0 + artsBackfilled := 0 for _, m := range history[start:] { if m.ID == currentUser.ID { continue } - content := renderMessage(m, resolveCard, limitCard, rep, &cardsBackfilled) + content := renderMessage(m, resolveCard, limitCard, resolveArtifact, rep, &cardsBackfilled, &artsBackfilled) if content == "" { continue } @@ -77,6 +84,9 @@ func AssembleInput(prof core.AgentProfile, eff core.EffectiveSettings, history [ if cardsBackfilled > 0 { rep.Items = append(rep.Items, fmt.Sprintf("card(%d)", cardsBackfilled)) } + if artsBackfilled > 0 { + rep.Items = append(rep.Items, fmt.Sprintf("artifact(%d)", artsBackfilled)) + } // 当前轮:文本 + 附件解析结果 + 知识检索注入。 var sb strings.Builder @@ -117,8 +127,8 @@ func AssembleInput(prof core.AgentProfile, eff core.EffectiveSettings, history [ } // renderMessage 把消息部件渲染为模型可读文本(思考部件不回灌;工具结果给摘要; -// card 引用部件回填产物卡正文——截断记入 rep.Truncations)。 -func renderMessage(m core.Message, resolveCard CardResolver, limitCard int, rep *Report, cardsBackfilled *int) string { +// card 引用部件回填产物卡正文、artifact_ref 回填工件正文——截断记入 rep.Truncations)。 +func renderMessage(m core.Message, resolveCard CardResolver, limitCard int, resolveArtifact ArtifactResolver, rep *Report, cardsBackfilled, artsBackfilled *int) string { var sb strings.Builder for _, p := range m.Parts { switch p.Type { @@ -134,9 +144,7 @@ func renderMessage(m core.Message, resolveCard CardResolver, limitCard int, rep fmt.Fprintf(&sb, "[工具结果 %s %s]\n", p.ToolResult.Status, p.ToolResult.Summary) } case core.PartArtifactRef: - if p.Artifact != nil { - fmt.Fprintf(&sb, "[产出工件 %s]\n", p.Artifact.Name) - } + sb.WriteString(renderArtifactRef(p.Artifact, resolveArtifact, limitCard, rep, artsBackfilled)) case core.PartHITL: if p.Hitl != nil && p.Hitl.Resolved { fmt.Fprintf(&sb, "[用户确认:%s → %s]\n", p.Hitl.Question, p.Hitl.Answer) @@ -190,3 +198,29 @@ func renderCardPart(cp *core.CardPart, resolveCard CardResolver, limit int, rep } return sb.String() } + +// renderArtifactRef 渲染 artifact_ref 部件:有 resolver 且内容为 UTF-8 文本时 +// 回填正文(与卡同 limit,超限截断留痕);二进制/不可读/无 resolver 留占位行 +// (可见即信号,不静默丢弃)。 +func renderArtifactRef(a *core.ArtifactRef, resolve ArtifactResolver, limit int, rep *Report, backfilled *int) string { + if a == nil { + return "" + } + if resolve == nil { + return fmt.Sprintf("[产出工件 %s]\n", a.Name) + } + art, content, ok := resolve(a.ArtifactID) + if !ok { + return fmt.Sprintf("[产出工件 %s]\n", a.Name) + } + if !utf8.Valid(content) { + return fmt.Sprintf("[产出工件 %s(二进制内容,正文见工件查看器)]\n", art.Name) + } + *backfilled++ + text := string(content) + if runes := []rune(text); len(runes) > limit { + text = string(runes[:limit]) + "…(工件超长截断)" + rep.Truncations = append(rep.Truncations, fmt.Sprintf("工件 %s 超 %d 字符截断", art.Name, limit)) + } + return fmt.Sprintf("[产出工件 %s]\n%s\n", art.Name, text) +} diff --git a/internal/ctxasm/ctxasm_test.go b/internal/ctxasm/ctxasm_test.go index 8f8419b..92c22de 100644 --- a/internal/ctxasm/ctxasm_test.go +++ b/internal/ctxasm/ctxasm_test.go @@ -18,7 +18,7 @@ func TestAssembleInputAttachmentLimit(t *testing.T) { Meta: core.DocMeta{Filename: "纪要.docx"}, Blocks: []core.DocBlock{{Type: core.BlockPara, Text: long}}, } - msgs, rep := AssembleInput(prof, core.EffectiveSettings{}, nil, fakeUser("请总结"), []core.ParsedDocument{doc}, nil, nil, nil, nil) + msgs, rep := AssembleInput(prof, core.EffectiveSettings{}, nil, fakeUser("请总结"), []core.ParsedDocument{doc}, nil, nil, nil, nil, nil) last := msgs[len(msgs)-1] if !strings.Contains(last.Content, "纪要.docx") { @@ -42,7 +42,7 @@ func TestAssembleInputAttachmentLimitConfigurable(t *testing.T) { Blocks: []core.DocBlock{{Type: core.BlockPara, Text: strings.Repeat("甲", 120)}}, } eff := core.EffectiveSettings{"context": map[string]any{"maxAttachmentChars": float64(100)}} - msgs, rep := AssembleInput(prof, eff, nil, fakeUser("总结"), []core.ParsedDocument{doc}, nil, nil, nil, nil) + msgs, rep := AssembleInput(prof, eff, nil, fakeUser("总结"), []core.ParsedDocument{doc}, nil, nil, nil, nil, nil) last := msgs[len(msgs)-1] if got := strings.Count(last.Content, "甲"); got != 100 { t.Errorf("injected chars = %d, want 100 (configured limit)", got) @@ -58,7 +58,7 @@ func TestAssembleInputAttachmentNoTruncationUnderLimit(t *testing.T) { Meta: core.DocMeta{Filename: "b.txt"}, Blocks: []core.DocBlock{{Type: core.BlockPara, Text: strings.Repeat("乙", 100)}}, } - msgs, rep := AssembleInput(prof, core.EffectiveSettings{}, nil, fakeUser("总结"), []core.ParsedDocument{doc}, nil, nil, nil, nil) + msgs, rep := AssembleInput(prof, core.EffectiveSettings{}, nil, fakeUser("总结"), []core.ParsedDocument{doc}, nil, nil, nil, nil, nil) if len(rep.Truncations) != 0 { t.Errorf("under-limit doc should not truncate: %v", rep.Truncations) } @@ -89,7 +89,7 @@ func TestAssembleInputCardBackfill(t *testing.T) { } return nil } - msgs, rep := AssembleInput(prof, core.EffectiveSettings{}, hist, fakeUser("把第二章改短"), nil, nil, nil, nil, resolve) + msgs, rep := AssembleInput(prof, core.EffectiveSettings{}, hist, fakeUser("把第二章改短"), nil, nil, nil, nil, resolve, nil) var assistantIn string for _, m := range msgs { @@ -128,7 +128,7 @@ func TestAssembleInputCardTruncationAndFallback(t *testing.T) { }}, } eff := core.EffectiveSettings{"context": map[string]any{"maxCardChars": float64(100)}} - msgs, rep := AssembleInput(prof, eff, hist, fakeUser("继续"), nil, nil, nil, nil, func(id core.ID) *core.ActivityCard { return &card }) + msgs, rep := AssembleInput(prof, eff, hist, fakeUser("继续"), nil, nil, nil, nil, func(id core.ID) *core.ActivityCard { return &card }, nil) if strings.Count(msgs[1].Content, "丁") != 100 { t.Errorf("card chars = %d, want 100 (configured limit)", strings.Count(msgs[1].Content, "丁")) } @@ -137,8 +137,69 @@ func TestAssembleInputCardTruncationAndFallback(t *testing.T) { } // 无 resolver(收件箱关闭/卡片缺失):占位可见。 - msgs2, _ := AssembleInput(prof, core.EffectiveSettings{}, hist, fakeUser("继续"), nil, nil, nil, nil, nil) + msgs2, _ := AssembleInput(prof, core.EffectiveSettings{}, hist, fakeUser("继续"), nil, nil, nil, nil, nil, nil) if !strings.Contains(msgs2[1].Content, "内容见收件箱") { t.Errorf("fallback placeholder missing: %q", msgs2[1].Content) } } + +// TestAssembleInputArtifactBackfill 验证 D46:artifact_ref 与卡片回填对称—— +// 文本工件回填正文(超 limit 截断留痕、报告 artifact(n));二进制留占位。 +func TestAssembleInputArtifactBackfill(t *testing.T) { + prof := core.AgentProfile{SystemPrompt: "s"} + art := core.Artifact{ID: "art_1", Name: "检索汇总.md", Kind: "document"} + hist := []core.Message{ + {ID: "m2", Role: core.RoleAssistant, Parts: []core.Part{ + {Type: core.PartArtifactRef, Artifact: &core.ArtifactRef{ArtifactID: "art_1", Name: art.Name, Kind: art.Kind}}, + }}, + } + resolve := func(id core.ID) (core.Artifact, []byte, bool) { + if id == "art_1" { + return art, []byte("## 汇总\n关键结论一。"), true + } + return core.Artifact{}, nil, false + } + msgs, rep := AssembleInput(prof, core.EffectiveSettings{}, hist, fakeUser("继续"), nil, nil, nil, nil, nil, resolve) + if !strings.Contains(msgs[1].Content, "[产出工件 检索汇总.md]") || !strings.Contains(msgs[1].Content, "关键结论一。") { + t.Errorf("artifact body not backfilled: %q", msgs[1].Content) + } + if !contains(rep.Items, "artifact(1)") { + t.Errorf("artifact(n) missing from report items: %v", rep.Items) + } + + // 超限截断留痕(复用 maxCardChars 上限口径)。 + long := strings.Repeat("乙", 300) + eff := core.EffectiveSettings{"context": map[string]any{"maxCardChars": float64(100)}} + msgs2, rep2 := AssembleInput(prof, eff, hist, fakeUser("继续"), nil, nil, nil, nil, nil, func(core.ID) (core.Artifact, []byte, bool) { + return art, []byte(long), true + }) + if strings.Count(msgs2[1].Content, "乙") != 100 { + t.Errorf("artifact chars = %d, want 100", strings.Count(msgs2[1].Content, "乙")) + } + if len(rep2.Truncations) != 1 || !strings.Contains(rep2.Truncations[0], "检索汇总.md") { + t.Errorf("artifact truncation not observable: %v", rep2.Truncations) + } + + // 二进制内容:占位不回灌乱码。 + msgs3, _ := AssembleInput(prof, core.EffectiveSettings{}, hist, fakeUser("继续"), nil, nil, nil, nil, nil, func(core.ID) (core.Artifact, []byte, bool) { + return art, []byte{0xff, 0xfe, 0x00, 0x01}, true + }) + if !strings.Contains(msgs3[1].Content, "二进制内容") || strings.Contains(msgs3[1].Content, "关键结论") { + t.Errorf("binary artifact should keep placeholder: %q", msgs3[1].Content) + } + + // 无 resolver:占位可见。 + msgs4, _ := AssembleInput(prof, core.EffectiveSettings{}, hist, fakeUser("继续"), nil, nil, nil, nil, nil, nil) + if !strings.Contains(msgs4[1].Content, "[产出工件 检索汇总.md]") { + t.Errorf("artifact placeholder missing without resolver: %q", msgs4[1].Content) + } +} + +func contains(items []string, want string) bool { + for _, it := range items { + if it == want { + return true + } + } + return false +} diff --git a/internal/runmgr/runmgr.go b/internal/runmgr/runmgr.go index e1a9df5..9879b88 100644 --- a/internal/runmgr/runmgr.go +++ b/internal/runmgr/runmgr.go @@ -325,6 +325,20 @@ func (c *runCtx) Card(id string) *core.ActivityCard { return &card } +// Artifact:按 ID 取工件与内容(ctxasm 回填 artifact_ref 正文用;跨租户/不存在 +// 一律 false,不泄露存在性)。 +func (c *runCtx) Artifact(id string) (core.Artifact, []byte, bool) { + art, err := c.mgr.st.GetArtifact(c.run.Owner, string(id)) + if err != nil { + return core.Artifact{}, nil, false + } + content, err := c.mgr.st.ArtifactContent(c.run.Owner, string(id)) + if err != nil { + return art, nil, false + } + return art, content, true +} + func (c *runCtx) WriteArtifact(name, kind, mime string, content []byte) (core.Artifact, error) { art := core.Artifact{ProjectID: c.run.ProjectID, Owner: c.run.Owner, Name: name, Kind: kind, MIME: mime, SourceRun: c.run.ID} if err := c.mgr.st.CreateArtifact(&art, content); err != nil { diff --git a/internal/runtime/llm.go b/internal/runtime/llm.go index ca620d2..3ca15f5 100644 --- a/internal/runtime/llm.go +++ b/internal/runtime/llm.go @@ -95,6 +95,24 @@ func (l *LLMRunner) toolDesc(owner, name, fallback string) string { return fallback } +// artifactFacadeCard 构造工具大结果的门面卡(D47):kind=result、status=done, +// body = 摘要 markdown + 来源 kv + doc-ref(正文权威在工件,卡只做门面与锚)。 +func artifactFacadeCard(tool string, art core.Artifact, summary string) core.ActivityCard { + md := strings.TrimSpace(summary) + if md == "" { + md = "工具结果超限,完整内容已存为工件" + } + return core.ActivityCard{ + Kind: core.CardKindResult, Title: "工具大结果 · " + art.Name, + Status: core.CardDone, Tone: "info", + Body: []core.UINode{ + {Type: "markdown", Markdown: md}, + {Type: "kv", KV: [][2]string{{"来源工具", tool}, {"工件", art.Name}}}, + {Type: "doc-ref", DocID: core.ID(art.ID), Title: art.Name}, + }, + } +} + func versionOf(blocks []core.PromptBlock, id string) int { for _, b := range blocks { if b.ModuleID == id { @@ -160,7 +178,9 @@ func (l *LLMRunner) Execute(rc RunCtx) error { if l.Prompts != nil { sys, promptObs = l.resolveSystem(rc, route.Model) } - msgs, asmReport := ctxasm.AssembleInput(prof, eff, hist, currentUser, rc.Attachments(), knowledge, sys, promptObs, rc.Card) + // 工件正文回填(D46):artifact_ref 与 card 部件同样回填,下一轮不失忆。 + resolveArtifact := func(id core.ID) (core.Artifact, []byte, bool) { return rc.Artifact(id) } + msgs, asmReport := ctxasm.AssembleInput(prof, eff, hist, currentUser, rc.Attachments(), knowledge, sys, promptObs, rc.Card, resolveArtifact) if asmReport != nil { rc.SetContextObservation(core.ContextObservation{ Items: asmReport.Items, Truncations: asmReport.Truncations, EstChars: asmReport.EstChars, @@ -208,6 +228,7 @@ func (l *LLMRunner) Execute(rc RunCtx) error { } var finalParts []core.Part + explicitCard := false // 本 run 已显式提交产物卡(submit_card/card_commit;门面卡避让,D47) usageTotal := core.Usage{} maxTurns := 16 if prof.Budget.MaxTurns != nil { @@ -359,6 +380,7 @@ func (l *LLMRunner) Execute(rc RunCtx) error { break } finalParts = append(finalParts, core.Part{Type: core.PartCard, Card: &core.CardPart{Kind: string(core.CardKindResult), CardID: card.ID}}) + explicitCard = true out = fmt.Sprintf("已作为卡片发出(%s,card:%s)。对话中保留简短结论即可,不要重复卡片全文。", a.Title, card.ID) case toolCardBegin: if !cardflow { @@ -406,6 +428,7 @@ func (l *LLMRunner) Execute(rc RunCtx) error { break } finalParts = append(finalParts, core.Part{Type: core.PartCard, Card: &core.CardPart{Kind: string(core.CardKindResult), CardID: card.ID}}) + explicitCard = true out = fmt.Sprintf("已作为卡片发出(%s,card:%s)。对话中保留简短结论即可,不要重复卡片全文。", card.Title, card.ID) default: res := rc.ExecuteTool(core.ToolCallRequest{CallID: call.ID, Name: call.Name, Arguments: json.RawMessage(call.Arguments)}) @@ -416,6 +439,15 @@ func (l *LLMRunner) Execute(rc RunCtx) error { } if res.Artifact != nil { artifactRef = &core.ArtifactRef{ArtifactID: res.Artifact.ID, Name: res.Artifact.Name, Kind: res.Artifact.Kind} + // 工具大结果门面卡(D47):截断超限的结果在收件箱留一张可发现 + // 的门面(摘要 + 来源 + doc-ref),正文权威仍在工件(不复制字节)。 + // 本 run 已显式提交产物卡时避让(防双卡)。 + if cardflow && res.Truncated && !explicitCard { + fc, ferr := rc.SubmitCard(artifactFacadeCard(call.Name, *res.Artifact, summarize(out, nil))) + if ferr == nil { + finalParts = append(finalParts, core.Part{Type: core.PartCard, Card: &core.CardPart{Kind: string(core.CardKindResult), CardID: fc.ID}}) + } + } } rc.Emit(core.EvToolResult, core.ToolResultPayload{CallID: call.ID, Status: res.Status, Summary: summarize(out, err), Truncated: res.Truncated}) } diff --git a/internal/runtime/llm_test.go b/internal/runtime/llm_test.go index 4e61839..08d4eef 100644 --- a/internal/runtime/llm_test.go +++ b/internal/runtime/llm_test.go @@ -18,15 +18,17 @@ type emittedEvent struct { // 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 语义) - toolDecls []agentllm.ToolDecl - execArtifact *core.Artifact // ExecuteTool 返回的工件(非 nil 时走工件路径) + ctx context.Context + prof core.AgentProfile + cardflow bool + events []emittedEvent + assistant []core.Part + cards []core.ActivityCard + streamCardID core.ID // 活跃流式卡(Begin/Append/Commit fake 语义) + 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 } @@ -57,7 +59,7 @@ func (f *fakeRunCtx) WriteArtifact(name, kind, mime string, content []byte) (cor } 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"} + res := core.ToolCallResult{Status: "ok", Output: "done", Truncated: f.execTruncated} if f.execArtifact != nil { res.Artifact = f.execArtifact } @@ -74,6 +76,13 @@ func (f *fakeRunCtx) CreateCard(card core.ActivityCard) (core.ActivityCard, erro 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) } @@ -468,3 +477,107 @@ func TestLLMRunnerCardStreamGated(t *testing.T) { 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) + } +} diff --git a/internal/runtime/mock.go b/internal/runtime/mock.go index f4c1617..81b2053 100644 --- a/internal/runtime/mock.go +++ b/internal/runtime/mock.go @@ -175,6 +175,12 @@ func (m *MockRunner) Execute(rc RunCtx) error { art, err := rc.WriteArtifact("demo-报告.md", "document", "text/markdown", []byte(content)) if err == nil { parts = append(parts, core.Part{Type: core.PartArtifactRef, Artifact: &core.ArtifactRef{ArtifactID: art.ID, Name: art.Name, Kind: art.Kind}}) + // 工件门面卡演示(CF-A/D47):registry 工具大结果的统一呈现—— + // 摘要 + 来源 kv + doc-ref(正文权威在工件);收件箱关闭时 SubmitCard + // 返回空 ID,跳过卡部件。 + if fc, ferr := rc.SubmitCard(artifactFacadeCard("write_artifact", art, "演示工件已生成(#doc)")); ferr == nil && fc.ID != "" { + parts = append(parts, core.Part{Type: core.PartCard, Card: &core.CardPart{Kind: string(core.CardKindResult), CardID: fc.ID}}) + } } addText("文档已生成,见右侧 Artifacts 标签(也可点击消息中的工件引用)。") diff --git a/internal/runtime/runner.go b/internal/runtime/runner.go index 8ecb17d..d3873d2 100644 --- a/internal/runtime/runner.go +++ b/internal/runtime/runner.go @@ -45,6 +45,8 @@ type RunCtx interface { CommitStreamCard() (core.ActivityCard, error) // Card:按 ID 取卡(ctxasm 回填 card 部件正文用;无则 nil)。 Card(id string) *core.ActivityCard + // Artifact:按 ID 取工件与内容(ctxasm 回填 artifact_ref 正文用;无则 false)。 + Artifact(id string) (core.Artifact, []byte, bool) // SetContextObservation 记录本次运行的上下文组装观测(随 run.completed 下发)。 SetContextObservation(o core.ContextObservation) } diff --git a/internal/server/e2e_test.go b/internal/server/e2e_test.go index d21620a..c2cb9e0 100644 --- a/internal/server/e2e_test.go +++ b/internal/server/e2e_test.go @@ -447,6 +447,64 @@ func TestArtifactFlow(t *testing.T) { } } +// TestArtifactFacadeCard 工件门面卡全链路(CF-A/D47):mock #doc → 工件落库 + +// artifact_ref 部件 → 门面卡入收件箱(摘要 kv + doc-ref)→ 消息含 card 引用 → +// finishRun 锚点回填。 +func TestArtifactFacadeCard(t *testing.T) { + srv, _ := newTestServer(t) + h := srv.Handler() + _, convID := setupConversation(t, h) + runID := postMessage(t, h, convID, "#doc") + terminal := waitRunTerminal(t, h, runID, 5*time.Second) + if terminal["status"] != "completed" { + t.Fatalf("run status: %v", terminal["status"]) + } + + _, cardsResp := doJSON(t, h, "GET", "/api/conversations/"+convID+"/cards", nil) + cards, _ := cardsResp["cards"].([]any) + var facade map[string]any + for _, ci := range cards { + c := ci.(map[string]any) + if c["kind"] == "result" && c["title"] == "工具大结果 · demo-报告.md" { + facade = c + } + } + if facade == nil { + t.Fatalf("facade card missing from inbox: %v", cards) + } + if facade["status"] != "done" { + t.Errorf("facade status = %v, want done", facade["status"]) + } + hasDocRef := false + for _, bi := range facade["body"].([]any) { + n := bi.(map[string]any) + if n["type"] == "doc-ref" && n["docId"] != "" { + hasDocRef = true + } + } + if !hasDocRef { + t.Error("facade body missing doc-ref node") + } + + _, detail := doJSON(t, h, "GET", "/api/conversations/"+convID, nil) + msgs := detail["messages"].([]any) + assistant := msgs[len(msgs)-1].(map[string]any) + cardID := facade["id"].(string) + hasRef := false + for _, pi := range assistant["parts"].([]any) { + p := pi.(map[string]any) + if p["type"] == "card" && p["card"].(map[string]any)["cardId"] == cardID { + hasRef = true + } + } + if !hasRef { + t.Errorf("assistant message missing facade card ref %s", cardID) + } + if anchor, _ := facade["anchorMessageId"].(string); anchor != assistant["id"].(string) { + t.Errorf("facade anchorMessageId = %q, want %q", anchor, assistant["id"]) + } +} + func TestUploadParseAndKnowledge(t *testing.T) { srv, _ := newTestServer(t) h := srv.Handler() -- 2.54.0