chore(agent): move filter logic into a function

Christian Rocha created

Change summary

internal/agent/agent.go | 58 +++++++++++++++++++++++++-----------------
1 file changed, 34 insertions(+), 24 deletions(-)

Detailed changes

internal/agent/agent.go 🔗

@@ -792,30 +792,7 @@ If not, please feel free to ignore. Again do not mention this message to the use
 			continue
 		}
 		if m.Role == message.Tool {
-			// Filter out tool results that have no matching tool call. An orphaned
-			// result causes every subsequent API call to fail validation.
-			aiMsgs := m.ToAIMessage()
-			if len(aiMsgs) == 0 {
-				continue
-			}
-			var validParts []fantasy.MessagePart
-			for _, part := range aiMsgs[0].Content {
-				tr, ok := fantasy.AsMessagePart[fantasy.ToolResultPart](part)
-				if !ok {
-					validParts = append(validParts, part)
-					continue
-				}
-				if _, known := knownToolCallIDs[tr.ToolCallID]; known {
-					validParts = append(validParts, part)
-				} else {
-					slog.Warn("Dropping orphaned tool result with no matching tool call",
-						"tool_call_id", tr.ToolCallID,
-					)
-				}
-			}
-			if len(validParts) > 0 {
-				msg := aiMsgs[0]
-				msg.Content = validParts
+			if msg, ok := filterOrphanedToolResults(m, knownToolCallIDs); ok {
 				history = append(history, msg)
 			}
 			continue
@@ -838,6 +815,39 @@ If not, please feel free to ignore. Again do not mention this message to the use
 	return history, files
 }
 
+// filterOrphanedToolResults converts a tool message to a fantasy.Message,
+// dropping any tool result parts whose tool_call_id has no matching tool call
+// in the known set. An orphaned result causes API validation to fail on every
+// subsequent turn, permanently locking the session. Returns the filtered
+// message and true if at least one valid part remains.
+func filterOrphanedToolResults(m message.Message, knownToolCallIDs map[string]struct{}) (fantasy.Message, bool) {
+	aiMsgs := m.ToAIMessage()
+	if len(aiMsgs) == 0 {
+		return fantasy.Message{}, false
+	}
+	var validParts []fantasy.MessagePart
+	for _, part := range aiMsgs[0].Content {
+		tr, ok := fantasy.AsMessagePart[fantasy.ToolResultPart](part)
+		if !ok {
+			validParts = append(validParts, part)
+			continue
+		}
+		if _, known := knownToolCallIDs[tr.ToolCallID]; known {
+			validParts = append(validParts, part)
+		} else {
+			slog.Warn("Dropping orphaned tool result with no matching tool call",
+				"tool_call_id", tr.ToolCallID,
+			)
+		}
+	}
+	if len(validParts) == 0 {
+		return fantasy.Message{}, false
+	}
+	msg := aiMsgs[0]
+	msg.Content = validParts
+	return msg, true
+}
+
 func (a *sessionAgent) getSessionMessages(ctx context.Context, session session.Session) ([]message.Message, error) {
 	msgs, err := a.messages.List(ctx, session.ID)
 	if err != nil {