Files
Agent-System-Backend/internal/ctxasm/ctxasm_test.go
T
weichenandCursor cfde38de31 Send current-turn images and keep vision fallbacks capable
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>
2026-10-14 17:51:17 +00:00

250 lines
11 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, 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)
}
}