757 lines
34 KiB
Go
757 lines
34 KiB
Go
package app
|
||
|
||
import (
|
||
"encoding/json"
|
||
"errors"
|
||
"strconv"
|
||
"strings"
|
||
"testing"
|
||
|
||
"yingce/backend/internal/model"
|
||
"yingce/backend/internal/repository"
|
||
)
|
||
|
||
// 这组用例钉死一条上游请求合同:assistant 消息声明的**每一个** tool_call_id,
|
||
// 都必须在紧随其后的连续 tool 消息里被回应。
|
||
//
|
||
// 背景(真实复现,2026-09-19 3001 环境):DeepSeek(严格 OpenAI 兼容)上,模型一轮里
|
||
// 一次发起 4 个 canvas_inspect_image 调用。旧实现每看完一张图就先 append tool 回执、
|
||
// 紧接着 append 一条 user 图片消息,历史于是变成
|
||
//
|
||
// assistant(tool_calls×4)
|
||
// → tool(call_00) → user(image)
|
||
// → tool(call_01) → user(image)
|
||
// → ...
|
||
//
|
||
// 下一个请求被上游 400 拒绝:
|
||
//
|
||
// An assistant message with 'tool_calls' must be followed by tool messages
|
||
// responding to each 'tool_call_id'. (insufficient tool messages following
|
||
// tool_calls message)
|
||
//
|
||
// 修法:图片先缓冲,等整批 tool 结果都入历史后合并成**一条** user 消息。
|
||
|
||
// cloudAgentToolPairingViolation 检查一条消息序列是否满足上游的请求合同,返回第一条违约
|
||
// 的可读描述("" = 满足)。
|
||
//
|
||
// 合同:每个含 tool_calls 的 assistant 消息,其声明的**每一个** tool_call_id 都必须在
|
||
// "下一条非 tool 消息之前"的连续 tool 消息里按声明顺序出现。
|
||
func cloudAgentToolPairingViolation(messages []map[string]any) string {
|
||
for index, message := range messages {
|
||
if stringField(message, "role") != "assistant" {
|
||
continue
|
||
}
|
||
declared := make([]string, 0, 4)
|
||
for _, call := range canonicalAgentToolCalls(message["tool_calls"]) {
|
||
if id := stringValue(call["id"]); id != "" {
|
||
declared = append(declared, id)
|
||
}
|
||
}
|
||
if len(declared) == 0 {
|
||
continue
|
||
}
|
||
answered := make([]string, 0, len(declared))
|
||
for _, follow := range messages[index+1:] {
|
||
// 第一条非 tool 消息就是边界:消息之后被插了任何别的东西都算违约,
|
||
// 这正是 user 图片消息插在 tool 结果之间时被上游拒掉的形态。
|
||
if stringField(follow, "role") != "tool" {
|
||
break
|
||
}
|
||
if id := stringField(follow, "tool_call_id"); id != "" {
|
||
answered = append(answered, id)
|
||
}
|
||
}
|
||
if len(answered) < len(declared) {
|
||
return "assistant#" + strconv.Itoa(index) + " declared " + strings.Join(declared, ",") +
|
||
" but only " + strconv.Itoa(len(answered)) + " tool messages follow before the next non-tool message: " +
|
||
strings.Join(answered, ",")
|
||
}
|
||
for position, id := range declared {
|
||
if answered[position] != id {
|
||
return "assistant#" + strconv.Itoa(index) + " tool results are out of order: declared " +
|
||
strings.Join(declared, ",") + ", answered " + strings.Join(answered, ",")
|
||
}
|
||
}
|
||
}
|
||
return ""
|
||
}
|
||
|
||
// assertCloudAgentToolCallPairing 是不可变式断言,用在每条看图路径的用例里。
|
||
func assertCloudAgentToolCallPairing(t *testing.T, messages []map[string]any) {
|
||
t.Helper()
|
||
if violation := cloudAgentToolPairingViolation(messages); violation != "" {
|
||
t.Fatalf("%s%s", violation, cloudAgentDebugMessages(messages))
|
||
}
|
||
}
|
||
|
||
// 反例自检:不变式断言必须真能抓住修复前的消息顺序,否则它只是个空断言。
|
||
func TestCloudAgentToolPairingInvariantCatchesInterleavedImages(t *testing.T) {
|
||
const batch = 4
|
||
declared := make([]any, 0, batch)
|
||
inspections := make([]cloudAgentImageInspection, 0, batch)
|
||
nodes := make([]string, 0, batch)
|
||
for index := 0; index < batch; index++ {
|
||
id := "call-" + strconv.Itoa(index)
|
||
declared = append(declared, map[string]any{"id": id, "type": "function", "function": map[string]any{"name": "canvas_inspect_image", "arguments": `{}`}})
|
||
node := "image-" + strconv.Itoa(index)
|
||
nodes = append(nodes, node)
|
||
inspections = append(inspections, cloudAgentPairingInspection(node, "https://example.test/"+node))
|
||
}
|
||
assistant := map[string]any{"role": "assistant", "content": "", "tool_calls": declared}
|
||
|
||
// 修复前:每看完一张图立刻 append 一条 user 图片消息,于是插在了后续 tool 结果之前
|
||
// —— assistant(tool_calls×4) 之后只有 1 条 tool 消息就碰到了 user,上游 400
|
||
// insufficient tool messages following tool_calls message。
|
||
buggy := []map[string]any{{"role": "user", "content": "看这四张图"}, assistant}
|
||
for index := 0; index < batch; index++ {
|
||
buggy = append(buggy,
|
||
map[string]any{"role": "tool", "tool_call_id": "call-" + strconv.Itoa(index), "content": `{"nodeId":"` + nodes[index] + `"}`},
|
||
map[string]any{"role": "user", "content": cloudAgentImageContentParts(inspections[index])},
|
||
)
|
||
}
|
||
if violation := cloudAgentToolPairingViolation(buggy); violation == "" {
|
||
t.Fatal("invariant check is vacuous: it accepted the buggy interleaved ordering")
|
||
}
|
||
|
||
// 修复后:四条 tool 结果连续排开,四张图合并成一条 user 消息放最后。
|
||
fixed := []map[string]any{{"role": "user", "content": "看这四张图"}, assistant}
|
||
for index := 0; index < batch; index++ {
|
||
fixed = append(fixed, map[string]any{"role": "tool", "tool_call_id": "call-" + strconv.Itoa(index), "content": `{"nodeId":"` + nodes[index] + `"}`})
|
||
}
|
||
fixed = append(fixed, map[string]any{"role": "user", "content": cloudAgentImageContentParts(inspections...)})
|
||
if violation := cloudAgentToolPairingViolation(fixed); violation != "" {
|
||
t.Fatalf("invariant rejected the fixed ordering: %s", violation)
|
||
}
|
||
if got := cloudAgentMessageImagePartCount(fixed[len(fixed)-1]); got != batch {
|
||
t.Fatalf("fixed ordering must carry all %d images in one message, got %d", batch, got)
|
||
}
|
||
}
|
||
|
||
// assertNoImageMessageBeforeToolResult 断言带图片的消息后面不再紧跟 tool 消息:
|
||
// 那正是"图片插在工具结果之间"的形态(旧实现:tool → user(图) → tool → …)。
|
||
// 与上面的不变式互补:前者管 tool_call_id 的连续性,这条直接咬住图片的位置。
|
||
func assertNoImageMessageBeforeToolResult(t *testing.T, messages []map[string]any) {
|
||
t.Helper()
|
||
for index, message := range messages {
|
||
if cloudAgentMessageImagePartCount(message) == 0 || index+1 >= len(messages) {
|
||
continue
|
||
}
|
||
if stringField(messages[index+1], "role") == "tool" {
|
||
t.Fatalf("图片消息 #%d 后面还跟着 tool 结果:图片必须落在整批 tool 结果之后%s", index, cloudAgentDebugMessages(messages))
|
||
}
|
||
}
|
||
}
|
||
|
||
// cloudAgentImageMessageIndexes 返回所有带 image_url 的 user 消息下标。
|
||
func cloudAgentImageMessageIndexes(messages []map[string]any) []int {
|
||
indexes := make([]int, 0, 2)
|
||
for index, message := range messages {
|
||
if cloudAgentMessageImagePartCount(message) > 0 {
|
||
indexes = append(indexes, index)
|
||
}
|
||
}
|
||
return indexes
|
||
}
|
||
|
||
// cloudAgentMessageImagePartCount 数一条消息里有几个图片部件。
|
||
func cloudAgentMessageImagePartCount(message map[string]any) int {
|
||
parts, ok := message["content"].([]any)
|
||
if !ok {
|
||
return 0
|
||
}
|
||
count := 0
|
||
for _, value := range parts {
|
||
part, _ := value.(map[string]any)
|
||
if stringField(part, "type") == "image_url" {
|
||
count++
|
||
}
|
||
}
|
||
return count
|
||
}
|
||
|
||
func cloudAgentDebugMessages(messages []map[string]any) string {
|
||
var builder strings.Builder
|
||
for index, message := range messages {
|
||
parts, _ := message["content"].([]any)
|
||
builder.WriteString("\n #")
|
||
builder.WriteString(strconv.Itoa(index))
|
||
builder.WriteString(" role=")
|
||
builder.WriteString(stringField(message, "role"))
|
||
builder.WriteString(" toolCallId=")
|
||
builder.WriteString(stringField(message, "tool_call_id"))
|
||
if len(parts) > 0 {
|
||
kinds := make([]string, 0, len(parts))
|
||
for _, value := range parts {
|
||
part, _ := value.(map[string]any)
|
||
kinds = append(kinds, stringField(part, "type"))
|
||
}
|
||
builder.WriteString(" parts=")
|
||
builder.WriteString(strings.Join(kinds, "+"))
|
||
}
|
||
}
|
||
return builder.String()
|
||
}
|
||
|
||
// cloudAgentPairingCall 造一个工具调用。
|
||
func cloudAgentPairingCall(id, name, arguments string) cloudAgentCall {
|
||
var call cloudAgentCall
|
||
call.ID = id
|
||
call.Function.Name = name
|
||
call.Function.Arguments = arguments
|
||
return call
|
||
}
|
||
|
||
// cloudAgentPairingState 造一个"一批调用刚开始执行"的运行时状态:
|
||
// canonical 里只有那条声明了全部调用的 assistant 消息。
|
||
func cloudAgentPairingState(calls ...cloudAgentCall) *cloudAgentRuntime {
|
||
state := &cloudAgentRuntime{}
|
||
state.Calls = calls
|
||
state.Canonical.Messages = append(state.Canonical.Messages,
|
||
map[string]any{"role": "assistant", "content": "", "tool_calls": calls})
|
||
return state
|
||
}
|
||
|
||
// cloudAgentPairingAssistantMessage 造一条声明了单个工具调用的 assistant 消息。
|
||
func cloudAgentPairingAssistantMessage(id string) map[string]any {
|
||
return map[string]any{"role": "assistant", "content": "", "tool_calls": []any{
|
||
map[string]any{"id": id, "type": "function", "function": map[string]any{"name": "canvas_get_state", "arguments": `{}`}},
|
||
}}
|
||
}
|
||
|
||
func cloudAgentPairingInspection(nodeID, url string) cloudAgentImageInspection {
|
||
return cloudAgentImageInspection{
|
||
Receipt: map[string]any{"nodeId": nodeID, "mimeType": "image/png", "title": nodeID},
|
||
ImageURL: url,
|
||
}
|
||
}
|
||
|
||
// runCloudAgentBatch 复刻 runtime 执行一批调用的两条路径:
|
||
// 1. 逐个调用走 cloudAgentToolResult(本批最后一个调用是看图时,它会立刻 flush);
|
||
// 2. 整批结束、开始组装 canonical 之前走 advanceCloudAgent 里的兜底 flush(幂等)。
|
||
//
|
||
// results 按 call.ID 给结果;error 类型的结果表示该调用以错误收场。
|
||
func runCloudAgentBatch(t *testing.T, state *cloudAgentRuntime, results map[string]any) {
|
||
t.Helper()
|
||
for _, call := range state.Calls {
|
||
result, ok := results[call.ID]
|
||
if !ok {
|
||
result = map[string]any{"ok": true}
|
||
}
|
||
if err, isError := result.(error); isError {
|
||
cloudAgentToolResult("run-1", state, call, nil, err)
|
||
continue
|
||
}
|
||
cloudAgentToolResult("run-1", state, call, result, nil)
|
||
}
|
||
cloudAgentFlushPendingImages(state)
|
||
}
|
||
|
||
// 场景 1:一个 assistant 回合带 3 个调用(图、图、非图),两张图都成功。
|
||
// 断言 assistant(tool_calls) 之后紧跟 3 条 tool 消息(tool_call_id 与声明顺序一致),
|
||
// 图片只在最后一条 tool 之后出现,且**只有一条** user 图片消息、内含 2 张图。
|
||
func TestCloudAgentBatchImagesFollowAllToolResults(t *testing.T) {
|
||
state := cloudAgentPairingState(
|
||
cloudAgentPairingCall("call-0", "canvas_inspect_image", `{"nodeId":"image-a"}`),
|
||
cloudAgentPairingCall("call-1", "canvas_inspect_image", `{"nodeId":"image-b"}`),
|
||
cloudAgentPairingCall("call-2", "canvas_get_state", `{}`),
|
||
)
|
||
results := map[string]any{
|
||
"call-0": cloudAgentPairingInspection("image-a", "https://example.test/a"),
|
||
"call-1": cloudAgentPairingInspection("image-b", "https://example.test/b"),
|
||
}
|
||
|
||
// 只执行调用,不走兜底 flush:模拟"批次刚执行完、还没开始组装下一步请求"。
|
||
for index, call := range state.Calls {
|
||
result := results[call.ID]
|
||
if result == nil {
|
||
result = map[string]any{"ok": true}
|
||
}
|
||
cloudAgentToolResult("run-1", state, call, result, nil)
|
||
if index == 1 {
|
||
if len(state.Canonical.Messages) != 3 {
|
||
t.Fatalf("batch is not finished yet: messages must be assistant + 2 tool, got%s", cloudAgentDebugMessages(state.Canonical.Messages))
|
||
}
|
||
if indexes := cloudAgentImageMessageIndexes(state.Canonical.Messages); len(indexes) != 0 {
|
||
t.Fatalf("image message was attached inside the batch: %v", indexes)
|
||
}
|
||
if len(state.PendingImageInspections) != 2 {
|
||
t.Fatalf("both inspections must be buffered, got %d", len(state.PendingImageInspections))
|
||
}
|
||
}
|
||
}
|
||
assertCloudAgentToolCallPairing(t, state.Canonical.Messages)
|
||
|
||
// 本批调用都执行完(advanceCloudAgent 的兜底 flush)。
|
||
cloudAgentFlushPendingImages(state)
|
||
messages := state.Canonical.Messages
|
||
assertCloudAgentToolCallPairing(t, messages)
|
||
assertNoImageMessageBeforeToolResult(t, messages)
|
||
|
||
if len(messages) != 5 {
|
||
t.Fatalf("expected assistant + 3 tool + 1 user image, got%s", cloudAgentDebugMessages(messages))
|
||
}
|
||
for index, id := range []string{"call-0", "call-1", "call-2"} {
|
||
message := messages[index+1]
|
||
if stringField(message, "role") != "tool" || stringField(message, "tool_call_id") != id {
|
||
t.Fatalf("tool message #%d is not %q: %+v", index, id, message)
|
||
}
|
||
}
|
||
imageIndexes := cloudAgentImageMessageIndexes(messages)
|
||
if len(imageIndexes) != 1 || imageIndexes[0] != 4 {
|
||
t.Fatalf("images must ride exactly one user message after the last tool result, got %v", imageIndexes)
|
||
}
|
||
if got := cloudAgentMessageImagePartCount(messages[4]); got != 2 {
|
||
t.Fatalf("merged image message must carry 2 images, got %d", got)
|
||
}
|
||
if nodes := cloudAgentImageMessageNodeIDs(messages[4]); len(nodes) != 2 || nodes[0] != "image-a" || nodes[1] != "image-b" {
|
||
t.Fatalf("merged message must keep both node ids in order: %v", nodes)
|
||
}
|
||
// 逐图"文字回执 + 图片"的成对结构:2 图 = 4 个部件,交替 text/image_url。
|
||
parts, _ := messages[4]["content"].([]any)
|
||
if len(parts) != 4 {
|
||
t.Fatalf("expected 2× (text + image), got %d parts", len(parts))
|
||
}
|
||
for index, want := range []string{"text", "image_url", "text", "image_url"} {
|
||
part, _ := parts[index].(map[string]any)
|
||
if stringField(part, "type") != want {
|
||
t.Fatalf("part #%d must be %s: %+v", index, want, part)
|
||
}
|
||
}
|
||
// 幂等:再 flush 一次不该多出一条消息
|
||
if cloudAgentFlushPendingImages(state) {
|
||
t.Fatal("flush must be idempotent")
|
||
}
|
||
if len(state.Canonical.Messages) != 5 {
|
||
t.Fatalf("flush is not idempotent: %d messages", len(state.Canonical.Messages))
|
||
}
|
||
}
|
||
|
||
// 场景 2:单图且是最后一个调用 —— 行为与修复前一致(仍然只有一条 user 图片消息,
|
||
// 紧跟最后一条 tool 结果)。
|
||
func TestCloudAgentSingleImageAsLastCallKeepsOneImageMessage(t *testing.T) {
|
||
state := cloudAgentPairingState(
|
||
cloudAgentPairingCall("call-0", "canvas_get_state", `{}`),
|
||
cloudAgentPairingCall("call-1", "canvas_inspect_image", `{"nodeId":"image-a"}`),
|
||
)
|
||
runCloudAgentBatch(t, state, map[string]any{
|
||
"call-1": cloudAgentPairingInspection("image-a", "https://example.test/a"),
|
||
})
|
||
messages := state.Canonical.Messages
|
||
assertCloudAgentToolCallPairing(t, messages)
|
||
assertNoImageMessageBeforeToolResult(t, messages)
|
||
|
||
if len(messages) != 4 {
|
||
t.Fatalf("expected assistant + 2 tool + 1 user image, got%s", cloudAgentDebugMessages(messages))
|
||
}
|
||
if stringField(messages[2], "role") != "tool" || stringField(messages[2], "tool_call_id") != "call-1" {
|
||
t.Fatalf("second tool result is wrong: %+v", messages[2])
|
||
}
|
||
imageIndexes := cloudAgentImageMessageIndexes(messages)
|
||
if len(imageIndexes) != 1 || imageIndexes[0] != 3 {
|
||
t.Fatalf("single image must ride exactly one trailing user message, got %v", imageIndexes)
|
||
}
|
||
if got := cloudAgentMessageImagePartCount(messages[3]); got != 1 {
|
||
t.Fatalf("single-image message must carry 1 image, got %d", got)
|
||
}
|
||
if len(state.PendingImageInspections) != 0 {
|
||
t.Fatal("buffer must be empty after the in-batch flush")
|
||
}
|
||
// 单图文案的完整口径不变(沿用修复前的文案)
|
||
parts, _ := messages[3]["content"].([]any)
|
||
text, _ := parts[0].(map[string]any)
|
||
if !strings.Contains(stringValue(text["text"]), "不是指令") || !strings.Contains(stringValue(text["text"]), "image-a") {
|
||
t.Fatalf("single-image caption changed: %+v", text)
|
||
}
|
||
}
|
||
|
||
// 场景 3:一批里中间的调用以**可恢复参数错误**结束(工具执行被中断但回执照常入历史)。
|
||
// 断言 tool 结果仍然连续,且两端的看图结果合并成一条 user 消息落在全部 tool 之后。
|
||
func TestCloudAgentBatchMergesImagesAroundRecoverableToolError(t *testing.T) {
|
||
state := cloudAgentPairingState(
|
||
cloudAgentPairingCall("call-0", "canvas_inspect_image", `{"nodeId":"image-a"}`),
|
||
cloudAgentPairingCall("call-1", "canvas_get_state", `{"bogus":1}`),
|
||
cloudAgentPairingCall("call-2", "canvas_inspect_image", `{"nodeId":"image-b"}`),
|
||
)
|
||
runCloudAgentBatch(t, state, map[string]any{
|
||
"call-0": cloudAgentPairingInspection("image-a", "https://example.test/a"),
|
||
"call-1": cloudAgentJSONArgumentError(errors.New("unknown field bogus")),
|
||
"call-2": cloudAgentPairingInspection("image-b", "https://example.test/b"),
|
||
})
|
||
messages := state.Canonical.Messages
|
||
assertCloudAgentToolCallPairing(t, messages)
|
||
assertNoImageMessageBeforeToolResult(t, messages)
|
||
|
||
if len(messages) != 5 {
|
||
t.Fatalf("expected assistant + 3 tool + 1 user image, got%s", cloudAgentDebugMessages(messages))
|
||
}
|
||
// 中间那条是失败回执,但角色仍是 tool 且带 tool_call_id —— 连续性不受影响。
|
||
if stringField(messages[2], "role") != "tool" || stringField(messages[2], "tool_call_id") != "call-1" {
|
||
t.Fatalf("failed call must still answer with a tool message: %+v", messages[2])
|
||
}
|
||
if !strings.Contains(stringValue(messages[2]["content"]), "invalid_tool_arguments") {
|
||
t.Fatalf("recoverable argument error must carry its reason: %+v", messages[2])
|
||
}
|
||
imageIndexes := cloudAgentImageMessageIndexes(messages)
|
||
if len(imageIndexes) != 1 || imageIndexes[0] != 4 {
|
||
t.Fatalf("both inspections must merge into one trailing message, got %v", imageIndexes)
|
||
}
|
||
if got := cloudAgentMessageImagePartCount(messages[4]); got != 2 {
|
||
t.Fatalf("merged message must carry 2 images, got %d", got)
|
||
}
|
||
}
|
||
|
||
// 场景 4:批次结束在"不看图"的调用上(兜底 flush 路径),以及重复看图(只回执文字、
|
||
// 不带图片)时不留下空图片消息。
|
||
//
|
||
// 两种行为都有断言:缓冲里的图片**不**在批内出现(否则就插到了 tool 结果之间),
|
||
// 但必须在本批结束后的兜底 flush 里补上(否则永久丢失,而 tool 回执已经在历史里)。
|
||
func TestCloudAgentBufferedImagesFlushAtBatchEndAndSurviveCheckpoint(t *testing.T) {
|
||
state := cloudAgentPairingState(
|
||
cloudAgentPairingCall("call-0", "canvas_inspect_image", `{"nodeId":"image-a"}`),
|
||
// 同一张图第二次看:只回执文字,不再附图(ImageURL 为空)。
|
||
cloudAgentPairingCall("call-1", "canvas_inspect_image", `{"nodeId":"image-a"}`),
|
||
cloudAgentPairingCall("call-2", "canvas_get_state", `{}`),
|
||
)
|
||
runCloudAgentBatch(t, state, map[string]any{
|
||
"call-0": cloudAgentPairingInspection("image-a", "https://example.test/a"),
|
||
"call-1": cloudAgentImageInspection{Receipt: map[string]any{"nodeId": "image-a", "repeat": true}},
|
||
})
|
||
messages := state.Canonical.Messages
|
||
assertCloudAgentToolCallPairing(t, messages)
|
||
assertNoImageMessageBeforeToolResult(t, messages)
|
||
|
||
if len(messages) != 5 {
|
||
t.Fatalf("expected assistant + 3 tool + 1 user image, got%s", cloudAgentDebugMessages(messages))
|
||
}
|
||
imageIndexes := cloudAgentImageMessageIndexes(messages)
|
||
if len(imageIndexes) != 1 || imageIndexes[0] != 4 {
|
||
t.Fatalf("buffered image must be flushed after all tool results, got %v", imageIndexes)
|
||
}
|
||
if got := cloudAgentMessageImagePartCount(messages[4]); got != 1 {
|
||
t.Fatalf("repeat inspection must not add a second image, got %d", got)
|
||
}
|
||
if len(state.PendingImageInspections) != 0 {
|
||
t.Fatal("buffer must be drained by the fallback flush")
|
||
}
|
||
}
|
||
|
||
// 缓冲必须进检查点:一次 advanceCloudAgent 只执行一个工具调用,整批调用跨越多次转移,
|
||
// 每次转移都从 StateJSON 重新解码(cloudAgentDecode)。这也是它不能标 `json:"-"` 的原因
|
||
// ——进程内字段在下一个调用到来时必然是空的,缓冲就白缓冲了。
|
||
// 本用例走真实的保存/解码路径(cloudAgentSave + cloudAgentDecode)把这点钉死。
|
||
func TestCloudAgentPendingImagesSurviveCheckpointRoundTrip(t *testing.T) {
|
||
state := cloudAgentPairingState(
|
||
cloudAgentPairingCall("call-0", "canvas_inspect_image", `{"nodeId":"image-a"}`),
|
||
cloudAgentPairingCall("call-1", "canvas_get_state", `{}`),
|
||
)
|
||
// 合并树(上游为底)的解码会校验事件身份 `event.EventID == "<runID>:<seq>"`。
|
||
// 本用例只需要"保存/解码"这条路径,不需要真实运行身份:用空 runID 让事件身份与
|
||
// 下面那个裸 run(ID 为空)一致,从而绕开运行期请求校验(它只在 run 有 ID 时执行)。
|
||
cloudAgentToolResult("", state, state.Calls[0], cloudAgentPairingInspection("image-a", "https://example.test/a"), nil)
|
||
cloudAgentToolResult("", state, state.Calls[1], map[string]any{"ok": true}, nil)
|
||
if len(state.PendingImageInspections) != 1 {
|
||
t.Fatalf("expected one buffered inspection, got %d", len(state.PendingImageInspections))
|
||
}
|
||
|
||
run := &model.CloudAgentExecution{}
|
||
if err := cloudAgentSave(run, state); err != nil {
|
||
t.Fatalf("cloudAgentSave failed: %v", err)
|
||
}
|
||
if !strings.Contains(run.StateJSON, "pendingImageInspections") {
|
||
t.Fatalf("buffer is not part of the checkpoint: %s", run.StateJSON)
|
||
}
|
||
decoded, err := cloudAgentDecode(run)
|
||
if err != nil {
|
||
t.Fatalf("cloudAgentDecode failed: %v", err)
|
||
}
|
||
if len(decoded.PendingImageInspections) != 1 {
|
||
t.Fatalf("buffer did not survive the checkpoint round trip: %d", len(decoded.PendingImageInspections))
|
||
}
|
||
if decoded.PendingImageInspections[0].ImageURL != "https://example.test/a" {
|
||
t.Fatalf("buffered image url was lost: %+v", decoded.PendingImageInspections[0])
|
||
}
|
||
// 下一个调用的转移里 flush,图片落在已经入历史的两条 tool 结果之后。
|
||
cloudAgentFlushPendingImages(&decoded)
|
||
assertCloudAgentToolCallPairing(t, decoded.Canonical.Messages)
|
||
imageIndexes := cloudAgentImageMessageIndexes(decoded.Canonical.Messages)
|
||
if len(imageIndexes) != 1 || imageIndexes[0] != len(decoded.Canonical.Messages)-1 {
|
||
t.Fatalf("flushed image must be the last message, got %v", imageIndexes)
|
||
}
|
||
}
|
||
|
||
// 多图合并成一条消息之后,裁剪仍要:移出该消息里的全部图片、保留逐图的文字回执与
|
||
// nodeId、并且占位符要逐图带上各自的观察(只写第一张会把整批的观察算到那张图头上)。
|
||
func TestCloudAgentPruneMergedImageMessageKeepsPerImageNotes(t *testing.T) {
|
||
imageMessage := map[string]any{"role": "user", "content": cloudAgentImageContentParts(
|
||
cloudAgentPairingInspection("image-a", "https://example.test/a"),
|
||
cloudAgentPairingInspection("image-b", "https://example.test/b"),
|
||
)}
|
||
messages := []map[string]any{{"role": "user", "content": "看看这两张图"}}
|
||
// 第一轮就是那个"一批两次看图、合并成一条 user 消息"的回合。
|
||
messages = append(messages, cloudAgentPairingAssistantMessage("call-0"))
|
||
messages = append(messages,
|
||
map[string]any{"role": "tool", "tool_call_id": "call-0", "content": `{"nodeId":"image-a"}`},
|
||
imageMessage)
|
||
// 后面再排满保留窗口,把它挤出裁剪边界之外(边界只保留最近
|
||
// cloudAgentImageRetentionRounds 个工具轮次)。
|
||
for round := 1; round <= cloudAgentImageRetentionRounds+1; round++ {
|
||
id := "call-" + strconv.Itoa(round)
|
||
messages = append(messages,
|
||
cloudAgentPairingAssistantMessage(id),
|
||
map[string]any{"role": "tool", "tool_call_id": id, "content": `{}`})
|
||
}
|
||
const imageIndex = 3
|
||
|
||
request := canonicalAgentRequest{Messages: messages}
|
||
notes := map[string]string{"image-a": "灰底三视图,赛璐璐平涂", "image-b": "蓝天海水,写实厚涂"}
|
||
changed, pruned := cloudAgentPruneInspectedImages(&request, notes)
|
||
if !changed || pruned != 2 {
|
||
t.Fatalf("both images of the merged message must be pruned: changed=%v pruned=%d", changed, pruned)
|
||
}
|
||
prunedMessage := request.Messages[imageIndex]
|
||
if count := cloudAgentMessageImagePartCount(prunedMessage); count != 0 {
|
||
t.Fatalf("images survived pruning: %d", count)
|
||
}
|
||
parts, _ := prunedMessage["content"].([]any)
|
||
if len(parts) != 3 {
|
||
t.Fatalf("expected 2 text receipts + 1 note, got %d parts", len(parts))
|
||
}
|
||
// 两条文字回执都在,且各自带自己的 nodeId
|
||
for index, want := range []string{"image-a", "image-b"} {
|
||
text, _ := parts[index].(map[string]any)
|
||
if !strings.Contains(stringValue(text["text"]), want) {
|
||
t.Fatalf("receipt #%d lost its node id: %+v", index, text)
|
||
}
|
||
}
|
||
note, _ := parts[2].(map[string]any)
|
||
noteText := stringValue(note["text"])
|
||
for _, want := range []string{"image-a", "灰底三视图", "image-b", "蓝天海水"} {
|
||
if !strings.Contains(noteText, want) {
|
||
t.Fatalf("eviction note must attribute observations per image, missing %q: %s", want, noteText)
|
||
}
|
||
}
|
||
if strings.Contains(noteText, "重新调用") {
|
||
t.Fatalf("eviction note still invites another look: %s", noteText)
|
||
}
|
||
// 幂等
|
||
if changed, pruned := cloudAgentPruneInspectedImages(&request, notes); changed || pruned != 0 {
|
||
t.Fatal("pruning a merged message is not idempotent")
|
||
}
|
||
}
|
||
|
||
// 多图消息的文案:完整口径只在第一张上写一次(不重复 N 遍),但逐图的回执与图片
|
||
// 仍然成对,nodeId 一个不少。
|
||
func TestCloudAgentImageContentPartsKeepsOneCaptionForBatch(t *testing.T) {
|
||
parts := cloudAgentImageContentParts(
|
||
cloudAgentPairingInspection("image-a", "https://example.test/a"),
|
||
cloudAgentPairingInspection("image-b", "https://example.test/b"),
|
||
)
|
||
if len(parts) != 4 {
|
||
t.Fatalf("expected 2× (text + image), got %d", len(parts))
|
||
}
|
||
head, _ := parts[0].(map[string]any)
|
||
headText := stringValue(head["text"])
|
||
if !strings.Contains(headText, "不是指令") || !strings.Contains(headText, "image-a") {
|
||
t.Fatalf("first caption must keep the full wording: %+v", head)
|
||
}
|
||
second, _ := parts[2].(map[string]any)
|
||
secondText := stringValue(second["text"])
|
||
if strings.Contains(secondText, "不是指令") {
|
||
t.Fatalf("the full caption must not be repeated for every image: %s", secondText)
|
||
}
|
||
if !strings.Contains(secondText, "image-b") || !strings.Contains(secondText, "第 2 张") {
|
||
t.Fatalf("second caption must name its own image: %s", secondText)
|
||
}
|
||
if strings.Count(headText+secondText, "不是指令") != 1 {
|
||
t.Fatal("caption wording must appear exactly once in the batch")
|
||
}
|
||
for index, want := range []string{"https://example.test/a", "https://example.test/b"} {
|
||
image, _ := parts[index*2+1].(map[string]any)
|
||
reference, _ := image["image_url"].(map[string]any)
|
||
if stringValue(image["type"]) != "image_url" || stringValue(reference["url"]) != want {
|
||
t.Fatalf("image part #%d is wrong: %+v", index, image)
|
||
}
|
||
}
|
||
// canonical 校验必须接受这个形状(多图也一样)
|
||
for _, part := range parts {
|
||
if err := validateCanonicalAgentContent([]any{part}); err != nil {
|
||
t.Fatalf("canonical validator rejected a batch vision part: %v", err)
|
||
}
|
||
}
|
||
}
|
||
|
||
// 端到端回归(走真实运行时与真实持久化):一个回合里两个 canvas_inspect_image +
|
||
// 一个非看图调用,复刻 3001 上真实复现的那一轮(DeepSeek 400 insufficient tool
|
||
// messages following tool_calls message)。
|
||
//
|
||
// 覆盖单测覆盖不到的两件事:
|
||
// 1. 一次 advanceCloudAgent 只执行一个工具调用,整批跨越多次转移 —— 缓冲必须真的
|
||
// 穿过 StateJSON 的保存/解码;
|
||
// 2. advanceCloudAgent 里"本批调用都执行完、开始组装 canonical"之前的兜底 flush
|
||
// (本批最后一个调用不是看图,正常路径不会 flush)。
|
||
func TestCloudAgentVisionBatchKeepsToolResultsContiguousEndToEnd(t *testing.T) {
|
||
t.Setenv("CANVAS_PUBLIC_BASE_URL", "")
|
||
s, db, _ := agentMediaFixture(t)
|
||
capability := DefaultModelCapabilityConfigForModel(string(model.ChannelInterfaceChatCompletion), "text-test")
|
||
capability.Text.References.MaxImages = 2
|
||
if err := db.Model(&model.ChannelModel{}).Where("id = ?", "cm").Update("capability_config_json", mustEncodeModelCapabilityConfig(t, capability)).Error; err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
for _, id := range []string{"ref-one", "ref-two"} {
|
||
if err := db.Model(&model.Resource{}).Where("id = ?", id).Update("provider", "local").Error; err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
}
|
||
|
||
req := agentTestRequest()
|
||
req.Budget.MaxCredits = 10
|
||
root, err := s.CreateCloudAgentRun("user", req, "")
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
run, err := s.repo.CloudAgent("user", root.ID)
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
state, err := cloudAgentDecode(run)
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
state.ActiveTaskID = ""
|
||
state.Request.VisionEnabled = true
|
||
state.Calls = []cloudAgentCall{
|
||
cloudAgentStoryboardCall(t, "canvas_inspect_image", "inspect-cat", map[string]any{"nodeId": "cat"}),
|
||
cloudAgentStoryboardCall(t, "canvas_inspect_image", "inspect-hero", map[string]any{"nodeId": "hero"}),
|
||
cloudAgentStoryboardCall(t, "canvas_get_state", "read-state", map[string]any{}),
|
||
}
|
||
// 生产里这条 assistant 消息是模型返回工具调用时写入的(cloud_agent_runtime.go
|
||
// 的 state.Calls = calls 那一步),这里手工补上,好让不变式断言真的覆盖到它。
|
||
state.Canonical.Messages = append(state.Canonical.Messages,
|
||
map[string]any{"role": "assistant", "content": "", "tool_calls": state.Calls})
|
||
if err := s.repo.MutateCloudAgent("user", run.ID, run.Revision, func(current *model.CloudAgentExecution, _ *repository.Repository) error {
|
||
return cloudAgentSave(current, &state)
|
||
}); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
|
||
// 逐个推进:一次转移执行一个工具调用。
|
||
for step := 0; step < len(state.Calls); step++ {
|
||
if err := s.advanceCloudAgentByID("user", root.ID); err != nil {
|
||
t.Fatalf("advance %d failed: %v", step, err)
|
||
}
|
||
run, err = s.repo.CloudAgent("user", root.ID)
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
mid, decodeErr := cloudAgentDecode(run)
|
||
if decodeErr != nil {
|
||
t.Fatalf("decode after advance %d failed: %v", step, decodeErr)
|
||
}
|
||
// 注意:批内中间态**故意**只有部分 tool 结果(一次转移执行一个调用),此时
|
||
// state.CallIndex < len(state.Calls),advanceCloudAgent 直接返回、不会发请求,
|
||
// 所以不变式只对"整批执行完、真正要发出去的 canonical"成立 —— 下面批次结束处才断言。
|
||
if step < len(state.Calls)-1 && len(cloudAgentImageMessageIndexes(mid.Canonical.Messages)) != 0 {
|
||
t.Fatalf("image message appeared before the batch finished (after advance %d): %s",
|
||
step, cloudAgentDebugMessages(mid.Canonical.Messages))
|
||
}
|
||
if step == len(state.Calls)-1 && len(mid.PendingImageInspections) != 2 {
|
||
t.Fatalf("both buffered inspections must survive the tick boundary, got %d", len(mid.PendingImageInspections))
|
||
}
|
||
}
|
||
|
||
// 第四次推进:本批调用都执行完,兜底 flush 把两张图合并成一条 user 消息。
|
||
if err := s.advanceCloudAgentByID("user", root.ID); err != nil {
|
||
t.Fatalf("batch-end advance failed: %v", err)
|
||
}
|
||
run, err = s.repo.CloudAgent("user", root.ID)
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
final, err := cloudAgentDecode(run)
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if final.ActiveTaskID == "" || run.Status == "failed" {
|
||
t.Fatalf("next model task not queued: %s", run.FailureMessage)
|
||
}
|
||
task, err := s.repo.TaskForUser("user", final.ActiveTaskID)
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
var input canvasGenerationInput
|
||
if err := json.Unmarshal([]byte(task.InputJSON), &input); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if len(input.ReferenceImages) != 2 || validateAgentResourcePlaceholders(input) != nil {
|
||
t.Fatalf("next task lost approved image resources: %+v", input.ReferenceImages)
|
||
}
|
||
if strings.Contains(task.InputJSON, "base64,") || strings.Contains(task.InputJSON, "signature=") {
|
||
t.Fatal("persisted image bytes or signed URL")
|
||
}
|
||
messages := final.Canonical.Messages
|
||
assertCloudAgentToolCallPairing(t, messages)
|
||
assertNoImageMessageBeforeToolResult(t, messages)
|
||
|
||
if len(final.PendingImageInspections) != 0 {
|
||
t.Fatalf("batch-end flush must drain the buffer, got %d", len(final.PendingImageInspections))
|
||
}
|
||
start := -1
|
||
for index, message := range messages {
|
||
if len(canonicalAgentToolCalls(message["tool_calls"])) == len(state.Calls) {
|
||
start = index
|
||
break
|
||
}
|
||
}
|
||
if start < 0 {
|
||
t.Fatalf("assistant batch with %d tool calls is missing: %s", len(state.Calls), cloudAgentDebugMessages(messages))
|
||
}
|
||
// 3 条 tool 结果连续且顺序与声明一致,紧接着才是唯一那条带 2 张图的 user 消息。
|
||
for offset, id := range []string{"inspect-cat", "inspect-hero", "read-state"} {
|
||
message := messages[start+1+offset]
|
||
if stringField(message, "role") != "tool" || stringField(message, "tool_call_id") != id {
|
||
t.Fatalf("tool message #%d is not %q: %+v", offset, id, message)
|
||
}
|
||
}
|
||
imageIndexes := cloudAgentImageMessageIndexes(messages)
|
||
if len(imageIndexes) != 1 || imageIndexes[0] != start+1+len(state.Calls) {
|
||
t.Fatalf("expected exactly one user image message right after the %d tool results, got %v (assistant#%d)%s",
|
||
len(state.Calls), imageIndexes, start, cloudAgentDebugMessages(messages))
|
||
}
|
||
if got := cloudAgentMessageImagePartCount(messages[imageIndexes[0]]); got != 2 {
|
||
t.Fatalf("merged image message must carry 2 images, got %d", got)
|
||
}
|
||
nodes := cloudAgentImageMessageNodeIDs(messages[imageIndexes[0]])
|
||
if len(nodes) != 2 || nodes[0] != "cat" || nodes[1] != "hero" {
|
||
t.Fatalf("merged message must keep both node ids in order: %v", nodes)
|
||
}
|
||
}
|
||
|
||
// 场景 5:一批里"先看图、再 ask_user"——本轮就此结束,不会再走 advanceCloudAgent 的
|
||
// 兜底 flush。所以"本批结束"的另一个入口 skipRemainingCloudAgentCalls 自己必须 flush,
|
||
// 否则这批的图片会被永久丢掉(tool 回执已经在历史里)。
|
||
func TestCloudAgentAskUserBatchFlushesBufferedImages(t *testing.T) {
|
||
state := cloudAgentPairingState(
|
||
cloudAgentPairingCall("call-0", "canvas_inspect_image", `{"nodeId":"image-a"}`),
|
||
cloudAgentPairingCall("call-1", "ask_user", `{}`),
|
||
)
|
||
cloudAgentToolResult("run-1", state, state.Calls[0], cloudAgentPairingInspection("image-a", "https://example.test/a"), nil)
|
||
// ask_user 的收尾顺序(cloud_agent_runtime.go 的 ask_user 分支):先写自己的回执,
|
||
// 再结束本批剩余调用。
|
||
cloudAgentToolResult("run-1", state, state.Calls[1], map[string]any{"phase": "question"}, nil)
|
||
skipRemainingCloudAgentCalls("run-1", state)
|
||
|
||
messages := state.Canonical.Messages
|
||
assertCloudAgentToolCallPairing(t, messages)
|
||
assertNoImageMessageBeforeToolResult(t, messages)
|
||
if len(state.PendingImageInspections) != 0 {
|
||
t.Fatalf("ask_user 结束时缓冲必须被 flush,got %d", len(state.PendingImageInspections))
|
||
}
|
||
imageIndexes := cloudAgentImageMessageIndexes(messages)
|
||
if len(imageIndexes) != 1 || imageIndexes[0] != len(messages)-1 {
|
||
t.Fatalf("合并后的图片消息必须在全部 tool 结果之后,got %v%s", imageIndexes, cloudAgentDebugMessages(messages))
|
||
}
|
||
if got := cloudAgentMessageImagePartCount(messages[imageIndexes[0]]); got != 1 {
|
||
t.Fatalf("expected one image in the flushed message, got %d", got)
|
||
}
|
||
}
|