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).
206 lines
8.5 KiB
Go
206 lines
8.5 KiB
Go
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
|
||
}
|