diff --git a/internal/runtime/executor/devin_executor.go b/internal/runtime/executor/devin_executor.go index e82ed4865..3d34afa76 100644 --- a/internal/runtime/executor/devin_executor.go +++ b/internal/runtime/executor/devin_executor.go @@ -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 ") { diff --git a/internal/runtime/executor/devin_executor_test.go b/internal/runtime/executor/devin_executor_test.go index 020ec4147..0150c76c4 100644 --- a/internal/runtime/executor/devin_executor_test.go +++ b/internal/runtime/executor/devin_executor_test.go @@ -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)) + } + }) +} diff --git a/internal/runtime/executor/helps/devin_wire.go b/internal/runtime/executor/helps/devin_wire.go index 3a6356fb5..b4f4872e2 100644 --- a/internal/runtime/executor/helps/devin_wire.go +++ b/internal/runtime/executor/helps/devin_wire.go @@ -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.