Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 12 additions & 4 deletions TablePro/ViewModels/AIChatViewModel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -1104,14 +1104,19 @@ final class AIChatViewModel {
}
}

/// Execute the given tool-use blocks in parallel via a `withTaskGroup`,
/// returning result blocks in the same order. The `registry` parameter
/// defaults to the shared singleton; tests inject a fresh instance to
/// avoid polluting global state.
nonisolated static func executeToolUses(
_ blocks: [ToolUseBlock],
context: ChatToolContext
context: ChatToolContext,
registry: ChatToolRegistry? = nil
) async -> [ToolResultBlock] {
await withTaskGroup(of: (Int, ToolResultBlock).self) { group in
for (index, block) in blocks.enumerated() {
group.addTask {
(index, await runToolUse(block, context: context))
(index, await runToolUse(block, context: context, registry: registry))
}
}
var indexed: [(Int, ToolResultBlock)] = []
Expand All @@ -1122,12 +1127,15 @@ final class AIChatViewModel {

nonisolated private static func runToolUse(
_ block: ToolUseBlock,
context: ChatToolContext
context: ChatToolContext,
registry: ChatToolRegistry?
) async -> ToolResultBlock {
if Task.isCancelled {
return ToolResultBlock(toolUseId: block.id, content: "Cancelled", isError: true)
}
let tool = await MainActor.run { ChatToolRegistry.shared.tool(named: block.name) }
let tool = await MainActor.run {
(registry ?? ChatToolRegistry.shared).tool(named: block.name)
}
guard let tool else {
Self.logger.warning("Tool '\(block.name, privacy: .public)' not registered; returning error")
return ToolResultBlock(
Expand Down
180 changes: 180 additions & 0 deletions TableProTests/Core/AI/ExecuteToolUsesTests.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,180 @@
//
// ExecuteToolUsesTests.swift
// TableProTests
//

import Foundation
@testable import TablePro
import Testing

@Suite("AIChatViewModel.executeToolUses")
@MainActor
struct ExecuteToolUsesTests {
/// Stub tool that returns a fixed response when invoked. Tracks invocation
/// count and the input it received so tests can assert dispatch behaviour.
private final class StubTool: ChatTool {
let name: String
let description: String
let inputSchema: JSONValue
let response: String
let isError: Bool
private(set) var invocations: [JSONValue] = []

init(name: String, response: String = "ok", isError: Bool = false) {
self.name = name
self.description = ""
self.inputSchema = .object(["type": .string("object")])
self.response = response
self.isError = isError
}

func execute(input: JSONValue, context: ChatToolContext) async throws -> ChatToolResult {
invocations.append(input)
return ChatToolResult(content: response, isError: isError)
}
}

/// Tool that always throws when called. Used to verify the error path
/// returns a ToolResultBlock with isError: true rather than crashing.
private struct ThrowingTool: ChatTool {
let name: String
let description = ""
let inputSchema: JSONValue = .object(["type": .string("object")])
struct Boom: Error {}
func execute(input: JSONValue, context: ChatToolContext) async throws -> ChatToolResult {
throw Boom()
}
}

private func makeContext() -> ChatToolContext {
ChatToolContext(
connectionId: nil,
bridge: MCPConnectionBridge(),
authPolicy: MCPAuthPolicy()
)
}

@Test("Resolves tool by name and returns its content as a ToolResultBlock")
func dispatchesToRegisteredTool() async {
let registry = ChatToolRegistry()
registry.register(StubTool(name: "alpha", response: "hello"))
let blocks = [ToolUseBlock(id: "u1", name: "alpha", input: .object([:]))]
let results = await AIChatViewModel.executeToolUses(
blocks,
context: makeContext(),
registry: registry
)
#expect(results.count == 1)
#expect(results[0].toolUseId == "u1")
#expect(results[0].content == "hello")
#expect(results[0].isError == false)
}

@Test("Tools execute in parallel; results come back in input order")
func resultsAreInInputOrder() async {
let registry = ChatToolRegistry()
registry.register(StubTool(name: "alpha", response: "A"))
registry.register(StubTool(name: "bravo", response: "B"))
registry.register(StubTool(name: "charlie", response: "C"))
let blocks = [
ToolUseBlock(id: "u1", name: "charlie", input: .object([:])),
ToolUseBlock(id: "u2", name: "alpha", input: .object([:])),
ToolUseBlock(id: "u3", name: "bravo", input: .object([:]))
]
let results = await AIChatViewModel.executeToolUses(
blocks,
context: makeContext(),
registry: registry
)
#expect(results.map(\.toolUseId) == ["u1", "u2", "u3"])
#expect(results.map(\.content) == ["C", "A", "B"])
}

@Test("Unregistered tool name yields isError: true result with explanation")
func unregisteredToolReturnsError() async {
let registry = ChatToolRegistry()
let blocks = [ToolUseBlock(id: "u1", name: "ghost", input: .object([:]))]
let results = await AIChatViewModel.executeToolUses(
blocks,
context: makeContext(),
registry: registry
)
#expect(results.count == 1)
#expect(results[0].isError == true)
#expect(results[0].content.contains("ghost"))
}

@Test("Throwing tool yields isError: true with the error description")
func throwingToolReturnsError() async {
let registry = ChatToolRegistry()
registry.register(ThrowingTool(name: "boom"))
let blocks = [ToolUseBlock(id: "u1", name: "boom", input: .object([:]))]
let results = await AIChatViewModel.executeToolUses(
blocks,
context: makeContext(),
registry: registry
)
#expect(results.count == 1)
#expect(results[0].isError == true)
#expect(results[0].content.hasPrefix("Error:"))
}

@Test("Tool's own isError flag is propagated to the result block")
func toolIsErrorPropagates() async {
let registry = ChatToolRegistry()
registry.register(StubTool(name: "warn", response: "permission denied", isError: true))
let blocks = [ToolUseBlock(id: "u1", name: "warn", input: .object([:]))]
let results = await AIChatViewModel.executeToolUses(
blocks,
context: makeContext(),
registry: registry
)
#expect(results[0].isError == true)
#expect(results[0].content == "permission denied")
}

@Test("Mixed registered and unregistered tools each return one result block")
func mixedToolsAllReturnResults() async {
let registry = ChatToolRegistry()
registry.register(StubTool(name: "alpha", response: "A"))
let blocks = [
ToolUseBlock(id: "u1", name: "alpha", input: .object([:])),
ToolUseBlock(id: "u2", name: "missing", input: .object([:]))
]
let results = await AIChatViewModel.executeToolUses(
blocks,
context: makeContext(),
registry: registry
)
#expect(results.count == 2)
#expect(results[0].isError == false)
#expect(results[0].content == "A")
#expect(results[1].isError == true)
}

@Test("Tool receives the input JSONValue from its ToolUseBlock")
func inputForwarded() async {
let registry = ChatToolRegistry()
let stub = StubTool(name: "alpha")
registry.register(stub)
let input: JSONValue = .object(["query": .string("SELECT 1")])
_ = await AIChatViewModel.executeToolUses(
[ToolUseBlock(id: "u1", name: "alpha", input: input)],
context: makeContext(),
registry: registry
)
#expect(stub.invocations.count == 1)
#expect(stub.invocations.first == input)
}

@Test("Empty input array returns empty results")
func emptyInput() async {
let registry = ChatToolRegistry()
let results = await AIChatViewModel.executeToolUses(
[],
context: makeContext(),
registry: registry
)
#expect(results.isEmpty)
}
}
Loading