mirror of
https://github.com/router-for-me/CLIProxyAPI.git
synced 2026-10-05 15:19:54 +08:00
fix(devin): handle orphaned tool results and normalize function result payloads
- Match tool results against pending tool calls and downgrade unmatched results to user messages. - Prevent downgraded orphaned tool results from consuming images intended for user turns. - Unwrap protocol wrapper envelopes and extract structured text parts while preserving arbitrary business JSON. - Provide a placeholder for empty or whitespace-only tool results. Closes: #5911
This commit is contained in:
@@ -1381,6 +1381,31 @@ func parseInteractionsPayload(payload, originalRequest []byte) (
|
||||
cascadeID = sessionID
|
||||
|
||||
// 4. Repeated History Prompts
|
||||
var pendingToolCalls []string
|
||||
matchPendingToolCall := func(id string) (bool, string) {
|
||||
matchedIdx := -1
|
||||
if id != "" {
|
||||
for idx, pendingID := range pendingToolCalls {
|
||||
if pendingID == id {
|
||||
matchedIdx = idx
|
||||
break
|
||||
}
|
||||
}
|
||||
} else if len(pendingToolCalls) > 0 {
|
||||
matchedIdx = 0
|
||||
}
|
||||
if matchedIdx >= 0 {
|
||||
matchedID := pendingToolCalls[matchedIdx]
|
||||
pendingToolCalls = append(pendingToolCalls[:matchedIdx], pendingToolCalls[matchedIdx+1:]...)
|
||||
finalID := id
|
||||
if finalID == "" {
|
||||
finalID = matchedID
|
||||
}
|
||||
return true, finalID
|
||||
}
|
||||
return false, ""
|
||||
}
|
||||
|
||||
inputRes := root.Get("input")
|
||||
if inputRes.IsArray() {
|
||||
for _, step := range inputRes.Array() {
|
||||
@@ -1447,7 +1472,13 @@ func parseInteractionsPayload(payload, originalRequest []byte) (
|
||||
case "function_call":
|
||||
name := step.Get("name").String()
|
||||
id := firstNonEmpty(step.Get("id").String(), step.Get("call_id").String())
|
||||
args := step.Get("arguments").Raw
|
||||
argsRes := step.Get("arguments")
|
||||
var args string
|
||||
if argsRes.Type == gjson.String {
|
||||
args = argsRes.String()
|
||||
} else if argsRes.Exists() {
|
||||
args = argsRes.Raw
|
||||
}
|
||||
tc := helps.DevinToolCall{ID: id, Name: name, Arguments: args}
|
||||
if len(prompts) > 0 && prompts[len(prompts)-1].Source == 2 {
|
||||
prompts[len(prompts)-1].ToolCalls = append(prompts[len(prompts)-1].ToolCalls, tc)
|
||||
@@ -1458,17 +1489,29 @@ func parseInteractionsPayload(payload, originalRequest []byte) (
|
||||
ToolCalls: []helps.DevinToolCall{tc},
|
||||
})
|
||||
}
|
||||
pendingToolCalls = append(pendingToolCalls, id)
|
||||
|
||||
case "function_result":
|
||||
id := firstNonEmpty(step.Get("call_id").String(), step.Get("id").String())
|
||||
resText, resImages := extractFunctionResultContent(step)
|
||||
prompts = append(prompts, helps.DevinPrompt{
|
||||
MessageID: uuid.New().String(),
|
||||
Source: 4,
|
||||
ToolCallID: id,
|
||||
Content: resText,
|
||||
Images: resImages,
|
||||
})
|
||||
if matched, matchedID := matchPendingToolCall(id); matched {
|
||||
prompts = append(prompts, helps.DevinPrompt{
|
||||
MessageID: uuid.New().String(),
|
||||
Source: 4,
|
||||
ToolCallID: matchedID,
|
||||
Content: resText,
|
||||
Images: resImages,
|
||||
})
|
||||
} else {
|
||||
prompts = append(prompts, helps.DevinPrompt{
|
||||
MessageID: uuid.New().String(),
|
||||
Source: 1,
|
||||
OriginalToolCallID: id,
|
||||
IsOrphanedTool: true,
|
||||
Content: resText,
|
||||
Images: resImages,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
} else if messagesRes := root.Get("messages"); messagesRes.IsArray() {
|
||||
@@ -1490,21 +1533,59 @@ func parseInteractionsPayload(payload, originalRequest []byte) (
|
||||
})
|
||||
case "assistant":
|
||||
text := extractInteractionsStepText(m)
|
||||
var toolCalls []helps.DevinToolCall
|
||||
if tcRes := m.Get("tool_calls"); tcRes.IsArray() {
|
||||
for _, tcItem := range tcRes.Array() {
|
||||
tcID := firstNonEmpty(tcItem.Get("id").String(), tcItem.Get("call_id").String())
|
||||
tcName := tcItem.Get("function.name").String()
|
||||
if tcName == "" {
|
||||
tcName = tcItem.Get("name").String()
|
||||
}
|
||||
var tcArgs string
|
||||
fnArgs := tcItem.Get("function.arguments")
|
||||
if !fnArgs.Exists() {
|
||||
fnArgs = tcItem.Get("arguments")
|
||||
}
|
||||
if fnArgs.Type == gjson.String {
|
||||
tcArgs = fnArgs.String()
|
||||
} else if fnArgs.Exists() {
|
||||
tcArgs = fnArgs.Raw
|
||||
}
|
||||
toolCalls = append(toolCalls, helps.DevinToolCall{
|
||||
ID: tcID,
|
||||
Name: tcName,
|
||||
Arguments: tcArgs,
|
||||
})
|
||||
pendingToolCalls = append(pendingToolCalls, tcID)
|
||||
}
|
||||
}
|
||||
prompts = append(prompts, helps.DevinPrompt{
|
||||
MessageID: uuid.New().String(),
|
||||
Source: 2,
|
||||
Content: text,
|
||||
ToolCalls: toolCalls,
|
||||
})
|
||||
case "tool":
|
||||
id := firstNonEmpty(m.Get("tool_call_id").String(), m.Get("id").String())
|
||||
id := firstNonEmpty(m.Get("tool_call_id").String(), m.Get("id").String(), m.Get("call_id").String())
|
||||
resText, resImages := extractFunctionResultContent(m)
|
||||
prompts = append(prompts, helps.DevinPrompt{
|
||||
MessageID: uuid.New().String(),
|
||||
Source: 4,
|
||||
ToolCallID: id,
|
||||
Content: resText,
|
||||
Images: resImages,
|
||||
})
|
||||
if matched, matchedID := matchPendingToolCall(id); matched {
|
||||
prompts = append(prompts, helps.DevinPrompt{
|
||||
MessageID: uuid.New().String(),
|
||||
Source: 4,
|
||||
ToolCallID: matchedID,
|
||||
Content: resText,
|
||||
Images: resImages,
|
||||
})
|
||||
} else {
|
||||
prompts = append(prompts, helps.DevinPrompt{
|
||||
MessageID: uuid.New().String(),
|
||||
Source: 1,
|
||||
OriginalToolCallID: id,
|
||||
IsOrphanedTool: true,
|
||||
Content: resText,
|
||||
Images: resImages,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1647,23 +1728,86 @@ func extractDevinImage(part gjson.Result) (helps.DevinImage, bool) {
|
||||
}, true
|
||||
}
|
||||
|
||||
func extractFunctionResultContent(step gjson.Result) (string, []helps.DevinImage) {
|
||||
target := step.Get("result")
|
||||
if !target.Exists() {
|
||||
target = step.Get("output")
|
||||
const devinEmptyToolResultPlaceholder = "{}"
|
||||
|
||||
func isProtocolWrapperObject(obj gjson.Result, wrapperKey string) bool {
|
||||
if !obj.IsObject() {
|
||||
return false
|
||||
}
|
||||
if !target.Exists() {
|
||||
target = step.Get("content")
|
||||
targetVal := obj.Get(wrapperKey)
|
||||
if !targetVal.Exists() {
|
||||
return false
|
||||
}
|
||||
|
||||
// Case 1: Explicit tool_result protocol block (e.g. Claude tool_result)
|
||||
if strings.EqualFold(strings.TrimSpace(obj.Get("type").String()), "tool_result") {
|
||||
allAllowed := true
|
||||
obj.ForEach(func(k, _ gjson.Result) bool {
|
||||
key := k.String()
|
||||
if key == wrapperKey || key == "type" || key == "tool_use_id" || key == "id" || key == "is_error" || key == "cache_control" {
|
||||
return true
|
||||
}
|
||||
allAllowed = false
|
||||
return false
|
||||
})
|
||||
return allAllowed
|
||||
}
|
||||
|
||||
// Case 2: Pure single-key envelope containing only wrapperKey (and optional cache_control)
|
||||
allAllowed := true
|
||||
hasWrapper := false
|
||||
obj.ForEach(func(k, _ gjson.Result) bool {
|
||||
key := k.String()
|
||||
if key == wrapperKey {
|
||||
hasWrapper = true
|
||||
return true
|
||||
}
|
||||
if key == "cache_control" {
|
||||
return true
|
||||
}
|
||||
allAllowed = false
|
||||
return false
|
||||
})
|
||||
return hasWrapper && allAllowed
|
||||
}
|
||||
|
||||
func extractFunctionResultTarget(target gjson.Result) (string, []helps.DevinImage) {
|
||||
if !target.Exists() {
|
||||
return "", nil
|
||||
}
|
||||
|
||||
if target.Type == gjson.String {
|
||||
return target.String(), nil
|
||||
}
|
||||
|
||||
if img, ok := extractDevinImage(target); ok {
|
||||
return "", []helps.DevinImage{img}
|
||||
}
|
||||
if target.IsObject() {
|
||||
if isProtocolWrapperObject(target, "content") {
|
||||
return extractFunctionResultTarget(target.Get("content"))
|
||||
}
|
||||
if isProtocolWrapperObject(target, "output") {
|
||||
return extractFunctionResultTarget(target.Get("output"))
|
||||
}
|
||||
if isProtocolWrapperObject(target, "result") {
|
||||
return extractFunctionResultTarget(target.Get("result"))
|
||||
}
|
||||
itemType := strings.ToLower(strings.TrimSpace(target.Get("type").String()))
|
||||
if itemType == "text" {
|
||||
isPureTextPart := true
|
||||
target.ForEach(func(k, _ gjson.Result) bool {
|
||||
key := k.String()
|
||||
if key != "type" && key != "text" && key != "cache_control" {
|
||||
isPureTextPart = false
|
||||
return false
|
||||
}
|
||||
return true
|
||||
})
|
||||
if isPureTextPart {
|
||||
return target.Get("text").String(), nil
|
||||
}
|
||||
}
|
||||
return target.Raw, nil
|
||||
}
|
||||
if target.IsArray() {
|
||||
var textParts []string
|
||||
var images []helps.DevinImage
|
||||
@@ -1675,10 +1819,43 @@ func extractFunctionResultContent(step gjson.Result) (string, []helps.DevinImage
|
||||
hasStructuredParts = true
|
||||
continue
|
||||
}
|
||||
itemType := strings.ToLower(strings.TrimSpace(item.Get("type").String()))
|
||||
if itemType == "text" {
|
||||
isPureTextPart := true
|
||||
if item.IsObject() {
|
||||
if item.IsObject() {
|
||||
if isProtocolWrapperObject(item, "content") {
|
||||
hasStructuredParts = true
|
||||
txt, imgs := extractFunctionResultTarget(item.Get("content"))
|
||||
if txt != "" {
|
||||
textParts = append(textParts, txt)
|
||||
}
|
||||
if len(imgs) > 0 {
|
||||
images = append(images, imgs...)
|
||||
}
|
||||
continue
|
||||
}
|
||||
if isProtocolWrapperObject(item, "output") {
|
||||
hasStructuredParts = true
|
||||
txt, imgs := extractFunctionResultTarget(item.Get("output"))
|
||||
if txt != "" {
|
||||
textParts = append(textParts, txt)
|
||||
}
|
||||
if len(imgs) > 0 {
|
||||
images = append(images, imgs...)
|
||||
}
|
||||
continue
|
||||
}
|
||||
if isProtocolWrapperObject(item, "result") {
|
||||
hasStructuredParts = true
|
||||
txt, imgs := extractFunctionResultTarget(item.Get("result"))
|
||||
if txt != "" {
|
||||
textParts = append(textParts, txt)
|
||||
}
|
||||
if len(imgs) > 0 {
|
||||
images = append(images, imgs...)
|
||||
}
|
||||
continue
|
||||
}
|
||||
itemType := strings.ToLower(strings.TrimSpace(item.Get("type").String()))
|
||||
if itemType == "text" {
|
||||
isPureTextPart := true
|
||||
item.ForEach(func(k, _ gjson.Result) bool {
|
||||
key := k.String()
|
||||
if key != "type" && key != "text" && key != "cache_control" {
|
||||
@@ -1687,41 +1864,28 @@ func extractFunctionResultContent(step gjson.Result) (string, []helps.DevinImage
|
||||
}
|
||||
return true
|
||||
})
|
||||
}
|
||||
if isPureTextPart {
|
||||
if t := item.Get("text").String(); t != "" {
|
||||
textParts = append(textParts, t)
|
||||
}
|
||||
hasStructuredParts = true
|
||||
} else {
|
||||
if raw := strings.TrimSpace(item.Raw); raw != "" {
|
||||
textParts = append(textParts, raw)
|
||||
if isPureTextPart {
|
||||
hasStructuredParts = true
|
||||
if t := item.Get("text").String(); t != "" {
|
||||
textParts = append(textParts, t)
|
||||
}
|
||||
continue
|
||||
} else {
|
||||
if raw := strings.TrimSpace(item.Raw); raw != "" {
|
||||
textParts = append(textParts, raw)
|
||||
}
|
||||
}
|
||||
continue
|
||||
}
|
||||
} else {
|
||||
// Preserve unconsumed array items (e.g. arbitrary business JSON or string parts)
|
||||
if raw := strings.TrimSpace(item.Raw); raw != "" {
|
||||
textParts = append(textParts, raw)
|
||||
}
|
||||
}
|
||||
// Preserve unconsumed array items (e.g. arbitrary business JSON or string parts)
|
||||
if raw := strings.TrimSpace(item.Raw); raw != "" {
|
||||
textParts = append(textParts, raw)
|
||||
}
|
||||
}
|
||||
|
||||
if hasStructuredParts || len(images) > 0 {
|
||||
resText := strings.Join(textParts, "\n")
|
||||
if len(images) > 0 && !strings.Contains(resText, "[Image ") {
|
||||
var imgHeaders []string
|
||||
for i, img := range images {
|
||||
ext := mimeExtension(img.MimeType)
|
||||
imgHeaders = append(imgHeaders, fmt.Sprintf("[Image %d: pasted_image_%d.%s]", i+1, i+1, ext))
|
||||
}
|
||||
header := strings.Join(imgHeaders, "\n")
|
||||
if resText != "" {
|
||||
resText = header + "\n\n" + resText
|
||||
} else {
|
||||
resText = header
|
||||
}
|
||||
}
|
||||
return resText, images
|
||||
return strings.Join(textParts, "\n"), images
|
||||
}
|
||||
|
||||
return target.Raw, nil
|
||||
@@ -1730,6 +1894,41 @@ func extractFunctionResultContent(step gjson.Result) (string, []helps.DevinImage
|
||||
return target.Raw, nil
|
||||
}
|
||||
|
||||
func extractFunctionResultContent(step gjson.Result) (string, []helps.DevinImage) {
|
||||
target := step.Get("result")
|
||||
if !target.Exists() {
|
||||
target = step.Get("output")
|
||||
}
|
||||
if !target.Exists() {
|
||||
target = step.Get("content")
|
||||
}
|
||||
|
||||
if !target.Exists() {
|
||||
return devinEmptyToolResultPlaceholder, nil
|
||||
}
|
||||
|
||||
resText, images := extractFunctionResultTarget(target)
|
||||
if len(images) > 0 && !strings.Contains(resText, "[Image ") {
|
||||
var imgHeaders []string
|
||||
for i, img := range images {
|
||||
ext := mimeExtension(img.MimeType)
|
||||
imgHeaders = append(imgHeaders, fmt.Sprintf("[Image %d: pasted_image_%d.%s]", i+1, i+1, ext))
|
||||
}
|
||||
header := strings.Join(imgHeaders, "\n")
|
||||
if resText != "" {
|
||||
resText = header + "\n\n" + resText
|
||||
} else {
|
||||
resText = header
|
||||
}
|
||||
}
|
||||
|
||||
if strings.TrimSpace(resText) == "" && len(images) == 0 {
|
||||
resText = devinEmptyToolResultPlaceholder
|
||||
}
|
||||
|
||||
return resText, images
|
||||
}
|
||||
|
||||
func extractInteractionsStepContent(step gjson.Result) (string, []helps.DevinImage) {
|
||||
content := step.Get("content")
|
||||
var textParts []string
|
||||
@@ -1855,6 +2054,27 @@ func supplementImagesFromOriginal(original []byte, prompts []helps.DevinPrompt)
|
||||
userPromptIdx := 0
|
||||
for i := range prompts {
|
||||
if prompts[i].Source == 1 {
|
||||
if prompts[i].IsOrphanedTool {
|
||||
// Downgraded orphaned tool result; do not consume userImages from user messages
|
||||
if len(prompts[i].Images) == 0 && prompts[i].OriginalToolCallID != "" {
|
||||
if matchedImgs, ok := toolImagesByID[prompts[i].OriginalToolCallID]; ok && len(matchedImgs) > 0 {
|
||||
prompts[i].Images = matchedImgs
|
||||
if !strings.Contains(prompts[i].Content, "[Image ") {
|
||||
var imgHeaders []string
|
||||
for imgIdx, img := range prompts[i].Images {
|
||||
imgHeaders = append(imgHeaders, fmt.Sprintf("[Image %d: pasted_image_%d.%s]", imgIdx+1, imgIdx+1, mimeExtension(img.MimeType)))
|
||||
}
|
||||
header := strings.Join(imgHeaders, "\n")
|
||||
if prompts[i].Content != "" {
|
||||
prompts[i].Content = header + "\n\n" + prompts[i].Content
|
||||
} else {
|
||||
prompts[i].Content = header
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
continue
|
||||
}
|
||||
if len(prompts[i].Images) == 0 && userPromptIdx < len(userImages) && len(userImages[userPromptIdx]) > 0 {
|
||||
prompts[i].Images = userImages[userPromptIdx]
|
||||
if !strings.Contains(prompts[i].Content, "[Image ") {
|
||||
|
||||
@@ -3217,3 +3217,429 @@ func TestRegressionIssue5910_UsageStatsCacheWriteTokensInResponses(t *testing.T)
|
||||
t.Errorf("ParseInteractionsStreamUsage InputTokens = %d, want 53", sDetail.InputTokens)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDevinExecutor_FunctionResult_Regression_Issue5911(t *testing.T) {
|
||||
t.Run("structured_results_projected_to_plain_text", func(t *testing.T) {
|
||||
// Case 1: Array of text content blocks
|
||||
payloadBlocks := []byte(`{
|
||||
"model": "devin/swe-2",
|
||||
"input": [
|
||||
{"type": "function_call", "id": "call_1", "name": "read"},
|
||||
{
|
||||
"type": "function_result",
|
||||
"call_id": "call_1",
|
||||
"result": [
|
||||
{"type": "text", "text": "hello "},
|
||||
{"type": "text", "text": "world"}
|
||||
]
|
||||
}
|
||||
]
|
||||
}`)
|
||||
_, prompts1, _, _, _, _, _, _, _ := parseInteractionsPayload(payloadBlocks, nil)
|
||||
if len(prompts1) != 2 {
|
||||
t.Fatalf("expected 2 prompts, got %d", len(prompts1))
|
||||
}
|
||||
if prompts1[1].Source != 4 || prompts1[1].ToolCallID != "call_1" {
|
||||
t.Fatalf("expected tool prompt source=4 tool_call_id=call_1, got source=%d id=%s", prompts1[1].Source, prompts1[1].ToolCallID)
|
||||
}
|
||||
if strings.Contains(prompts1[1].Content, `[{"type":`) || strings.Contains(prompts1[1].Content, `"text":`) {
|
||||
t.Errorf("structured result has JSON scaffolding: %q", prompts1[1].Content)
|
||||
}
|
||||
if !strings.Contains(prompts1[1].Content, "hello") || !strings.Contains(prompts1[1].Content, "world") {
|
||||
t.Errorf("structured result missing extracted text: %q", prompts1[1].Content)
|
||||
}
|
||||
|
||||
// Case 2: Object with type=text and text field
|
||||
payloadObject := []byte(`{
|
||||
"model": "devin/swe-2",
|
||||
"input": [
|
||||
{"type": "function_call", "id": "call_2", "name": "read"},
|
||||
{
|
||||
"type": "function_result",
|
||||
"call_id": "call_2",
|
||||
"result": {"type": "text", "text": "single object output"}
|
||||
}
|
||||
]
|
||||
}`)
|
||||
_, prompts2, _, _, _, _, _, _, _ := parseInteractionsPayload(payloadObject, nil)
|
||||
if len(prompts2) != 2 {
|
||||
t.Fatalf("expected 2 prompts, got %d", len(prompts2))
|
||||
}
|
||||
if prompts2[1].Content != "single object output" {
|
||||
t.Errorf("expected plain text 'single object output', got %q", prompts2[1].Content)
|
||||
}
|
||||
|
||||
// Case 3: Object with nested content array
|
||||
payloadNested := []byte(`{
|
||||
"model": "devin/swe-2",
|
||||
"input": [
|
||||
{"type": "function_call", "id": "call_3", "name": "read"},
|
||||
{
|
||||
"type": "function_result",
|
||||
"call_id": "call_3",
|
||||
"result": {"content": [{"type": "text", "text": "nested content text"}]}
|
||||
}
|
||||
]
|
||||
}`)
|
||||
_, prompts3, _, _, _, _, _, _, _ := parseInteractionsPayload(payloadNested, nil)
|
||||
if len(prompts3) != 2 {
|
||||
t.Fatalf("expected 2 prompts, got %d", len(prompts3))
|
||||
}
|
||||
if prompts3[1].Content != "nested content text" {
|
||||
t.Errorf("expected plain text 'nested content text', got %q", prompts3[1].Content)
|
||||
}
|
||||
|
||||
// Case 4: Business object with output/result fields alongside business fields is preserved in full JSON
|
||||
payloadBusiness := []byte(`{
|
||||
"model": "devin/swe-2",
|
||||
"input": [
|
||||
{"type": "function_call", "id": "call_4", "name": "exec"},
|
||||
{
|
||||
"type": "function_result",
|
||||
"call_id": "call_4",
|
||||
"result": {"output": "permission denied", "exit_code": 1, "retryable": false}
|
||||
}
|
||||
]
|
||||
}`)
|
||||
_, prompts4, _, _, _, _, _, _, _ := parseInteractionsPayload(payloadBusiness, nil)
|
||||
if len(prompts4) != 2 {
|
||||
t.Fatalf("expected 2 prompts, got %d", len(prompts4))
|
||||
}
|
||||
if !strings.Contains(prompts4[1].Content, "permission denied") ||
|
||||
!strings.Contains(prompts4[1].Content, `"exit_code": 1`) ||
|
||||
!strings.Contains(prompts4[1].Content, `"retryable": false`) {
|
||||
t.Errorf("expected full business object to be preserved, got %q", prompts4[1].Content)
|
||||
}
|
||||
|
||||
// Case 5: Business object with id, name, content is preserved in full JSON
|
||||
payloadBusinessReport := []byte(`{
|
||||
"model": "devin/swe-2",
|
||||
"input": [
|
||||
{"type": "function_call", "id": "call_5", "name": "get_report"},
|
||||
{
|
||||
"type": "function_result",
|
||||
"call_id": "call_5",
|
||||
"result": {"id": 42, "name": "report", "content": "body"}
|
||||
}
|
||||
]
|
||||
}`)
|
||||
_, prompts5, _, _, _, _, _, _, _ := parseInteractionsPayload(payloadBusinessReport, nil)
|
||||
if len(prompts5) != 2 {
|
||||
t.Fatalf("expected 2 prompts, got %d", len(prompts5))
|
||||
}
|
||||
if !strings.Contains(prompts5[1].Content, `"id": 42`) && !strings.Contains(prompts5[1].Content, `"id":42`) ||
|
||||
!strings.Contains(prompts5[1].Content, `"name": "report"`) && !strings.Contains(prompts5[1].Content, `"name":"report"`) ||
|
||||
!strings.Contains(prompts5[1].Content, `"content": "body"`) && !strings.Contains(prompts5[1].Content, `"content":"body"`) {
|
||||
t.Errorf("expected full business report object to be preserved, got %q", prompts5[1].Content)
|
||||
}
|
||||
|
||||
// Case 6: Pure business string array is preserved as raw JSON
|
||||
payloadStringArray := []byte(`{
|
||||
"model": "devin/swe-2",
|
||||
"input": [
|
||||
{"type": "function_call", "id": "call_6", "name": "list"},
|
||||
{
|
||||
"type": "function_result",
|
||||
"call_id": "call_6",
|
||||
"result": ["a", "b"]
|
||||
}
|
||||
]
|
||||
}`)
|
||||
_, prompts6, _, _, _, _, _, _, _ := parseInteractionsPayload(payloadStringArray, nil)
|
||||
if len(prompts6) != 2 {
|
||||
t.Fatalf("expected 2 prompts, got %d", len(prompts6))
|
||||
}
|
||||
if !strings.Contains(prompts6[1].Content, `"a"`) || !strings.Contains(prompts6[1].Content, `"b"`) || !strings.Contains(prompts6[1].Content, `[`) {
|
||||
t.Errorf("expected string array to be preserved as raw JSON, got %q", prompts6[1].Content)
|
||||
}
|
||||
|
||||
// Case 7: Mixed string and business object array is preserved as raw JSON
|
||||
payloadMixedArray := []byte(`{
|
||||
"model": "devin/swe-2",
|
||||
"input": [
|
||||
{"type": "function_call", "id": "call_7", "name": "run"},
|
||||
{
|
||||
"type": "function_result",
|
||||
"call_id": "call_7",
|
||||
"result": ["ok", {"exit_code": 0}]
|
||||
}
|
||||
]
|
||||
}`)
|
||||
_, prompts7, _, _, _, _, _, _, _ := parseInteractionsPayload(payloadMixedArray, nil)
|
||||
if len(prompts7) != 2 {
|
||||
t.Fatalf("expected 2 prompts, got %d", len(prompts7))
|
||||
}
|
||||
if !strings.Contains(prompts7[1].Content, `"ok"`) || !strings.Contains(prompts7[1].Content, `"exit_code": 0`) {
|
||||
t.Errorf("expected mixed array to be preserved as raw JSON, got %q", prompts7[1].Content)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("empty_results_get_placeholder", func(t *testing.T) {
|
||||
// Case 1: Empty string result
|
||||
payloadEmptyStr := []byte(`{
|
||||
"model": "devin/swe-2",
|
||||
"input": [
|
||||
{"type": "function_call", "id": "call_1", "name": "noop"},
|
||||
{"type": "function_result", "call_id": "call_1", "result": ""}
|
||||
]
|
||||
}`)
|
||||
_, prompts1, _, _, _, _, _, _, _ := parseInteractionsPayload(payloadEmptyStr, nil)
|
||||
if len(prompts1) != 2 {
|
||||
t.Fatalf("expected 2 prompts, got %d", len(prompts1))
|
||||
}
|
||||
if prompts1[1].Content == "" {
|
||||
t.Errorf("expected non-empty placeholder for empty string result, got empty string")
|
||||
}
|
||||
|
||||
// Case 2: Absent result/output/content
|
||||
payloadAbsent := []byte(`{
|
||||
"model": "devin/swe-2",
|
||||
"input": [
|
||||
{"type": "function_call", "id": "call_2", "name": "noop"},
|
||||
{"type": "function_result", "call_id": "call_2"}
|
||||
]
|
||||
}`)
|
||||
_, prompts2, _, _, _, _, _, _, _ := parseInteractionsPayload(payloadAbsent, nil)
|
||||
if len(prompts2) != 2 {
|
||||
t.Fatalf("expected 2 prompts, got %d", len(prompts2))
|
||||
}
|
||||
if prompts2[1].Content == "" {
|
||||
t.Errorf("expected non-empty placeholder for absent result, got empty string")
|
||||
}
|
||||
|
||||
// Case 3: Array with empty wrapper content block
|
||||
payloadEmptyWrapper := []byte(`{
|
||||
"model": "devin/swe-2",
|
||||
"input": [
|
||||
{"type": "function_call", "id": "call_3", "name": "noop"},
|
||||
{"type": "function_result", "call_id": "call_3", "result": [{"type": "tool_result", "content": ""}]}
|
||||
]
|
||||
}`)
|
||||
_, prompts3, _, _, _, _, _, _, _ := parseInteractionsPayload(payloadEmptyWrapper, nil)
|
||||
if len(prompts3) != 2 {
|
||||
t.Fatalf("expected 2 prompts, got %d", len(prompts3))
|
||||
}
|
||||
if prompts3[1].Content != "{}" {
|
||||
t.Errorf("expected placeholder '{}' for empty wrapper result, got %q", prompts3[1].Content)
|
||||
}
|
||||
|
||||
// Case 4: Array with whitespace-only content
|
||||
payloadWhitespaceWrapper := []byte(`{
|
||||
"model": "devin/swe-2",
|
||||
"input": [
|
||||
{"type": "function_call", "id": "call_4", "name": "noop"},
|
||||
{"type": "function_result", "call_id": "call_4", "result": [{"type": "text", "text": " "}]}
|
||||
]
|
||||
}`)
|
||||
_, prompts4, _, _, _, _, _, _, _ := parseInteractionsPayload(payloadWhitespaceWrapper, nil)
|
||||
if len(prompts4) != 2 {
|
||||
t.Fatalf("expected 2 prompts, got %d", len(prompts4))
|
||||
}
|
||||
if prompts4[1].Content != "{}" {
|
||||
t.Errorf("expected placeholder '{}' for whitespace wrapper result, got %q", prompts4[1].Content)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("orphaned_results_sent_as_user_text", func(t *testing.T) {
|
||||
// Case 1: Interactions payload with trimmed function_call
|
||||
payloadTrimmed := []byte(`{
|
||||
"model": "devin/swe-2",
|
||||
"input": [
|
||||
{"type": "function_result", "call_id": "call_trimmed", "result": "orphaned data"}
|
||||
]
|
||||
}`)
|
||||
_, prompts1, _, _, _, _, _, _, _ := parseInteractionsPayload(payloadTrimmed, nil)
|
||||
if len(prompts1) != 1 {
|
||||
t.Fatalf("expected 1 prompt, got %d", len(prompts1))
|
||||
}
|
||||
if prompts1[0].Source != 1 {
|
||||
t.Errorf("expected orphaned function_result to be source=1 (user text), got source=%d", prompts1[0].Source)
|
||||
}
|
||||
if prompts1[0].Content != "orphaned data" {
|
||||
t.Errorf("expected content 'orphaned data', got %q", prompts1[0].Content)
|
||||
}
|
||||
|
||||
// Case 2: Messages fallback with assistant.tool_calls properly matched
|
||||
payloadMessagesMatched := []byte(`{
|
||||
"model": "devin/swe-2",
|
||||
"messages": [
|
||||
{
|
||||
"role": "assistant",
|
||||
"content": "",
|
||||
"tool_calls": [
|
||||
{
|
||||
"id": "call_msg_1",
|
||||
"type": "function",
|
||||
"function": {"name": "read_file", "arguments": "{\"path\":\"a.txt\"}"}
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"role": "tool",
|
||||
"tool_call_id": "call_msg_1",
|
||||
"content": "file content here"
|
||||
}
|
||||
]
|
||||
}`)
|
||||
_, prompts2, _, _, _, _, _, _, _ := parseInteractionsPayload(payloadMessagesMatched, nil)
|
||||
if len(prompts2) != 2 {
|
||||
t.Fatalf("expected 2 prompts, got %d", len(prompts2))
|
||||
}
|
||||
if prompts2[0].Source != 2 || len(prompts2[0].ToolCalls) != 1 || prompts2[0].ToolCalls[0].ID != "call_msg_1" {
|
||||
t.Errorf("assistant tool_calls not parsed: %+v", prompts2[0])
|
||||
}
|
||||
if prompts2[0].ToolCalls[0].Arguments != `{"path":"a.txt"}` {
|
||||
t.Errorf("expected tool arguments %q, got %q", `{"path":"a.txt"}`, prompts2[0].ToolCalls[0].Arguments)
|
||||
}
|
||||
if prompts2[1].Source != 4 || prompts2[1].ToolCallID != "call_msg_1" {
|
||||
t.Errorf("expected tool prompt source=4 tool_call_id=call_msg_1, got source=%d id=%s", prompts2[1].Source, prompts2[1].ToolCallID)
|
||||
}
|
||||
if prompts2[1].Content != "file content here" {
|
||||
t.Errorf("expected content 'file content here', got %q", prompts2[1].Content)
|
||||
}
|
||||
|
||||
// Case 3: Messages fallback with orphaned tool message
|
||||
payloadMessagesOrphan := []byte(`{
|
||||
"model": "devin/swe-2",
|
||||
"messages": [
|
||||
{
|
||||
"role": "tool",
|
||||
"tool_call_id": "call_orphan",
|
||||
"content": "orphaned tool message"
|
||||
}
|
||||
]
|
||||
}`)
|
||||
_, prompts3, _, _, _, _, _, _, _ := parseInteractionsPayload(payloadMessagesOrphan, nil)
|
||||
if len(prompts3) != 1 {
|
||||
t.Fatalf("expected 1 prompt, got %d", len(prompts3))
|
||||
}
|
||||
if prompts3[0].Source != 1 {
|
||||
t.Errorf("expected orphaned tool message to be source=1 (user text), got source=%d", prompts3[0].Source)
|
||||
}
|
||||
if prompts3[0].Content != "orphaned tool message" {
|
||||
t.Errorf("expected content 'orphaned tool message', got %q", prompts3[0].Content)
|
||||
}
|
||||
|
||||
// Case 4: Multiple calls and results pairing with extra orphan result
|
||||
payloadMulti := []byte(`{
|
||||
"model": "devin/swe-2",
|
||||
"input": [
|
||||
{"type": "function_call", "id": "call_A", "name": "f1"},
|
||||
{"type": "function_call", "id": "call_B", "name": "f2"},
|
||||
{"type": "function_result", "call_id": "call_A", "result": "res_A"},
|
||||
{"type": "function_result", "call_id": "call_B", "result": "res_B"},
|
||||
{"type": "function_result", "call_id": "call_C", "result": "res_C_extra"}
|
||||
]
|
||||
}`)
|
||||
_, prompts4, _, _, _, _, _, _, _ := parseInteractionsPayload(payloadMulti, nil)
|
||||
if len(prompts4) != 4 {
|
||||
t.Fatalf("expected 4 prompts (1 assistant with 2 calls, 2 tool results, 1 user text orphan), got %d", len(prompts4))
|
||||
}
|
||||
if prompts4[1].Source != 4 || prompts4[1].ToolCallID != "call_A" || prompts4[1].Content != "res_A" {
|
||||
t.Errorf("call_A prompt mismatch: %+v", prompts4[1])
|
||||
}
|
||||
if prompts4[2].Source != 4 || prompts4[2].ToolCallID != "call_B" || prompts4[2].Content != "res_B" {
|
||||
t.Errorf("call_B prompt mismatch: %+v", prompts4[2])
|
||||
}
|
||||
if prompts4[3].Source != 1 || prompts4[3].Content != "res_C_extra" {
|
||||
t.Errorf("expected extra call_C to be source=1 user text, got: %+v", prompts4[3])
|
||||
}
|
||||
|
||||
// Case 5: Orphaned tool result does not steal subsequent user message's image in supplementImagesFromOriginal
|
||||
origWithUserImg := []byte(`{
|
||||
"messages": [
|
||||
{
|
||||
"role": "tool",
|
||||
"tool_call_id": "call_orphan_img",
|
||||
"content": "text only orphan"
|
||||
},
|
||||
{
|
||||
"role": "user",
|
||||
"content": [
|
||||
{"type": "text", "text": "user message with picture"},
|
||||
{
|
||||
"type": "image",
|
||||
"source": {
|
||||
"type": "base64",
|
||||
"media_type": "image/png",
|
||||
"data": "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg=="
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}`)
|
||||
interactionsOrphanAndUser := []byte(`{
|
||||
"model": "devin/swe-2",
|
||||
"input": [
|
||||
{"type": "function_result", "call_id": "call_orphan_img", "result": "text only orphan"},
|
||||
{"type": "user_input", "content": [{"type": "text", "text": "user message with picture"}]}
|
||||
]
|
||||
}`)
|
||||
_, prompts5, _, _, _, _, _, _, _ := parseInteractionsPayload(interactionsOrphanAndUser, origWithUserImg)
|
||||
if len(prompts5) != 2 {
|
||||
t.Fatalf("expected 2 prompts, got %d", len(prompts5))
|
||||
}
|
||||
// Prompt 0 is downgraded orphaned tool result
|
||||
if prompts5[0].Source != 1 || prompts5[0].OriginalToolCallID != "call_orphan_img" {
|
||||
t.Errorf("prompts5[0] expected source=1 orphaned tool, got: %+v", prompts5[0])
|
||||
}
|
||||
if len(prompts5[0].Images) != 0 {
|
||||
t.Errorf("orphaned tool result should not have stolen user images, got %d images", len(prompts5[0].Images))
|
||||
}
|
||||
// Prompt 1 is real user turn
|
||||
if prompts5[1].Source != 1 {
|
||||
t.Errorf("prompts5[1] expected source=1 user turn, got: %+v", prompts5[1])
|
||||
}
|
||||
if len(prompts5[1].Images) != 1 {
|
||||
t.Errorf("user turn should have received 1 image from originalRequest, got %d", len(prompts5[1].Images))
|
||||
}
|
||||
if !strings.Contains(prompts5[1].Content, "[Image 1: pasted_image_1.png]") {
|
||||
t.Errorf("user turn content missing image header: %q", prompts5[1].Content)
|
||||
}
|
||||
|
||||
// Case 6: Orphaned tool result without any ID does not steal subsequent user message's image
|
||||
origWithNoID := []byte(`{
|
||||
"messages": [
|
||||
{
|
||||
"role": "tool",
|
||||
"content": "no id orphan text"
|
||||
},
|
||||
{
|
||||
"role": "user",
|
||||
"content": [
|
||||
{"type": "text", "text": "user message"},
|
||||
{
|
||||
"type": "image",
|
||||
"source": {
|
||||
"type": "base64",
|
||||
"media_type": "image/png",
|
||||
"data": "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg=="
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}`)
|
||||
interactionsNoID := []byte(`{
|
||||
"model": "devin/swe-2",
|
||||
"input": [
|
||||
{"type": "function_result", "result": "no id orphan text"},
|
||||
{"type": "user_input", "content": [{"type": "text", "text": "user message"}]}
|
||||
]
|
||||
}`)
|
||||
_, prompts6, _, _, _, _, _, _, _ := parseInteractionsPayload(interactionsNoID, origWithNoID)
|
||||
if len(prompts6) != 2 {
|
||||
t.Fatalf("expected 2 prompts, got %d", len(prompts6))
|
||||
}
|
||||
if prompts6[0].Source != 1 || !prompts6[0].IsOrphanedTool {
|
||||
t.Errorf("expected prompt 0 to be marked orphaned tool, got: %+v", prompts6[0])
|
||||
}
|
||||
if len(prompts6[0].Images) != 0 {
|
||||
t.Errorf("orphaned tool result with no ID stole user image: %+v", prompts6[0].Images)
|
||||
}
|
||||
if len(prompts6[1].Images) != 1 {
|
||||
t.Errorf("user message should have 1 image, got %d", len(prompts6[1].Images))
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -84,15 +84,17 @@ type DevinImage struct {
|
||||
|
||||
// DevinPrompt represents a single turn in the request history (repeated Field 3).
|
||||
type DevinPrompt struct {
|
||||
MessageID string
|
||||
Source int // 1=user, 2=assistant, 4=tool
|
||||
Content string
|
||||
Images []DevinImage
|
||||
ToolCalls []DevinToolCall
|
||||
ToolCallID string // For source=4 (tool result)
|
||||
Thinking string
|
||||
Signature []byte
|
||||
SignatureType string
|
||||
MessageID string
|
||||
Source int // 1=user, 2=assistant, 4=tool
|
||||
Content string
|
||||
Images []DevinImage
|
||||
ToolCalls []DevinToolCall
|
||||
ToolCallID string // For source=4 (tool result)
|
||||
OriginalToolCallID string // Retained when downgraded from source=4 to source=1
|
||||
IsOrphanedTool bool // Explicit flag marking downgraded tool results
|
||||
Thinking string
|
||||
Signature []byte
|
||||
SignatureType string
|
||||
}
|
||||
|
||||
// DevinUsage captures token accounting from response Field 7.
|
||||
|
||||
Reference in New Issue
Block a user