diff --git a/frontend/src/views/ServerDetail.vue b/frontend/src/views/ServerDetail.vue
index 4f3ea964f..1aaf60deb 100644
--- a/frontend/src/views/ServerDetail.vue
+++ b/frontend/src/views/ServerDetail.vue
@@ -347,15 +347,29 @@
{{ tool.status }}
-
-
-
- {{ part.text }}
- {{ part.text }}
- {{ part.text }}
-
+
{{ tool.description }}
+
+
+
+
Before (approved)
+
+
+ {{ part.text }}
+ {{ part.text }}
+
+
+
+
+
After (current)
+
+
+ {{ part.text }}
+ {{ part.text }}
+
+
@@ -952,18 +966,18 @@ interface DiffPart {
text: string
}
-function computeWordDiff(oldText: string, newText: string): DiffPart[] {
- const oldWords = oldText.split(/(\s+)/)
- const newWords = newText.split(/(\s+)/)
+/// Generic LCS over arrays of strings (works for word tokens or single chars).
+function lcsDiff(oldElems: string[], newElems: string[]): DiffPart[] {
+ const m = oldElems.length
+ const n = newElems.length
+ if (m === 0 && n === 0) return []
+ if (m === 0) return newElems.map(t => ({ type: 'added', text: t }))
+ if (n === 0) return oldElems.map(t => ({ type: 'removed', text: t }))
- // Longest Common Subsequence to find matching words
- const m = oldWords.length
- const n = newWords.length
const dp: number[][] = Array.from({ length: m + 1 }, () => Array(n + 1).fill(0))
-
for (let i = 1; i <= m; i++) {
for (let j = 1; j <= n; j++) {
- if (oldWords[i - 1] === newWords[j - 1]) {
+ if (oldElems[i - 1] === newElems[j - 1]) {
dp[i][j] = dp[i - 1][j - 1] + 1
} else {
dp[i][j] = Math.max(dp[i - 1][j], dp[i][j - 1])
@@ -971,38 +985,75 @@ function computeWordDiff(oldText: string, newText: string): DiffPart[] {
}
}
- // Backtrack to build diff
- const parts: DiffPart[] = []
+ const out: DiffPart[] = []
let i = m, j = n
- const stack: DiffPart[] = []
-
while (i > 0 || j > 0) {
- if (i > 0 && j > 0 && oldWords[i - 1] === newWords[j - 1]) {
- stack.push({ type: 'same', text: oldWords[i - 1] })
- i--
- j--
+ if (i > 0 && j > 0 && oldElems[i - 1] === newElems[j - 1]) {
+ out.push({ type: 'same', text: oldElems[i - 1] })
+ i--; j--
} else if (j > 0 && (i === 0 || dp[i][j - 1] >= dp[i - 1][j])) {
- stack.push({ type: 'added', text: newWords[j - 1] })
+ out.push({ type: 'added', text: newElems[j - 1] })
j--
} else {
- stack.push({ type: 'removed', text: oldWords[i - 1] })
+ out.push({ type: 'removed', text: oldElems[i - 1] })
i--
}
}
+ return out.reverse()
+}
- // Reverse since we built from end to start
- stack.reverse()
+/// Char-level diff for short strings, with a safety cap on input length
+/// to keep the O(N×M) dp table bounded.
+function characterLevelDiff(oldText: string, newText: string, maxChars = 1500): DiffPart[] {
+ if (oldText.length > maxChars || newText.length > maxChars) {
+ return [
+ { type: 'removed', text: oldText },
+ { type: 'added', text: newText },
+ ]
+ }
+ return lcsDiff(Array.from(oldText), Array.from(newText))
+}
- // Merge consecutive parts of the same type
- for (const part of stack) {
- if (parts.length > 0 && parts[parts.length - 1].type === part.type) {
- parts[parts.length - 1].text += part.text
+function mergeSameKind(parts: DiffPart[]): DiffPart[] {
+ const out: DiffPart[] = []
+ for (const p of parts) {
+ const last = out[out.length - 1]
+ if (last && last.type === p.type) {
+ last.text += p.text
} else {
- parts.push({ ...part })
+ out.push({ ...p })
}
}
+ return out
+}
- return parts
+/// Word-level diff with character-level refinement inside adjacent
+/// (removed, added) pairs. Keeps whole-token highlights for large docstring
+/// expansions while narrowing substring changes like "1 April" → "8 April"
+/// down to just the differing characters.
+function computeWordDiff(oldText: string, newText: string): DiffPart[] {
+ const oldWords = oldText.split(/(\s+)/).filter(t => t.length > 0)
+ const newWords = newText.split(/(\s+)/).filter(t => t.length > 0)
+ const wordDiff = mergeSameKind(lcsDiff(oldWords, newWords))
+
+ const refined: DiffPart[] = []
+ for (let idx = 0; idx < wordDiff.length; idx++) {
+ const current = wordDiff[idx]
+ const next = wordDiff[idx + 1]
+ if (
+ next &&
+ ((current.type === 'removed' && next.type === 'added') ||
+ (current.type === 'added' && next.type === 'removed'))
+ ) {
+ const removedText = current.type === 'removed' ? current.text : next.text
+ const addedText = current.type === 'added' ? current.text : next.text
+ refined.push(...characterLevelDiff(removedText, addedText))
+ idx++ // skip the paired part
+ continue
+ }
+ refined.push(current)
+ }
+ return mergeSameKind(refined)
}
// Methods
diff --git a/internal/runtime/runtime.go b/internal/runtime/runtime.go
index a80a1789f..894cec94b 100644
--- a/internal/runtime/runtime.go
+++ b/internal/runtime/runtime.go
@@ -1103,6 +1103,15 @@ func (r *Runtime) GetRecentSessions(limit int) ([]*contracts.MCPSession, int, er
})
}
+ // Stable order for UI consumers: most-recently-active first, break ties
+ // by session ID so the list doesn't reshuffle on identical timestamps.
+ sort.SliceStable(sessions, func(i, j int) bool {
+ if !sessions[i].LastActivity.Equal(sessions[j].LastActivity) {
+ return sessions[i].LastActivity.After(sessions[j].LastActivity)
+ }
+ return sessions[i].ID < sessions[j].ID
+ })
+
return sessions, total, nil
}
@@ -1797,6 +1806,16 @@ func (r *Runtime) GetAllServers() ([]map[string]interface{}, error) {
result = append(result, serverMap)
}
+ // Stable alphabetical order by name — StateView is backed by a map, so
+ // iteration order is non-deterministic. UI consumers (tray, web) expect
+ // a stable list so the "Servers Needing Attention" and similar filtered
+ // views don't shuffle between polls.
+ sort.SliceStable(result, func(i, j int) bool {
+ ni, _ := result[i]["name"].(string)
+ nj, _ := result[j]["name"].(string)
+ return ni < nj
+ })
+
r.logger.Debug("GetAllServers completed", zap.Int("server_count", len(result)))
return result, nil
}
diff --git a/native/macos/MCPProxy/MCPProxy/Views/ActivityView.swift b/native/macos/MCPProxy/MCPProxy/Views/ActivityView.swift
index b40910ed7..263dcce7c 100644
--- a/native/macos/MCPProxy/MCPProxy/Views/ActivityView.swift
+++ b/native/macos/MCPProxy/MCPProxy/Views/ActivityView.swift
@@ -87,16 +87,18 @@ struct ActivityView: View {
}
// MARK: - Column widths
- private let colTime: CGFloat = 60
- private let colType: CGFloat = 100
- private let colServer: CGFloat = 100
+ // Kept tight so that with the detail panel open the list can still coexist
+ // with the outer NavigationSplitView sidebar at the default 800pt window width.
+ private let colTime: CGFloat = 56
+ private let colType: CGFloat = 92
+ private let colServer: CGFloat = 96
private let colDetails: CGFloat = 0 // flexible (minWidth enforced in layout)
- private let colIntent: CGFloat = 60
- private let colStatus: CGFloat = 70
- private let colDuration: CGFloat = 60
+ private let colIntent: CGFloat = 52
+ private let colStatus: CGFloat = 64
+ private let colDuration: CGFloat = 56
var body: some View {
- HSplitView {
+ HStack(spacing: 0) {
// Left: activity table with filters
VStack(alignment: .leading, spacing: 0) {
activityListHeader
@@ -145,17 +147,23 @@ struct ActivityView: View {
}
}
}
- .frame(minWidth: 560)
+ .frame(maxWidth: .infinity, maxHeight: .infinity)
- // Right: detail panel
+ // Right: detail panel (only rendered when an entry is selected)
+ // NOTE: avoid HSplitView here — it fights the outer NavigationSplitView's
+ // sidebar column for width and can cause the app sidebar to collapse when
+ // the detail panel appears. A plain HStack with a fixed-width detail column
+ // keeps the sidebar stable.
if let selectedID = selectedActivityID,
let selected = activities.first(where: { $0.id == selectedID }) {
- ActivityDetailView(entry: selected, recentSessions: appState.recentSessions)
- } else {
- Text("Select an activity entry")
- .font(.scaled(.body, scale: fontScale))
- .foregroundStyle(.secondary)
- .frame(maxWidth: .infinity, maxHeight: .infinity)
+ Divider()
+ ActivityDetailView(
+ entry: selected,
+ recentSessions: appState.recentSessions,
+ onDismiss: { selectedActivityID = nil }
+ )
+ .frame(minWidth: 300, idealWidth: 380, maxWidth: 520, maxHeight: .infinity)
+ .layoutPriority(1)
}
}
.task {
@@ -184,7 +192,7 @@ struct ActivityView: View {
.frame(width: colServer, alignment: .leading)
Text("Details")
.lineLimit(1)
- .frame(minWidth: 80, maxWidth: .infinity, alignment: .leading)
+ .frame(minWidth: 60, maxWidth: .infinity, alignment: .leading)
Text("Intent")
.frame(width: colIntent, alignment: .center)
Text("Status")
@@ -489,7 +497,7 @@ struct ActivityTableRow: View {
.accessibilityLabel("Contains sensitive data")
}
}
- .frame(minWidth: 80, maxWidth: .infinity, alignment: .leading)
+ .frame(minWidth: 60, maxWidth: .infinity, alignment: .leading)
// Intent column
if let op = entry.intentOperationType {
@@ -695,6 +703,7 @@ struct IntentBadge: View {
struct ActivityDetailView: View {
let entry: ActivityEntry
var recentSessions: [APIClient.MCPSession] = []
+ var onDismiss: (() -> Void)? = nil
@Environment(\.fontScale) var fontScale
@State private var copiedField: String?
@@ -830,6 +839,18 @@ struct ActivityDetailView: View {
}
}
Spacer()
+ if let onDismiss = onDismiss {
+ Button {
+ onDismiss()
+ } label: {
+ Image(systemName: "xmark.circle.fill")
+ .foregroundStyle(.secondary)
+ }
+ .buttonStyle(.borderless)
+ .help("Close detail panel")
+ .accessibilityLabel("Close detail panel")
+ .accessibilityIdentifier("activity-detail-close")
+ }
}
}
diff --git a/native/macos/MCPProxy/MCPProxy/Views/DashboardView.swift b/native/macos/MCPProxy/MCPProxy/Views/DashboardView.swift
index 3e0a9c714..aeae963ff 100644
--- a/native/macos/MCPProxy/MCPProxy/Views/DashboardView.swift
+++ b/native/macos/MCPProxy/MCPProxy/Views/DashboardView.swift
@@ -41,7 +41,7 @@ struct DashboardView: View {
recentSessionsSection
// Recent tool calls table
- recentToolCallsSection
+ recentActivitySection
}
.padding(20)
}
@@ -486,8 +486,19 @@ struct DashboardView: View {
byClient[key] = s
}
}
- return byClient.values
- .sorted { ($0.toolCallCount ?? 0) > ($1.toolCallCount ?? 0) }
+ // Sort most-recently-active first, break ties by tool call count,
+ // then by session ID so the order is deterministic when timestamps
+ // and call counts match. `.values` iteration is random, so an
+ // explicit sort key is required to keep the UI stable.
+ return byClient.values.sorted { lhs, rhs in
+ let lTime = lhs.lastActive ?? lhs.startTime ?? ""
+ let rTime = rhs.lastActive ?? rhs.startTime ?? ""
+ if lTime != rTime { return lTime > rTime }
+ let lCalls = lhs.toolCallCount ?? 0
+ let rCalls = rhs.toolCallCount ?? 0
+ if lCalls != rCalls { return lCalls > rCalls }
+ return lhs.id < rhs.id
+ }
}()
if sessions.isEmpty {
HStack {
@@ -558,16 +569,28 @@ struct DashboardView: View {
}
}
- // MARK: - Recent Tool Calls
+ // MARK: - Recent Activity
+ //
+ // Shows the full activity log on the dashboard (not just tool calls) so
+ // users with a quiet proxy still see something useful — security scans,
+ // tool quarantine changes, OAuth events, etc. System-level events
+ // (`system_start`, `system_stop`) are filtered out because they're noise
+ // on the dashboard.
+
+ private static let activityDashboardHiddenTypes: Set
= [
+ "system_start", "system_stop",
+ ]
@ViewBuilder
- private var recentToolCallsSection: some View {
- let toolCalls = appState.recentActivity.filter { $0.type == "tool_call" || $0.type == "internal_tool_call" }
- let recent = Array(toolCalls.prefix(10))
+ private var recentActivitySection: some View {
+ let entries = appState.recentActivity.filter { entry in
+ !Self.activityDashboardHiddenTypes.contains(entry.type)
+ }
+ let recent = Array(entries.prefix(10))
VStack(alignment: .leading, spacing: 8) {
HStack {
- Label("Recent Tool Calls", systemImage: "wrench.and.screwdriver")
+ Label("Recent Activity", systemImage: "clock.arrow.circlepath")
.font(.scaled(.headline, scale: fontScale))
Spacer()
}
@@ -575,7 +598,7 @@ struct DashboardView: View {
if recent.isEmpty {
HStack {
Spacer()
- Text("No tool calls recorded")
+ Text("No activity recorded")
.font(.scaled(.body, scale: fontScale))
.foregroundStyle(.secondary)
.padding(.vertical, 20)
@@ -591,7 +614,7 @@ struct DashboardView: View {
.frame(width: 70, alignment: .leading)
Text("Server")
.frame(width: 120, alignment: .leading)
- Text("Tool")
+ Text("Event")
.frame(maxWidth: .infinity, alignment: .leading)
Text("Status")
.frame(width: 80, alignment: .center)
diff --git a/native/macos/MCPProxy/MCPProxy/Views/ServerDetailView.swift b/native/macos/MCPProxy/MCPProxy/Views/ServerDetailView.swift
index 3359c9c8f..0c079dd9d 100644
--- a/native/macos/MCPProxy/MCPProxy/Views/ServerDetailView.swift
+++ b/native/macos/MCPProxy/MCPProxy/Views/ServerDetailView.swift
@@ -1158,12 +1158,7 @@ struct ToolRow: View {
.foregroundStyle(.secondary)
if oldDesc != newDesc {
- if !oldDesc.isEmpty {
- diffLine(text: oldDesc, isOld: true)
- }
- if !newDesc.isEmpty {
- diffLine(text: newDesc, isOld: false)
- }
+ diffBeforeAfter(previous: oldDesc, current: newDesc)
} else {
Text("No description changes")
.font(.scaled(.caption, scale: fontScale))
@@ -1188,12 +1183,7 @@ struct ToolRow: View {
.padding(.top, 4)
if oldSchema != newSchema {
- if !oldSchema.isEmpty {
- diffLine(text: oldSchema, isOld: true)
- }
- if !newSchema.isEmpty {
- diffLine(text: newSchema, isOld: false)
- }
+ diffBeforeAfter(previous: oldSchema, current: newSchema)
} else {
Text("No schema changes")
.font(.scaled(.caption, scale: fontScale))
@@ -1219,20 +1209,78 @@ struct ToolRow: View {
}
@ViewBuilder
- private func diffLine(text: String, isOld: Bool) -> some View {
- HStack(alignment: .top, spacing: 4) {
- Image(systemName: isOld ? "minus.circle.fill" : "plus.circle.fill")
- .font(.scaled(.caption2, scale: fontScale))
- .foregroundStyle(isOld ? .red : .green)
- Text(text)
- .font(.scaledMonospaced(.caption, scale: fontScale))
- .foregroundStyle(isOld ? .secondary : .primary)
- .lineLimit(isOld ? 6 : nil)
+ private func diffBeforeAfter(previous: String, current: String) -> some View {
+ let parts = computeWordDiff(previous, current)
+ VStack(alignment: .leading, spacing: 6) {
+ diffBox(
+ label: "BEFORE (APPROVED)",
+ attributed: renderDiffSide(parts, keep: .removed),
+ accent: .red,
+ isEmpty: previous.isEmpty
+ )
+ diffBox(
+ label: "AFTER (CURRENT)",
+ attributed: renderDiffSide(parts, keep: .added),
+ accent: .green,
+ isEmpty: current.isEmpty
+ )
+ }
+ }
+
+ @ViewBuilder
+ private func diffBox(label: String, attributed: AttributedString, accent: Color, isEmpty: Bool) -> some View {
+ VStack(alignment: .leading, spacing: 3) {
+ Text(label)
+ .font(.system(size: 9 * fontScale, weight: .semibold))
+ .tracking(0.5)
+ .foregroundStyle(.secondary)
+ if isEmpty {
+ Text("(empty)")
+ .font(.scaledMonospaced(.caption, scale: fontScale))
+ .foregroundStyle(.tertiary)
+ .padding(6)
+ .frame(maxWidth: .infinity, alignment: .leading)
+ .background(accent.opacity(0.04))
+ .overlay(
+ RoundedRectangle(cornerRadius: 4)
+ .strokeBorder(accent.opacity(0.25), lineWidth: 1)
+ )
+ .cornerRadius(4)
+ } else {
+ Text(attributed)
+ .font(.scaledMonospaced(.caption, scale: fontScale))
+ .textSelection(.enabled)
+ .frame(maxWidth: .infinity, alignment: .leading)
+ .padding(6)
+ .background(accent.opacity(0.04))
+ .overlay(
+ RoundedRectangle(cornerRadius: 4)
+ .strokeBorder(accent.opacity(0.25), lineWidth: 1)
+ )
+ .cornerRadius(4)
+ }
}
- .padding(4)
- .frame(maxWidth: .infinity, alignment: .leading)
- .background((isOld ? Color.red : Color.green).opacity(0.08))
- .cornerRadius(4)
+ }
+
+ /// Build AttributedString for one side of the diff. `keep` is the change-type
+ /// (removed or added) that belongs in this side; same-type parts render plain
+ /// in both sides; the opposite change-type is dropped.
+ private func renderDiffSide(_ parts: [ToolDiffPart], keep: ToolDiffPart.Kind) -> AttributedString {
+ var result = AttributedString()
+ for part in parts {
+ switch part.kind {
+ case .same:
+ result += AttributedString(part.text)
+ case .added, .removed:
+ if part.kind != keep { continue }
+ var span = AttributedString(part.text)
+ let accent: Color = part.kind == .removed ? .red : .green
+ span.backgroundColor = accent.opacity(0.25)
+ span.foregroundColor = accent
+ result += span
+ }
+ }
+ return result
}
private func loadDiff() {
@@ -1314,6 +1362,138 @@ struct ToolRow: View {
}
}
+// MARK: - Tool Description Word Diff
+
+struct ToolDiffPart {
+ enum Kind { case same, added, removed }
+ let kind: Kind
+ let text: String
+}
+
+/// Split a string into tokens, preserving runs of whitespace as their own tokens
+/// (matches the web UI's `split(/(\s+)/)` so the LCS behaves identically).
+private func splitPreservingWhitespace(_ s: String) -> [String] {
+ var tokens: [String] = []
+ var buffer = ""
+ var bufferIsWhitespace: Bool? = nil
+ for ch in s {
+ let chIsWhitespace = ch.isWhitespace
+ if bufferIsWhitespace == nil {
+ bufferIsWhitespace = chIsWhitespace
+ buffer.append(ch)
+ } else if bufferIsWhitespace == chIsWhitespace {
+ buffer.append(ch)
+ } else {
+ tokens.append(buffer)
+ buffer = String(ch)
+ bufferIsWhitespace = chIsWhitespace
+ }
+ }
+ if !buffer.isEmpty { tokens.append(buffer) }
+ return tokens
+}
+
+/// Generic LCS diff over `Element` sequences (words or characters).
+private func lcsDiff(_ oldElems: [Element], _ newElems: [Element]) -> [(kind: ToolDiffPart.Kind, elem: Element)] {
+ let m = oldElems.count
+ let n = newElems.count
+ if m == 0 && n == 0 { return [] }
+ if m == 0 { return newElems.map { (.added, $0) } }
+ if n == 0 { return oldElems.map { (.removed, $0) } }
+
+ var dp = Array(repeating: Array(repeating: 0, count: n + 1), count: m + 1)
+ for i in 1...m {
+ for j in 1...n {
+ if oldElems[i - 1] == newElems[j - 1] {
+ dp[i][j] = dp[i - 1][j - 1] + 1
+ } else {
+ dp[i][j] = max(dp[i - 1][j], dp[i][j - 1])
+ }
+ }
+ }
+
+ var out: [(ToolDiffPart.Kind, Element)] = []
+ var i = m
+ var j = n
+ while i > 0 || j > 0 {
+ if i > 0 && j > 0 && oldElems[i - 1] == newElems[j - 1] {
+ out.append((.same, oldElems[i - 1]))
+ i -= 1
+ j -= 1
+ } else if j > 0 && (i == 0 || dp[i][j - 1] >= dp[i - 1][j]) {
+ out.append((.added, newElems[j - 1]))
+ j -= 1
+ } else {
+ out.append((.removed, oldElems[i - 1]))
+ i -= 1
+ }
+ }
+ out.reverse()
+ return out
+}
+
+/// Character-level diff between two strings. Falls back to the raw
+/// `removed → added` pair when either side exceeds `maxChars` to avoid
+/// quadratic blowup on very long runs.
+private func characterLevelDiff(_ oldText: String, _ newText: String, maxChars: Int = 1500) -> [ToolDiffPart] {
+ if oldText.count > maxChars || newText.count > maxChars {
+ return [
+ ToolDiffPart(kind: .removed, text: oldText),
+ ToolDiffPart(kind: .added, text: newText),
+ ]
+ }
+ let oldChars = Array(oldText)
+ let newChars = Array(newText)
+ return lcsDiff(oldChars, newChars).map { ToolDiffPart(kind: $0.kind, text: String($0.elem)) }
+}
+
+/// Merge consecutive parts of the same kind to minimize attribute spans.
+private func mergeSameKind(_ parts: [ToolDiffPart]) -> [ToolDiffPart] {
+ var merged: [ToolDiffPart] = []
+ for part in parts {
+ if let last = merged.last, last.kind == part.kind {
+ merged[merged.count - 1] = ToolDiffPart(kind: last.kind, text: last.text + part.text)
+ } else {
+ merged.append(part)
+ }
+ }
+ return merged
+}
+
+/// Word-level diff with character-level refinement inside adjacent
+/// (removed, added) pairs. This keeps whole-token highlights for large
+/// docstring expansions while narrowing substring changes like
+/// `"1 April"` → `"8 April"` down to just the `1`/`8` characters.
+func computeWordDiff(_ oldText: String, _ newText: String) -> [ToolDiffPart] {
+ let oldTokens = splitPreservingWhitespace(oldText)
+ let newTokens = splitPreservingWhitespace(newText)
+ let wordDiff = lcsDiff(oldTokens, newTokens).map { ToolDiffPart(kind: $0.kind, text: $0.elem) }
+
+ // First merge consecutive same-kind parts, then refine adjacent
+ // (removed, added) runs into character-level diffs.
+ let merged = mergeSameKind(wordDiff)
+ var refined: [ToolDiffPart] = []
+ var idx = 0
+ while idx < merged.count {
+ let current = merged[idx]
+ if idx + 1 < merged.count {
+ let next = merged[idx + 1]
+ if (current.kind == .removed && next.kind == .added) ||
+ (current.kind == .added && next.kind == .removed) {
+ let removedText = current.kind == .removed ? current.text : next.text
+ let addedText = current.kind == .added ? current.text : next.text
+ refined.append(contentsOf: characterLevelDiff(removedText, addedText))
+ idx += 2
+ continue
+ }
+ }
+ refined.append(current)
+ idx += 1
+ }
+
+ return mergeSameKind(refined)
+}
+
// MARK: - Flow Layout (for wrapping annotation badges)
/// A simple horizontal flow layout that wraps items to the next line.
diff --git a/native/macos/MCPProxy/MCPProxyTests/ToolDiffTests.swift b/native/macos/MCPProxy/MCPProxyTests/ToolDiffTests.swift
new file mode 100644
index 000000000..8fed8a39a
--- /dev/null
+++ b/native/macos/MCPProxy/MCPProxyTests/ToolDiffTests.swift
@@ -0,0 +1,127 @@
+import XCTest
+@testable import MCPProxy
+
+final class ToolDiffTests: XCTestCase {
+
+ private func partsByKind(_ parts: [ToolDiffPart]) -> (same: String, added: String, removed: String) {
+ var same = ""
+ var added = ""
+ var removed = ""
+ for p in parts {
+ switch p.kind {
+ case .same: same += p.text
+ case .added: added += p.text
+ case .removed: removed += p.text
+ }
+ }
+ return (same, added, removed)
+ }
+
+ func testIdenticalStringsProduceOnlySameParts() {
+ let text = "Get the list of Alibaba Cloud regions."
+ let parts = computeWordDiff(text, text)
+ let buckets = partsByKind(parts)
+ XCTAssertEqual(buckets.same, text)
+ XCTAssertEqual(buckets.added, "")
+ XCTAssertEqual(buckets.removed, "")
+ }
+
+ func testEmptyBeforeMarksEverythingAdded() {
+ let parts = computeWordDiff("", "Hello world")
+ XCTAssertEqual(parts.count, 1)
+ XCTAssertEqual(parts[0].kind, .added)
+ XCTAssertEqual(parts[0].text, "Hello world")
+ }
+
+ func testEmptyAfterMarksEverythingRemoved() {
+ let parts = computeWordDiff("Hello world", "")
+ XCTAssertEqual(parts.count, 1)
+ XCTAssertEqual(parts[0].kind, .removed)
+ XCTAssertEqual(parts[0].text, "Hello world")
+ }
+
+ func testExpansionOfAlibabaRegions() {
+ // Real case from gcore-mcp-server: short one-liner expanded to docstring.
+ let before = "Get the list of Alibaba Cloud regions."
+ let after = "Get the list of Alibaba Cloud regions. Args: limit: Maximum number of items."
+ let parts = computeWordDiff(before, after)
+ let buckets = partsByKind(parts)
+
+ // The common prefix must survive intact in "same" parts.
+ XCTAssertTrue(buckets.same.contains("Get the list of Alibaba Cloud regions."))
+ // The trailing docstring must be in the "added" bucket.
+ XCTAssertTrue(buckets.added.contains("Args:"))
+ XCTAssertTrue(buckets.added.contains("limit:"))
+ // Nothing should be removed in this direction.
+ XCTAssertEqual(buckets.removed, "")
+ }
+
+ func testReconstructionFromAddedAndRemoved() {
+ // Reconstructing before = same + removed; after = same + added (in order)
+ let before = "alpha beta gamma"
+ let after = "alpha delta gamma"
+ let parts = computeWordDiff(before, after)
+
+ let reconstructedBefore = parts.compactMap { p -> String? in
+ p.kind == .added ? nil : p.text
+ }.joined()
+ let reconstructedAfter = parts.compactMap { p -> String? in
+ p.kind == .removed ? nil : p.text
+ }.joined()
+
+ XCTAssertEqual(reconstructedBefore, before)
+ XCTAssertEqual(reconstructedAfter, after)
+ }
+
+ func testCharLevelRefinementOnSingleCharChange() {
+ // The real screenshot case: date changed from "1 April" to "8 April".
+ // Word-level alone would highlight the "1" and "8" tokens. Char-level
+ // refinement produces the same result here (single-char tokens), but
+ // it also handles the common "gs_1234" → "gs_1235" style where the
+ // differing chars are in the MIDDLE of a word.
+ let before = "Knowledge up-to-date as at 1 April 2026."
+ let after = "Knowledge up-to-date as at 8 April 2026."
+ let parts = computeWordDiff(before, after)
+
+ let addedParts = parts.filter { $0.kind == .added }
+ let removedParts = parts.filter { $0.kind == .removed }
+
+ // Exactly one char each on each side — nothing else touched.
+ XCTAssertEqual(addedParts.map { $0.text }.joined(), "8")
+ XCTAssertEqual(removedParts.map { $0.text }.joined(), "1")
+
+ // Reconstruction still matches.
+ let recBefore = parts.compactMap { $0.kind == .added ? nil : $0.text }.joined()
+ let recAfter = parts.compactMap { $0.kind == .removed ? nil : $0.text }.joined()
+ XCTAssertEqual(recBefore, before)
+ XCTAssertEqual(recAfter, after)
+ }
+
+ func testCharLevelRefinementInsideWord() {
+ // Differing chars inside a single token — word-level alone would
+ // highlight the whole token ("version-1.2.3" vs "version-1.2.4"),
+ // char-level should narrow to just "3" → "4".
+ let before = "server version-1.2.3 release"
+ let after = "server version-1.2.4 release"
+ let parts = computeWordDiff(before, after)
+
+ let removed = parts.filter { $0.kind == .removed }.map { $0.text }.joined()
+ let added = parts.filter { $0.kind == .added }.map { $0.text }.joined()
+ XCTAssertEqual(removed, "3")
+ XCTAssertEqual(added, "4")
+
+ let recBefore = parts.compactMap { $0.kind == .added ? nil : $0.text }.joined()
+ let recAfter = parts.compactMap { $0.kind == .removed ? nil : $0.text }.joined()
+ XCTAssertEqual(recBefore, before)
+ XCTAssertEqual(recAfter, after)
+ }
+
+ func testConsecutivePartsOfSameKindAreMerged() {
+ let parts = computeWordDiff("one two three", "one two three four five")
+ // Trailing addition should be a single merged part, not many tokens.
+ let addedParts = parts.filter { $0.kind == .added }
+ XCTAssertEqual(addedParts.count, 1, "Consecutive added tokens must merge")
+ XCTAssertTrue(addedParts[0].text.contains("four"))
+ XCTAssertTrue(addedParts[0].text.contains("five"))
+ }
+}