diff --git a/clients/macos/vellum-assistant/Features/Chat/UsedToolsList.swift b/clients/macos/vellum-assistant/Features/Chat/UsedToolsList.swift index a3cd003905e..bbdf43d5f1c 100644 --- a/clients/macos/vellum-assistant/Features/Chat/UsedToolsList.swift +++ b/clients/macos/vellum-assistant/Features/Chat/UsedToolsList.swift @@ -317,6 +317,11 @@ private struct UsedToolsRow: View { } } .animation(VAnimation.fast, value: isExpanded) + .onChange(of: toolCall.inputFull) { _ in + // Invalidate the cached formatted input so the next render picks up + // the fresh (rehydrated) value instead of the stale truncated one. + cachedInputFull = nil + } } /// Open the image in Preview.app. Tries the original file path first; falls diff --git a/clients/shared/Features/Chat/ChatViewModel.swift b/clients/shared/Features/Chat/ChatViewModel.swift index e8d2fd0eafa..aa281f4bc0f 100644 --- a/clients/shared/Features/Chat/ChatViewModel.swift +++ b/clients/shared/Features/Chat/ChatViewModel.swift @@ -501,22 +501,45 @@ public final class ChatViewModel: ObservableObject { public func handleMessageContentResponse(_ response: IPCMessageContentResponse) { guard let idx = messages.firstIndex(where: { $0.daemonMessageId == response.messageId }) else { return } - // Update text with full content + // Update text with full content. When collapsing multiple text segments + // into one, also update contentOrder so stale .text(N>0) references are + // removed — otherwise interleaved content orders become invalid. if let fullText = response.text { messages[idx].textSegments = fullText.isEmpty ? [] : [fullText] + if !messages[idx].contentOrder.isEmpty { + var seenText = false + messages[idx].contentOrder = messages[idx].contentOrder.compactMap { entry in + if case .text = entry { + if seenText { return nil } + seenText = true + return .text(0) + } + return entry + } + } } - // Update tool call results with full content + // Update tool call results with full content. + // Use positional matching first — when a message has multiple tool calls + // with the same name (e.g. two `bash` calls), name-based lookup always + // overwrites the first match. Fall back to name-based only when the + // positional index is out of bounds or the name doesn't match. if let fullToolCalls = response.toolCalls { - for fullTC in fullToolCalls { - if let tcIdx = messages[idx].toolCalls.firstIndex(where: { $0.toolName == fullTC.name }) { - if let result = fullTC.result { - messages[idx].toolCalls[tcIdx].result = result - } - if let input = fullTC.input { - messages[idx].toolCalls[tcIdx].inputFull = ToolCallData.formatAllToolInput(input) - messages[idx].toolCalls[tcIdx].inputRawDict = input - } + for (i, fullTC) in fullToolCalls.enumerated() { + let tcIdx: Int + if i < messages[idx].toolCalls.count && messages[idx].toolCalls[i].toolName == fullTC.name { + tcIdx = i + } else if let fallback = messages[idx].toolCalls.firstIndex(where: { $0.toolName == fullTC.name }) { + tcIdx = fallback + } else { + continue + } + if let result = fullTC.result { + messages[idx].toolCalls[tcIdx].result = result + } + if let input = fullTC.input { + messages[idx].toolCalls[tcIdx].inputFull = ToolCallData.formatAllToolInput(input) + messages[idx].toolCalls[tcIdx].inputRawDict = input } } } diff --git a/clients/shared/Features/Chat/ToolCallChip.swift b/clients/shared/Features/Chat/ToolCallChip.swift index 3d0d89feee1..8177a49bb7f 100644 --- a/clients/shared/Features/Chat/ToolCallChip.swift +++ b/clients/shared/Features/Chat/ToolCallChip.swift @@ -200,6 +200,11 @@ public struct ToolCallChip: View { ? VColor.error.opacity(0.3) : VColor.surfaceBorder.opacity(0.5), lineWidth: 0.5) ) + .onChange(of: toolCall.inputFull) { _ in + // Invalidate the cached formatted input so the next render picks up + // the fresh (rehydrated) value instead of the stale truncated one. + cachedInputFull = nil + } } }