Files
Agent-System-Backend/internal/ctxasm/ctxasm_test.go
T
weichen 989379e924 Repair the quality-gate failure chain observed on real runs
Why: Real transcripts (dev instance, 62 assistant turns) showed the artifact
gate silently off and self-deadlocking. lintFile ran python3 -m housestyle.lint
without the skills PYTHONPATH, so the import failed and nil was treated as "no
issues" (same docx: 9 lint errors with PYTHONPATH, 0 without). stop() re-scored
every artifact registered in the run, including superseded versions, so a fixed
document could never pass and the composed reply reported the old verdict.
ctxasm rendered history artifacts as "[产出工件 …]" - the exact pattern
ClaimGate treats as a forged tool marker, so models copying the format were
rejected. scoreReadable flagged table cells like "项目概述撰写" as column
headings. Prompts and recipes said "python -m" (no python on PATH; exit 127)
and housestyle.summarize was invoked as a module that does not exist.

Evidence: SELF - new tests TestLintUnavailableFailsVisibleWhenSkillsRootSet,
TestLintCommandEnvCarriesSkillsRoot, TestDocxHeadingMarkedAndTableCellNotFlagged,
TestLatestDeliverablesKeepsLastPerName, TestRecipesUseRunnablePythonCommands,
TestLibraryUsesRunnablePythonCommands; go test ./internal/... passes; manual:
python3 -m housestyle.outline_cli reads a real artifact docx cleanly, lint
without PYTHONPATH exits 1 with ModuleNotFoundError and is now surfaced as
自检未运行 when the skills root is configured.

Impact: compose root injects quality.SetLintEnvRoot(<data>/skills). Final-turn
checks score only the newest artifact per name; deliverable lists and reply
cards follow the same dedupe. History artifact placeholders no longer collide
with the forged-marker regex. docx text extraction marks Title/HeadingN styles
so readability only judges real headings, with line numbers in the reason.
Check reminders no longer invite the model to ignore failed checks. Oversized
tool output is truncated by runes, not bytes.

Refs: Dev task feature/agent-quality-phase01 (quality gap diagnosis 2026-10-09).
2026-10-09 12:34:21 +00:00

206 lines
8.5 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 ctxasm
import (
"strings"
"testing"
core "agent-system-core"
)
func fakeUser(text string) core.Message {
return core.Message{ID: "m1", Role: core.RoleUser, Parts: []core.Part{{Type: core.PartText, Text: text}}}
}
func TestAssembleInputAttachmentLimit(t *testing.T) {
prof := core.AgentProfile{SystemPrompt: "s"}
long := strings.Repeat("会", 60000) // 60k 字符 > 默认 48k 上限
doc := core.ParsedDocument{
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, nil)
last := msgs[len(msgs)-1]
if !strings.Contains(last.Content, "纪要.docx") {
t.Fatal("attachment filename missing from model input")
}
if got := strings.Count(last.Content, "会"); got != DefaultMaxAttachmentChars {
t.Errorf("injected chars = %d, want %d (default limit)", got, DefaultMaxAttachmentChars)
}
if !strings.Contains(last.Content, "附件超长截断") {
t.Error("truncation marker missing in model input")
}
if len(rep.Truncations) != 1 || !strings.Contains(rep.Truncations[0], "纪要.docx") {
t.Errorf("truncation not observable in report: %v", rep.Truncations)
}
}
func TestAssembleInputAttachmentLimitConfigurable(t *testing.T) {
prof := core.AgentProfile{SystemPrompt: "s"}
doc := core.ParsedDocument{
Meta: core.DocMeta{Filename: "a.txt"},
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, nil)
last := msgs[len(msgs)-1]
if got := strings.Count(last.Content, "甲"); got != 100 {
t.Errorf("injected chars = %d, want 100 (configured limit)", got)
}
if len(rep.Truncations) != 1 {
t.Errorf("expected truncation record, got %v", rep.Truncations)
}
}
func TestAssembleInputAttachmentNoTruncationUnderLimit(t *testing.T) {
prof := core.AgentProfile{SystemPrompt: "s"}
doc := core.ParsedDocument{
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, nil)
if len(rep.Truncations) != 0 {
t.Errorf("under-limit doc should not truncate: %v", rep.Truncations)
}
if !strings.Contains(msgs[len(msgs)-1].Content, strings.Repeat("乙", 100)) {
t.Error("full text should be injected")
}
}
// TestAssembleInputCardBackfill 验证 card 引用部件回填产物卡全文(D35:卡片为产物
// 权威载体,下一轮模型不失忆),超限截断入 Truncations,构成入 Items 的 card(n)。
func TestAssembleInputCardBackfill(t *testing.T) {
prof := core.AgentProfile{SystemPrompt: "s"}
full := "全文第一段。\n" + strings.Repeat("丙", 50)
card := core.ActivityCard{ID: "card_1", Title: "会议纪要", Body: []core.UINode{
{Type: "markdown", Markdown: full},
{Type: "kv", KV: [][2]string{{"摘要", "三项决议"}}},
}}
hist := []core.Message{
fakeUser("生成纪要"),
{ID: "m2", Role: core.RoleAssistant, Parts: []core.Part{
{Type: core.PartText, Text: "已完成,详见卡片。"},
{Type: core.PartCard, Card: &core.CardPart{Kind: "result", CardID: "card_1"}},
}},
}
resolve := func(id core.ID) *core.ActivityCard {
if string(id) == "card_1" {
return &card
}
return nil
}
msgs, rep := AssembleInput(prof, core.EffectiveSettings{}, hist, fakeUser("把第二章改短"), nil, nil, nil, nil, resolve, nil)
var assistantIn string
for _, m := range msgs {
if m.Role == "assistant" {
assistantIn = m.Content
}
}
if !strings.Contains(assistantIn, "[产物卡片:会议纪要]") || !strings.Contains(assistantIn, "全文第一段。") {
t.Errorf("card body not backfilled into model input: %q", assistantIn)
}
if !strings.Contains(assistantIn, "摘要:三项决议") {
t.Error("kv summary not backfilled")
}
found := false
for _, it := range rep.Items {
if it == "card(1)" {
found = true
}
}
if !found {
t.Errorf("card(n) missing from report items: %v", rep.Items)
}
if len(rep.Truncations) != 0 {
t.Errorf("under-limit card should not truncate: %v", rep.Truncations)
}
}
// TestAssembleInputCardTruncationAndFallback 超限截断留痕;无 resolver 时留占位不静默丢弃。
func TestAssembleInputCardTruncationAndFallback(t *testing.T) {
prof := core.AgentProfile{SystemPrompt: "s"}
long := strings.Repeat("丁", 20000)
card := core.ActivityCard{ID: "card_2", Title: "长报告", Body: []core.UINode{{Type: "markdown", Markdown: long}}}
hist := []core.Message{
{ID: "m9", Role: core.RoleAssistant, Parts: []core.Part{
{Type: core.PartCard, Card: &core.CardPart{Kind: "result", CardID: "card_2"}},
}},
}
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 }, nil)
if strings.Count(msgs[1].Content, "丁") != 100 {
t.Errorf("card chars = %d, want 100 (configured limit)", strings.Count(msgs[1].Content, "丁"))
}
if len(rep.Truncations) != 1 || !strings.Contains(rep.Truncations[0], "长报告") {
t.Errorf("card truncation not observable: %v", rep.Truncations)
}
// 无 resolver(收件箱关闭/卡片缺失):占位可见。
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
}