Files
ttstd-infinite-canvas/backend/internal/app/cloud_agent_pi_unanswered_calls_test.go
jiabx 907a3eb3fb fix(agent): 审批恢复 - 补齐被审批暂停跳过的并行工具调用结果 (#670)
模型一步里发出多个工具调用、排在前面的进入用户审批时,运行时中止本轮,后面的调用既没执行也没有结果;批准后恢复运行,历史里留着有调用没结果的条目,整轮以「工具调用缺少结果」失败。转换前给这些调用补一条「未执行」结果:历史恢复合法,模型也知道那一步没做过,需要时会重新调用。

后端重启后恢复运行等场景下会被直接采用的服务端规范历史同样可能留着这类条目,一并补齐。
2026-10-05 13:28:14 +08:00

132 lines
6.2 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 app
import (
"strings"
"testing"
)
func runtimeToolCallPart(id, name string) map[string]interface{} {
return map[string]interface{}{"type": "toolCall", "id": id, "name": name, "arguments": map[string]interface{}{}}
}
// 复现:一步里 plan_update(需审批)+ canvas_list_node_types,
// 审批暂停后第二个调用没有结果,恢复运行时整轮以「工具调用缺少结果」失败。
func TestCloudAgentPiCanonicalRepairsCallSkippedByApprovalPause(t *testing.T) {
messages := []map[string]any{
{"role": "user", "content": "做一段特惠视频"},
{"role": "assistant", "content": []interface{}{
map[string]interface{}{"type": "text", "text": "参数已确认"},
runtimeToolCallPart("call_00_plan", "plan_update"),
runtimeToolCallPart("call_01_types", "canvas_list_node_types"),
}},
{"role": "toolResult", "toolCallId": "call_00_plan", "toolName": "plan_update", "content": []interface{}{map[string]interface{}{"type": "text", "text": "操作正在等待用户审批。"}}},
{"role": "user", "content": "用户已批准刚才等待审批的操作。"},
}
// 恢复前服务端保存的规范历史:停在审批那一步,比运行时消息短。
existing := canonicalAgentRequest{
SystemPrompt: "Agent system prompt",
PromptCacheKey: "cache-key",
Messages: []map[string]interface{}{
{"role": "system", "content": "Agent system prompt"},
{"role": "user", "content": "做一段特惠视频"},
{"role": "assistant", "content": "参数已确认", "tool_calls": []interface{}{
map[string]interface{}{"id": "call_00_plan", "function": map[string]interface{}{"name": "plan_update", "arguments": "{}"}},
map[string]interface{}{"id": "call_01_types", "function": map[string]interface{}{"name": "canvas_list_node_types", "arguments": "{}"}},
}},
},
}
canonical, err := cloudAgentPiCanonicalForModelRequest(messages, existing)
if err != nil {
t.Fatalf("convert resumed transcript: %v", err)
}
if err := validateCanonicalAgentHistory(&canonical, false); err != nil {
t.Fatalf("resumed history still invalid: %v", err)
}
// system + user + assistant + 两条工具结果 + 恢复提示词
if len(canonical.Messages) != 6 {
t.Fatalf("messages = %d, want 6: %#v", len(canonical.Messages), canonical.Messages)
}
skipped := canonical.Messages[4]
if stringField(skipped, "role") != "tool" || stringField(skipped, "tool_call_id") != "call_01_types" || !strings.Contains(stringField(skipped, "content"), "未被执行") {
t.Fatalf("skipped call was not answered: %#v", skipped)
}
last := canonical.Messages[5]
if stringField(last, "role") != "user" || !strings.Contains(stringField(last, "content"), "已批准") {
t.Fatalf("resume prompt did not reach the model: %#v", last)
}
if len(messages) != 4 {
t.Fatalf("input transcript was mutated: %d messages", len(messages))
}
}
func TestRepairRuntimeUnansweredCallsLeavesCompleteTranscriptUntouched(t *testing.T) {
messages := []map[string]any{
{"role": "user", "content": "读取画布和当前选区"},
{"role": "assistant", "content": []interface{}{
runtimeToolCallPart("call-state", "canvas_get_state"),
runtimeToolCallPart("call-selection", "canvas_get_selection"),
}},
{"role": "toolResult", "toolCallId": "call-selection", "content": "{}"},
{"role": "toolResult", "toolCallId": "call-state", "content": "{}"},
{"role": "assistant", "content": []interface{}{map[string]interface{}{"type": "text", "text": "读完了"}}},
}
repaired := repairRuntimeUnansweredCalls(messages)
if len(repaired) != len(messages) || &repaired[0] != &messages[0] {
t.Fatalf("complete transcript was rewritten: %#v", repaired)
}
}
func TestRepairRuntimeUnansweredCallsHandlesSeveralTurns(t *testing.T) {
messages := []map[string]any{
{"role": "user", "content": "开始"},
{"role": "assistant", "content": []interface{}{runtimeToolCallPart("a1", "plan_update"), runtimeToolCallPart("a2", "canvas_get_state")}},
{"role": "toolResult", "toolCallId": "a1", "content": "{}"},
{"role": "user", "content": "继续"},
{"role": "assistant", "content": []interface{}{runtimeToolCallPart("b1", "canvas_get_state")}},
{"role": "toolResult", "toolCallId": "b1", "content": "{}"},
{"role": "assistant", "content": []interface{}{runtimeToolCallPart("c1", "ask_user")}},
}
repaired := repairRuntimeUnansweredCalls(messages)
if err := validateRuntimeMessagesForModel(repaired); err != nil {
t.Fatalf("repaired transcript invalid: %v", err)
}
if len(repaired) != len(messages)+2 {
t.Fatalf("repaired = %d messages, want %d", len(repaired), len(messages)+2)
}
if stringField(repaired[3], "toolCallId") != "a2" || stringField(repaired[len(repaired)-1], "toolCallId") != "c1" {
t.Fatalf("synthetic results misplaced: %#v", repaired)
}
}
// 后端重启后恢复运行时,服务端规范历史比运行时消息长,会被直接采用;
// 它里面没有结果的调用同样要补齐,否则恢复运行仍以「工具调用缺少结果」失败。
func TestCloudAgentPiCanonicalRepairsServerHistoryWhenItIsAuthoritative(t *testing.T) {
existing := canonicalAgentRequest{
SystemPrompt: "Agent system prompt",
Messages: []map[string]interface{}{
{"role": "system", "content": "Agent system prompt"},
{"role": "user", "content": "整理画布"},
{"role": "assistant", "content": "", "tool_calls": []interface{}{
map[string]interface{}{"id": "call-a", "function": map[string]interface{}{"name": "canvas_get_state", "arguments": "{}"}},
map[string]interface{}{"id": "call-b", "function": map[string]interface{}{"name": "canvas_list_node_types", "arguments": "{}"}},
}},
{"role": "tool", "tool_call_id": "call-a", "content": "{}"},
{"role": "user", "content": "继续"},
},
}
// 运行时这边只剩一条恢复提示词,比服务端历史短。
messages := []map[string]any{{"role": "user", "content": "继续"}}
canonical, err := cloudAgentPiCanonicalForModelRequest(messages, existing)
if err != nil {
t.Fatal(err)
}
if err := validateCanonicalAgentHistory(&canonical, false); err != nil {
t.Fatalf("authoritative server history still invalid: %v", err)
}
if len(canonical.Messages) != 6 || stringField(canonical.Messages[4], "tool_call_id") != "call-b" {
t.Fatalf("missing result not inserted after existing results: %#v", canonical.Messages)
}
}