Why: Uploaded images never reached the model, so a text-only chat model could be asked to read a picture it cannot see. Evidence: go test ./internal/... passed locally before this commit. Image-link rejection is covered by vision package tests. Impact: Current-turn attachments and fetched image links become image input. History keeps a text placeholder. Image turns swap to the configured vision model when the chat model is not in the capable list, and fallbacks are filtered the same way. Refs: Agent-System-Dev feature/four-model-vision Co-authored-by: Cursor <cursoragent@cursor.com>
250 lines
11 KiB
Go
250 lines
11 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, AssembleOptions{})
|
||
|
||
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, AssembleOptions{})
|
||
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, AssembleOptions{})
|
||
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, AssembleOptions{})
|
||
|
||
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, AssembleOptions{})
|
||
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, AssembleOptions{})
|
||
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, AssembleOptions{})
|
||
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
|
||
}, AssembleOptions{})
|
||
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
|
||
}, AssembleOptions{})
|
||
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, AssembleOptions{})
|
||
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
|
||
}
|
||
|
||
func TestCurrentImageAttachmentAndHistoryPlaceholder(t *testing.T) {
|
||
png := []byte{
|
||
0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0x00, 0x00, 0x00, 0x0d, 0x49, 0x48, 0x44, 0x52,
|
||
0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x01, 0x08, 0x02, 0x00, 0x00, 0x00, 0x90, 0x77, 0x53,
|
||
0xde, 0x00, 0x00, 0x00, 0x0c, 0x49, 0x44, 0x41, 0x54, 0x08, 0xd7, 0x63, 0xf8, 0xcf, 0xc0, 0x50,
|
||
0x0f, 0x00, 0x04, 0x85, 0x01, 0x80, 0x84, 0xa9, 0x8c, 0x21, 0x00, 0x00, 0x00, 0x00, 0x49, 0x45,
|
||
0x4e, 0x44, 0xae, 0x42, 0x60, 0x82,
|
||
}
|
||
doc := core.ParsedDocument{
|
||
Meta: core.DocMeta{Filename: "chart.png", MIME: "image/png"},
|
||
Blocks: []core.DocBlock{{
|
||
Type: core.BlockImageRef,
|
||
Ref: "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg==",
|
||
}},
|
||
}
|
||
msg := BuildCurrentUser(core.EffectiveSettings{}, fakeUser("看图"), []core.ParsedDocument{doc}, nil, &Report{})
|
||
if len(msg.Images) != 1 || msg.Images[0].MIME != "image/png" || msg.Images[0].Data == "" {
|
||
t.Fatalf("images %+v", msg.Images)
|
||
}
|
||
if !strings.Contains(msg.Content, "图像已随本消息发送") || strings.Contains(msg.Content, string(png)) {
|
||
t.Fatalf("content %q", msg.Content)
|
||
}
|
||
|
||
prev := core.Message{ID: "old", Role: core.RoleUser, Parts: []core.Part{
|
||
{Type: core.PartText, Text: "上一张"},
|
||
{Type: core.PartArtifactRef, Artifact: &core.ArtifactRef{ArtifactID: "a1", Name: "chart.png", Kind: "image"}},
|
||
}}
|
||
msgs, _ := AssembleInput(core.AgentProfile{}, core.EffectiveSettings{}, []core.Message{prev}, fakeUser("继续"), nil, nil, nil, nil, nil, func(core.ID) (core.Artifact, []byte, bool) {
|
||
return core.Artifact{Name: "chart.png", MIME: "image/png"}, png, true
|
||
}, AssembleOptions{})
|
||
var hist string
|
||
for _, m := range msgs {
|
||
if strings.Contains(m.Content, "上一张") {
|
||
hist = m.Content
|
||
if len(m.Images) != 0 {
|
||
t.Fatal("history must not resend image bytes")
|
||
}
|
||
}
|
||
}
|
||
if !strings.Contains(hist, "此前上传的图片:chart.png") {
|
||
t.Fatalf("history placeholder missing: %q", hist)
|
||
}
|
||
}
|