Files
Agent-System-Backend/internal/runtime/compose_test.go
weichen a21a3b57b4 Route workspace Word delivery through the shared renderer
Why: Task 10 replaces Backend's independent Markdown-to-Word production path with the reviewed canonical parser, embedded organization-default candidate, and Word adapter.
Evidence: Canonical parity, deterministic package, routing, safety, cancellation, comparison, persistence, affected-package race, full test, vet, build, and static build checks passed locally.
Impact: Ordinary workspace renders now persist Markdown, deterministic DOCX, manifest, and archive with hashes; controlled/project/review/approved routes fail closed to RDM, and legacy Go rendering is comparison-only.
Refs: Unified Document Renderer Task 10; Renderer d449b9f779f2f968f8ac556b89940a6c9aaf3fdc
2026-10-15 19:41:14 +00:00

335 lines
11 KiB
Go
Raw Permalink 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"
"strings"
"testing"
agentllm "agent-native/runtime/llm"
core "agent-system-core"
)
func TestEvidencePromptLastCheckWins(t *testing.T) {
paper := `[{"severity":"error","rule":"paper-size","message":"纸张不是 A4(21.59 × 27.94 cm)"}]`
empty := `[{"severity":"error","rule":"empty-section","message":"标题下没有内容"}]`
warn := `[{"severity":"warning","rule":"visual-summary","message":"建议添加视觉摘要"}]`
evidence := []toolEvidence{
{Name: "run_shell", Status: "ok", Summary: summarize(paper, nil), VerifyText: tailRunes(paper, 600)},
{Name: "run_shell", Status: "ok", Summary: summarize(empty, nil), VerifyText: tailRunes(empty, 600)},
{Name: "run_shell", Status: "ok", Summary: summarize(warn, nil), VerifyText: tailRunes(warn, 600)},
}
got := evidencePrompt("给我总结一份会议纪要,以 Word 文档给我", "lint 无 error", evidence, []*core.ArtifactRef{
{ArtifactID: "art_abc79b0e4486", Name: "纪要.docx"},
})
for _, want := range []string{"#1 ", "#2 ", "#3 ", "以最后一次为准", "visual-summary", "这是强证据", "纸张不是 A4"} {
if !strings.Contains(got, want) {
t.Fatalf("prompt missing %q:\n%s", want, got)
}
}
if strings.Index(got, "paper-size") > strings.Index(got, "visual-summary") {
t.Fatal("later check should follow the earlier one")
}
long := strings.Repeat("纸张不是 A4。", 200) + "TAIL_MARKER"
tail := tailRunes(long, 600)
if !strings.HasPrefix(tail, "…") || !strings.HasSuffix(tail, "TAIL_MARKER") || strings.HasPrefix(tail, "纸张") {
t.Fatalf("tail did not keep the end: %q", tail[:40])
}
}
func TestVerifyRejectionIsNotUserSpeech(t *testing.T) {
turns := [][]agentllm.Event{
textDeltaTurn("答案是 42。"),
textDeltaTurn("答案仍是 42。"),
}
var seen []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 {
seen = append(seen, joinContents(msgs))
return base(ctx, model, msgs, tools, opts)
},
VerifyStream: func(ctx context.Context, model string, msgs []agentllm.Message, tools []agentllm.ToolDecl, opts agentllm.StreamOptions) <-chan agentllm.Event {
body := `{"passed":false,"reason":"纸张不是 A4","nextAction":"改成 A4"}`
if len(seen) > 1 {
body = `{"passed":true,"reason":"完成","nextAction":""}`
}
return passedStream(body)(ctx, model, msgs, tools, opts)
},
}
rc := &fakeRunCtx{ctx: context.Background(), prof: verifyOn(), verify: true}
if err := r.Execute(rc); err != nil {
t.Fatal(err)
}
if len(seen) < 2 {
t.Fatalf("calls = %d", len(seen))
}
second := seen[1]
if !strings.Contains(second, "<system-reminder>") || !strings.Contains(second, "不是用户发言") || !strings.Contains(second, "改成 A4") {
t.Fatalf("rejection was not wrapped: %s", second)
}
if strings.Contains(second, "您提到") {
t.Fatalf("rejection speaks as the user: %s", second)
}
}
func TestComposeReplacesDraftAndPinsArtifact(t *testing.T) {
turns := [][]agentllm.Event{
textDeltaTurn("我来写文档。", toolCall("make_report", "{}")),
textDeltaTurn("草稿:已完成,lint 无 error。"),
textDeltaTurn("会议纪要已写好,文件在下面。"),
}
var toolCounts []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 {
toolCounts = append(toolCounts, len(tools))
return base(ctx, model, msgs, tools, opts)
}}
rc := &fakeRunCtx{
ctx: context.Background(),
prof: core.AgentProfile{Model: core.ModelRoute{Chat: "test-model"}},
compose: true,
execArtifact: &core.Artifact{ID: "art_abc79b0e4486", Name: "纪要.docx"},
}
if err := r.Execute(rc); err != nil {
t.Fatal(err)
}
if len(toolCounts) != 3 || toolCounts[2] != 0 {
t.Fatalf("tool decl counts = %v, want the compose call to have no tools", toolCounts)
}
sawFinalizing := false
deltaAfter := false
for _, e := range rc.emittedEvents() {
if e.typ == core.EvRunPhase {
p, ok := e.payload.(core.RunPhasePayload)
if ok && p.Phase == core.PhaseFinalizing {
sawFinalizing = true
}
}
if sawFinalizing && e.typ == core.EvMessageDelta {
p, ok := e.payload.(core.MessageDeltaPayload)
if ok && strings.Contains(p.Text, "会议纪要已写好") {
deltaAfter = true
}
}
}
if !sawFinalizing || !deltaAfter {
t.Fatal("finalizing phase did not precede the compose deltas")
}
parts := rc.assistant
if parts[len(parts)-1].Type != core.PartArtifactRef || parts[len(parts)-1].Artifact.Name != "纪要.docx" {
t.Fatalf("last part = %+v", parts[len(parts)-1])
}
var draft, reply *core.Part
for i := range parts {
p := &parts[i]
if p.Type != core.PartText {
continue
}
if strings.Contains(p.Text, "草稿") {
draft = p
}
if strings.Contains(p.Text, "会议纪要已写好") {
reply = p
}
}
if draft == nil || !draft.Process || reply == nil || reply.Process {
t.Fatalf("draft=%+v reply=%+v", draft, reply)
}
}
func TestComposeStripsThinkBlock(t *testing.T) {
turns := [][]agentllm.Event{
textDeltaTurn("我来写。", toolCall("make_report", "{}")),
textDeltaTurn("草稿:写好了。"),
textDeltaTurn("<think>\n根据规则,所以我应该解释评分。\n</think>\n\n纪要已写好,文件在下面。"),
}
r := &LLMRunner{Stream: scriptStream(turns, nil)}
rc := &fakeRunCtx{
ctx: context.Background(),
prof: core.AgentProfile{Model: core.ModelRoute{Chat: "test-model"}},
compose: true,
}
if err := r.Execute(rc); err != nil {
t.Fatal(err)
}
var reply string
for _, p := range rc.assistant {
if p.Type == core.PartText && !p.Process {
reply = p.Text
}
if p.Type == core.PartText && strings.Contains(p.Text, "所以我应该") {
t.Fatalf("think leaked into parts: %+v", p)
}
}
if reply != "纪要已写好,文件在下面。" {
t.Fatalf("reply = %q", reply)
}
}
func TestComposeReplyDropsInternalRefs(t *testing.T) {
turns := [][]agentllm.Event{
textDeltaTurn("我来写。", toolCall("make_report", "{}")),
textDeltaTurn("草稿。"),
textDeltaTurn("纪要已写好。登记信息:art_986876932a39(38,505 字节)。"),
}
r := &LLMRunner{Stream: scriptStream(turns, nil)}
rc := &fakeRunCtx{
ctx: context.Background(),
prof: core.AgentProfile{Model: core.ModelRoute{Chat: "test-model"}},
compose: true,
}
if err := r.Execute(rc); err != nil {
t.Fatal(err)
}
var reply string
for _, p := range rc.assistant {
if p.Type == core.PartText && !p.Process {
reply = p.Text
}
}
if strings.Contains(reply, "art_") || strings.Contains(reply, "字节") || !strings.Contains(reply, "纪要已写好") {
t.Fatalf("reply = %q", reply)
}
}
func TestComposeFailureRestoresDraft(t *testing.T) {
turns := [][]agentllm.Event{
textDeltaTurn("", toolCall("make_report", "{}")),
textDeltaTurn("草稿还在。"),
}
r := &LLMRunner{Stream: scriptStream(turns, nil)}
rc := &fakeRunCtx{
ctx: context.Background(),
prof: core.AgentProfile{Model: core.ModelRoute{Chat: "test-model"}},
compose: true,
}
if err := r.Execute(rc); err != nil {
t.Fatal(err)
}
var draft *core.Part
for i := range rc.assistant {
p := &rc.assistant[i]
if p.Type == core.PartText && strings.Contains(p.Text, "草稿还在") {
draft = p
}
if p.Type == core.PartText && strings.Contains(p.Text, "整理") {
t.Fatalf("unexpected compose text: %+v", p)
}
}
if draft == nil || draft.Process {
t.Fatalf("draft was not restored: %+v", rc.assistant)
}
}
func TestComposeSkipsPlainChatAndCanBeDisabled(t *testing.T) {
plain := [][]agentllm.Event{textDeltaTurn("你好。")}
r := &LLMRunner{Stream: scriptStream(plain, nil)}
rc := &fakeRunCtx{
ctx: context.Background(),
prof: core.AgentProfile{Model: core.ModelRoute{Chat: "test-model"}},
compose: true,
}
if err := r.Execute(rc); err != nil {
t.Fatal(err)
}
if hasPhase(rc, core.PhaseFinalizing) {
t.Fatal("plain chat should not finalize")
}
worked := [][]agentllm.Event{
textDeltaTurn("我来写。", toolCall("make_report", "{}")),
textDeltaTurn("写好了。"),
}
r = &LLMRunner{Stream: scriptStream(worked, nil)}
rc = &fakeRunCtx{
ctx: context.Background(),
prof: core.AgentProfile{Model: core.ModelRoute{Chat: "test-model"}},
}
if err := r.Execute(rc); err != nil {
t.Fatal(err)
}
if hasPhase(rc, core.PhaseFinalizing) {
t.Fatal("compose off should not finalize")
}
found := false
for _, p := range rc.assistant {
if p.Type == core.PartText && p.Text == "写好了。" && !p.Process {
found = true
}
}
if !found {
t.Fatalf("draft missing: %+v", rc.assistant)
}
}
func TestComposeAbsorbsVerifierNotice(t *testing.T) {
turns := [][]agentllm.Event{
textDeltaTurn("我先生成。", toolCall("make_report", "{}")),
textDeltaTurn("写好了。"),
textDeltaTurn("文件已生成,请查看。"),
}
r := &LLMRunner{
Stream: scriptStream(turns, nil),
VerifyStream: func(ctx context.Context, model string, msgs []agentllm.Message, tools []agentllm.ToolDecl, opts agentllm.StreamOptions) <-chan agentllm.Event {
out := make(chan agentllm.Event, 1)
out <- agentllm.Event{Kind: agentllm.KindError, ErrorMessage: "upstream"}
close(out)
return out
},
}
rc := &fakeRunCtx{ctx: context.Background(), prof: verifyOn(), verify: true, compose: true}
if err := r.Execute(rc); err != nil {
t.Fatal(err)
}
for _, p := range rc.assistant {
if p.Type == core.PartText && strings.Contains(p.Text, "验证器不可用") {
t.Fatalf("notice was appended beside the compose reply: %+v", rc.assistant)
}
}
if !hasProcessNote(rc.assistant, "调用失败") {
t.Fatalf("check missing: %+v", rc.assistant)
}
found := false
for _, p := range rc.assistant {
if p.Type == core.PartText && p.Text == "文件已生成,请查看。" && !p.Process {
found = true
}
}
if !found {
t.Fatalf("compose reply missing: %+v", rc.assistant)
}
}
func TestComposeUserPromptOmitsArtifactIDs(t *testing.T) {
st := &directRun{
rc: &fakeRunCtx{ctx: context.Background(), history: []core.Message{{
Role: core.RoleUser, Parts: []core.Part{{Type: core.PartText, Text: "给我一份纪要"}},
}}},
evidence: []toolEvidence{{Name: "register_artifact", Status: "ok", Summary: "已登记 art_abc79b0e4486,38505 字节"}},
deliverables: []*core.ArtifactRef{{ArtifactID: "art_abc79b0e4486", Name: "纪要.docx"}},
checks: []core.CheckPart{{Passed: false, Reason: "纸张尺寸"}},
}
got := st.composeUserPrompt("草稿里写了 art_abc79b0e4486(38,505 字节)", "本轮回复未通过终轮检查。")
if strings.Contains(got, "art_") || strings.Contains(got, "字节") {
t.Fatalf("prompt leaked an internal ref: %s", got)
}
for _, want := range []string{"给我一份纪要", "纪要.docx", "纸张尺寸", "仍未完成", "本轮回复未通过终轮检查。"} {
if !strings.Contains(got, want) {
t.Fatalf("prompt missing %q:\n%s", want, got)
}
}
}
func hasPhase(rc *fakeRunCtx, phase core.RunPhase) bool {
for _, e := range rc.emittedEvents() {
if e.typ != core.EvRunPhase {
continue
}
p, ok := e.payload.(core.RunPhasePayload)
if ok && p.Phase == phase {
return true
}
}
return false
}