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:
Luis Pater
2026-09-18 01:34:38 +08:00
parent 9e10db53ad
commit b6fe4f20c4
3 changed files with 714 additions and 66 deletions

View File

@@ -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 ") {

View File

@@ -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))
}
})
}

View File

@@ -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.