From 59cadbd0140bf25573565ab041690ffbfd2d3246 Mon Sep 17 00:00:00 2001 From: Yazawa Hiroki <47569369+zunda-pixel@users.noreply.github.com> Date: Fri, 30 Oct 2026 16:05:17 +0900 Subject: [PATCH] Fix headless app launch resumption and request completion Separate a successful RunProject request from the matching native launch Operation so the app can keep running after the reply. Resume only stopped, untraced apps belonging to the launch simulator without a selected debugger, and retain stream and unrelated Operation draining. --- Docs/maintainer-architecture.md | 8 ++ .../NativeApplicationLaunch.swift | 91 ++++++++++++++++ .../NativeExecutionOperations.swift | 50 +++++++-- .../NativeMCPSession.swift | 20 ++-- .../NativeToolBackend.swift | 15 ++- .../NativeApplicationLaunchTests.swift | 39 +++++++ .../NativeExecutionOperationsTests.swift | 102 +++++++++++++++++- .../NativeMCPSessionTests.swift | 27 ++++- 8 files changed, 331 insertions(+), 21 deletions(-) create mode 100644 Sources/XcodeMCPNativeRuntime/NativeApplicationLaunch.swift create mode 100644 Tests/XcodeMCPNativeRuntimeTests/NativeApplicationLaunchTests.swift diff --git a/Docs/maintainer-architecture.md b/Docs/maintainer-architecture.md index d98dbd9c..a068f0c5 100644 --- a/Docs/maintainer-architecture.md +++ b/Docs/maintainer-architecture.md @@ -40,6 +40,14 @@ relay. The next request for the same workspace waits for this boundary. An actio with no cancellable Operation can take until natural completion; opaque native Swift Task handles are not joined by this mechanism. +A successful `RunProject` keeps its launched application's Operation under the +native workspace's ownership rather than waiting for the app to exit. Only a +tracker whose launch-session PID matches the successful output is excluded; +other Operations and failed or cancelled requests still drain. After the native +stream ends, a stopped simulator app without a debugger is resumed only when its +local executable belongs to the launch session's simulator. Physical-device PIDs +are never used for local process signaling. + Shutdown stops admission and awaits owned tasks before closing workspace and transport resources. In the native host, the main task owns shutdown and runs it once, for whichever comes first: input end, input or output failure, or a failure diff --git a/Sources/XcodeMCPNativeRuntime/NativeApplicationLaunch.swift b/Sources/XcodeMCPNativeRuntime/NativeApplicationLaunch.swift new file mode 100644 index 00000000..3299c389 --- /dev/null +++ b/Sources/XcodeMCPNativeRuntime/NativeApplicationLaunch.swift @@ -0,0 +1,91 @@ +import ABIBridge +import Darwin +import Foundation +import XcodeMCPWire + +@safe +struct NativeApplicationLaunch: Sendable { + var processIdentifier: Int32 + var debuggerIdentifier: String? + var simulatorIdentifier: UUID? + + static func processIdentifier(in outcome: NativeToolOutcome) -> Int32? { + guard case .succeeded(.object(let output)) = outcome, + case .array(let errors)? = output["buildErrors"], errors.isEmpty, + case .number(.int(let value))? = output["processIdentifier"], + let pid = Int32(exactly: value), pid > 0 else { return nil } + return pid + } + + func resumeIfStopped() throws { + guard debuggerIdentifier?.isEmpty != false, let simulatorIdentifier, + let process = Self.process(processIdentifier), + process.canResume(in: simulatorIdentifier), + let current = Self.process(processIdentifier), current == process else { return } + if kill(processIdentifier, SIGCONT) != 0, errno != ESRCH { + throw NativeRuntimeError.invocation("Resuming the simulator app (pid \(processIdentifier)) failed: errno \(errno)") + } + } + + struct ProcessSnapshot: Equatable, Sendable { + var executable: String + var startedSeconds: Int64 + var startedMicroseconds: Int32 + var stopped: Bool + var traced: Bool + + func canResume(in simulator: UUID) -> Bool { + guard stopped, !traced else { return false } + let parts = URL(fileURLWithPath: executable).standardizedFileURL.pathComponents + guard let index = parts.firstIndex(where: { UUID(uuidString: $0) == simulator }) else { return false } + return Array(parts.dropFirst(index + 1).prefix(4)) == ["data", "Containers", "Bundle", "Application"] + } + } + + private static func process(_ pid: Int32) -> ProcessSnapshot? { + var info = unsafe kinfo_proc() + var size = unsafe MemoryLayout.stride + var name: [Int32] = [CTL_KERN, KERN_PROC, KERN_PROC_PID, pid] + guard unsafe sysctl(&name, UInt32(name.count), &info, &size, nil, 0) == 0, size > 0 else { return nil } + var path = [CChar](repeating: 0, count: 4 * Int(MAXPATHLEN)) + guard unsafe proc_pidpath(pid, &path, UInt32(path.count)) > 0 else { return nil } + return unsafe ProcessSnapshot(executable: String(cString: path), + startedSeconds: Int64(info.kp_proc.p_starttime.tv_sec), + startedMicroseconds: info.kp_proc.p_starttime.tv_usec, + stopped: Int32(info.kp_proc.p_stat) == SSTOP, traced: info.kp_proc.p_flag & P_TRACED != 0) + } +} + +// Xcode assigns the launch session after creating its tracker. Read it only +// after the native producer has drained, on the actor that owns Xcode's models. +@safe +final class NativeExecutionTracker: @unchecked Sendable { + private let tracker: AnyObject + + init(_ tracker: AnyObject) { self.tracker = tracker } + + @MainActor func launch() throws -> NativeApplicationLaunch? { + guard let session = try Self.object(tracker, "launchSession") else { return nil } + let pid = try ABIRuntime.shared.object(session).resolveAndWait(.objc(.method("runnablePID", as: (() -> Int32).self))) + let processIdentifier = try unsafe pid.unsafeInvoke() + guard processIdentifier > 0 else { return nil } + let parameters = try Self.object(session, "launchParameters") + let debugger = try parameters.flatMap { try Self.object($0, "selectedDebuggerIdentifier") as? String } + var simulator: UUID? + if let destination = try Self.object(session, "runDestination"), + let sdk = try Self.object(destination, "targetSDK"), + let name = try Self.object(sdk, "canonicalName") as? String, + ["iphonesimulator", "appletvsimulator", "watchsimulator", "xrsimulator"].contains(where: name.hasPrefix), + let device = try Self.object(destination, "targetDevice"), + let identifier = try Self.object(device, "identifier") as? String { + simulator = UUID(uuidString: identifier) + } + return NativeApplicationLaunch(processIdentifier: processIdentifier, + debuggerIdentifier: debugger, simulatorIdentifier: simulator) + } + + @MainActor private static func object(_ receiver: AnyObject, _ selector: String) throws -> AnyObject? { + let get = try ABIRuntime.shared.object(receiver).resolveAndWait(.objc(.method(selector, as: (() -> AnyObject?).self))) + return try unsafe get.unsafeInvoke() + } +} diff --git a/Sources/XcodeMCPNativeRuntime/NativeExecutionOperations.swift b/Sources/XcodeMCPNativeRuntime/NativeExecutionOperations.swift index 29e2bb75..c908a0d6 100644 --- a/Sources/XcodeMCPNativeRuntime/NativeExecutionOperations.swift +++ b/Sources/XcodeMCPNativeRuntime/NativeExecutionOperations.swift @@ -23,7 +23,8 @@ final class NativeExecutionOperations { guard let tracker else { return } guard let scope = Self.scope else { return } if let operation = try unsafe callback.operation.unsafeInvoke(on: tracker) as? Operation { - scope.register(operation) + let tracker = NativeExecutionTracker(tracker) + scope.register(operation, launch: { try tracker.launch() }) } else { scope.recordFailure(NativeRuntimeError.unsupportedContract("Xcode's execution tracker does not contain an Operation")) } @@ -37,17 +38,22 @@ final class NativeExecutionOperations { // The native Objective-C initializer cannot await an actor. This scope only // associates its caller's native operations with the existing request owner. final class Scope: Sendable { + private struct TrackedOperation: Sendable { + var operation: Operation + var launch: @MainActor @Sendable () throws -> NativeApplicationLaunch? + } + private struct State { - var operations: [ObjectIdentifier: Operation] = [:] + var operations: [ObjectIdentifier: TrackedOperation] = [:] var cancelled = false var failure: (any Error)? } private let state = Mutex(State()) - func register(_ operation: Operation) { + func register(_ operation: Operation, launch: @escaping @MainActor @Sendable () throws -> NativeApplicationLaunch? = { nil }) { let cancelled = state.withLock { state in - state.operations[ObjectIdentifier(operation)] = operation + state.operations[ObjectIdentifier(operation)] = TrackedOperation(operation: operation, launch: launch) return state.cancelled } if cancelled { operation.cancel() } @@ -57,25 +63,53 @@ final class NativeExecutionOperations { let operations = state.withLock { state -> [Operation] in guard !state.cancelled else { return [] } state.cancelled = true - return Array(state.operations.values) + return state.operations.values.map(\.operation) } for operation in operations { operation.cancel() } } // Joining the observed operations does not expose or join private Swift // producer Tasks retained inside a native AsyncStream. - nonisolated(nonsending) func finish() async throws { + nonisolated(nonsending) func finish(continuingLaunchPID: Int32? = nil) async throws { var finished: Set = [] + var continuing: [ObjectIdentifier: Operation] = [:] while true { let pending = state.withLock { state in state.operations.filter { !finished.contains($0.key) } } guard !pending.isEmpty else { break } - for (identifier, operation) in pending { - await Self.waitForCompletion(of: operation) + for (identifier, tracked) in pending { + let launch: NativeApplicationLaunch? + do { launch = continuingLaunchPID == nil ? nil : try await tracked.launch() } + catch { + cancel() + recordFailure(error) + launch = nil + } + if let continuingLaunchPID, let launch, launch.processIdentifier == continuingLaunchPID, + !state.withLock({ $0.cancelled }) { + do { try launch.resumeIfStopped() } + catch { + cancel() + recordFailure(error) + } + continuing[identifier] = tracked.operation + } else { + await Self.waitForCompletion(of: tracked.operation) + } finished.insert(identifier) } } + let cancelled = state.withLock { state in + guard !state.cancelled else { return true } + // A completed launch belongs to the workspace. Later cancellation + // of its finished request must not stop it without draining it. + for identifier in continuing.keys { state.operations[identifier] = nil } + return false + } + if cancelled { + for operation in continuing.values { await Self.waitForCompletion(of: operation) } + } try checkFailure() } diff --git a/Sources/XcodeMCPNativeRuntime/NativeMCPSession.swift b/Sources/XcodeMCPNativeRuntime/NativeMCPSession.swift index 9aca0c83..39b59356 100644 --- a/Sources/XcodeMCPNativeRuntime/NativeMCPSession.swift +++ b/Sources/XcodeMCPNativeRuntime/NativeMCPSession.swift @@ -211,13 +211,13 @@ package actor NativeMCPSession { private func executeTool(params: JSONValue?, name: String, arguments: [String: JSONValue], context: NativeToolContext) async throws -> JSONValue { do { - let result = try await consumeTool(params: params, name: name, arguments: arguments, context: context) - do { try await backend.finishExecution(context: context) } + let completion = try await consumeTool(params: params, name: name, arguments: arguments, context: context) + do { try await backend.finishExecution(context: context, outcome: completion.outcome) } catch { await NativeErrorPresentation.report(error as NSError) } - return result + return completion.result } catch { let original = error - do { try await backend.finishExecution(context: context) } + do { try await backend.finishExecution(context: context, outcome: .failed) } catch { throw NativeRuntimeError.invocation("\(original); workspace cleanup failed: \(error)") } @@ -225,14 +225,19 @@ package actor NativeMCPSession { } } + private struct ToolCompletion { + var result: JSONValue + var outcome: NativeToolOutcome + } + private func consumeTool(params: JSONValue?, name: String, arguments: [String: JSONValue], - context: NativeToolContext) async throws -> JSONValue { + context: NativeToolContext) async throws -> ToolCompletion { let updates: AsyncStream do { updates = try await backend.execute(name, arguments: arguments, context: context) } catch let error as NativeToolExecutionError { try Task.checkCancellation() - return try toolResult(.string(error.message), isError: true) + return try ToolCompletion(result: toolResult(.string(error.message), isError: true), outcome: .failed) } var completed: JSONValue? var output: JSONValue? @@ -260,6 +265,7 @@ package actor NativeMCPSession { output = result completed = try toolResult(result, isError: false) case "error": + output = nil completed = try toolResult(fields["data"] ?? .string("Native tool failed"), isError: true) default: throw NativeRuntimeError.unsupportedContract("Unsupported native event '\(type)'") @@ -271,7 +277,7 @@ package actor NativeMCPSession { } let metadata = await backend.resultMetadata(for: name, output: output, context: context) if !metadata.isEmpty { result["_meta"] = .object(metadata) } - return .object(result) + return ToolCompletion(result: .object(result), outcome: output.map(NativeToolOutcome.succeeded) ?? .failed) } private func withOrigin(_ fields: [String: JSONValue]) async -> JSONValue { diff --git a/Sources/XcodeMCPNativeRuntime/NativeToolBackend.swift b/Sources/XcodeMCPNativeRuntime/NativeToolBackend.swift index 86cfac90..e19b8e06 100644 --- a/Sources/XcodeMCPNativeRuntime/NativeToolBackend.swift +++ b/Sources/XcodeMCPNativeRuntime/NativeToolBackend.swift @@ -1,6 +1,11 @@ import Foundation import XcodeMCPWire +package enum NativeToolOutcome: Sendable { + case succeeded(JSONValue) + case failed +} + package struct NativeToolContext: Sendable { package let artifactsDirectory: URL package let artifactsRoot: URL @@ -31,7 +36,7 @@ package protocol NativeToolBackend: AnyObject, Sendable { func listTools() async throws -> [NativeTool] func execute(_ name: String, arguments: [String: JSONValue], context: NativeToolContext) async throws -> AsyncStream func executionResource(for name: String, arguments: [String: JSONValue]) async throws -> NativeExecutionResource - func finishExecution(context: NativeToolContext) async throws + func finishExecution(context: NativeToolContext, outcome: NativeToolOutcome) async throws /// `_meta` fields for the result of `name`, which completed with `output`. func resultMetadata(for name: String, output: JSONValue?, context: NativeToolContext) async -> [String: JSONValue] /// A failure that leaves this process unable to serve further requests. @@ -48,7 +53,7 @@ extension NativeToolBackend { package func executionResource(for name: String, arguments: [String: JSONValue]) async throws -> NativeExecutionResource { NativeExecutionResource() } - package func finishExecution(context: NativeToolContext) async throws {} + package func finishExecution(context: NativeToolContext, outcome: NativeToolOutcome) async throws {} package func resultMetadata(for name: String, output: JSONValue?, context: NativeToolContext) async -> [String: JSONValue] { [:] } } @@ -321,7 +326,7 @@ package final class NativeXcodeBackend: NativeToolBackend { previewCorrection.cancelPendingOperations() } - package func finishExecution(context: NativeToolContext) async throws { + package func finishExecution(context: NativeToolContext, outcome: NativeToolOutcome) async throws { defer { workspaces.endExecution(request: context.artifactsDirectory) if let request = requestWorkspaces.removeValue(forKey: context.artifactsDirectory), !request.finished { @@ -334,7 +339,9 @@ package final class NativeXcodeBackend: NativeToolBackend { await crashCorrection.finishExecution(request: context.artifactsDirectory) await bridge.finishExecution(request: context.artifactsDirectory) var failures: [String] = [] - do { try await context.executionScope.finish() } + let launchedPID = requestWorkspaces[context.artifactsDirectory]?.tool == "RunProject" + ? NativeApplicationLaunch.processIdentifier(in: outcome) : nil + do { try await context.executionScope.finish(continuingLaunchPID: launchedPID) } catch { failures.append(String(describing: error)) } do { try executionOperations?.checkFailure() } catch { failures.append(String(describing: error)) } diff --git a/Tests/XcodeMCPNativeRuntimeTests/NativeApplicationLaunchTests.swift b/Tests/XcodeMCPNativeRuntimeTests/NativeApplicationLaunchTests.swift new file mode 100644 index 00000000..9ce5b78f --- /dev/null +++ b/Tests/XcodeMCPNativeRuntimeTests/NativeApplicationLaunchTests.swift @@ -0,0 +1,39 @@ +import Foundation +import Testing +@testable import XcodeMCPNativeRuntime +import XcodeMCPWire + +@Suite +struct NativeApplicationLaunchTests { + @Test(arguments: [false, true]) func onlyASuccessfulLaunchCanContinue(failed: Bool) { + let output = JSONValue.object([ + "buildErrors": .array(failed ? [.string("build failed")] : []), + "processIdentifier": .number(.int(42)), + ]) + #expect(NativeApplicationLaunch.processIdentifier(in: .succeeded(output)) == (failed ? nil : 42)) + #expect(NativeApplicationLaunch.processIdentifier(in: .failed) == nil) + } + + @Test(arguments: [Int64(0), -1, Int64(Int32.max) + 1]) func invalidLaunchPIDsDoNotChangeCleanup(pid: Int64) { + #expect(NativeApplicationLaunch.processIdentifier(in: .succeeded(.object([ + "buildErrors": .array([]), "processIdentifier": .number(.int(pid)), + ]))) == nil) + } + + @Test func onlyAStoppedUntracedAppInTheSelectedSimulatorCanResume() { + let device = UUID() + var process = NativeApplicationLaunch.ProcessSnapshot( + executable: "/a/custom/device/set/\(device)/data/Containers/Bundle/Application/\(UUID())/Fixture.app/Fixture", + startedSeconds: 10, startedMicroseconds: 0, stopped: true, traced: false) + #expect(process.canResume(in: device)) + #expect(!process.canResume(in: UUID())) + process.traced = true + #expect(!process.canResume(in: device)) + process.traced = false + process.stopped = false + #expect(!process.canResume(in: device)) + process.stopped = true + process.executable = "/Applications/Fixture.app/Contents/MacOS/Fixture" + #expect(!process.canResume(in: device)) + } +} diff --git a/Tests/XcodeMCPNativeRuntimeTests/NativeExecutionOperationsTests.swift b/Tests/XcodeMCPNativeRuntimeTests/NativeExecutionOperationsTests.swift index 535bcac9..64b78895 100644 --- a/Tests/XcodeMCPNativeRuntimeTests/NativeExecutionOperationsTests.swift +++ b/Tests/XcodeMCPNativeRuntimeTests/NativeExecutionOperationsTests.swift @@ -3,8 +3,108 @@ import Synchronization import Testing @testable import XcodeMCPNativeRuntime -@Suite +@Suite(.timeLimit(.minutes(1))) struct NativeExecutionOperationsTests { + @Test @MainActor func onlyTheCompletedLaunchMayOutliveTheRequest() async throws { + let scope = NativeExecutionOperations.Scope() + let launchGate = DispatchSemaphore(value: 0) + let workGate = DispatchSemaphore(value: 0) + defer { launchGate.signal(); workGate.signal() } + let started = AsyncStream.makeStream() + let launch = BlockOperation { started.continuation.yield(()); launchGate.wait() } + let work = BlockOperation { started.continuation.yield(()); workGate.wait() } + scope.register(launch, launch: { NativeApplicationLaunch(processIdentifier: 42) }) + scope.register(work) + let queue = OperationQueue() + queue.addOperation(launch) + queue.addOperation(work) + var iterator = started.stream.makeAsyncIterator() + _ = await iterator.next(isolation: MainActor.shared) + _ = await iterator.next(isolation: MainActor.shared) + + let drain = Task { try await scope.finish(continuingLaunchPID: 42) } + workGate.signal() + try await drain.value + + #expect(work.isFinished) + #expect(!launch.isFinished) + } + + @Test @MainActor func aDifferentLaunchStillFinishesBeforeTheRequest() async throws { + let scope = NativeExecutionOperations.Scope() + let gate = DispatchSemaphore(value: 0) + defer { gate.signal() } + let started = AsyncStream.makeStream() + let operation = BlockOperation { started.continuation.yield(()); gate.wait() } + let resolving = AsyncStream.makeStream() + let returned = Mutex(false) + scope.register(operation, launch: { + resolving.continuation.yield(()) + return NativeApplicationLaunch(processIdentifier: 7) + }) + let queue = OperationQueue() + queue.addOperation(operation) + var iterator = started.stream.makeAsyncIterator() + _ = await iterator.next(isolation: MainActor.shared) + let drain = Task { + try await scope.finish(continuingLaunchPID: 42) + returned.withLock { $0 = true } + #expect(operation.isFinished) + } + var resolution = resolving.stream.makeAsyncIterator() + _ = await resolution.next(isolation: MainActor.shared) + #expect(!returned.withLock { $0 }) + gate.signal() + try await drain.value + } + + @Test @MainActor func aCancelledLaunchCannotOutliveTheRequest() async throws { + let scope = NativeExecutionOperations.Scope() + let gate = DispatchSemaphore(value: 0) + defer { gate.signal() } + let started = AsyncStream.makeStream() + let operation = BlockOperation { started.continuation.yield(()); gate.wait() } + let resolving = AsyncStream.makeStream() + let returned = Mutex(false) + scope.register(operation, launch: { + resolving.continuation.yield(()) + return NativeApplicationLaunch(processIdentifier: 42) + }) + let queue = OperationQueue() + queue.addOperation(operation) + var iterator = started.stream.makeAsyncIterator() + _ = await iterator.next(isolation: MainActor.shared) + scope.cancel() + let drain = Task { + try await scope.finish(continuingLaunchPID: 42) + returned.withLock { $0 = true } + #expect(operation.isFinished) + } + var resolution = resolving.stream.makeAsyncIterator() + _ = await resolution.next(isolation: MainActor.shared) + #expect(!returned.withLock { $0 }) + gate.signal() + try await drain.value + } + + @Test @MainActor func aContinuingLaunchDoesNotHideRecordedFailures() async { + let scope = NativeExecutionOperations.Scope() + scope.register(BlockOperation {}, launch: { NativeApplicationLaunch(processIdentifier: 42) }) + scope.recordFailure(NativeRuntimeError.invocation("tracker failed")) + await #expect(throws: (any Error).self) { try await scope.finish(continuingLaunchPID: 42) } + } + + @Test @MainActor func cancellationAfterHandoffDoesNotStopTheWorkspaceLaunch() async throws { + let scope = NativeExecutionOperations.Scope() + let launch = BlockOperation {} + scope.register(launch, launch: { NativeApplicationLaunch(processIdentifier: 42) }) + try await scope.finish(continuingLaunchPID: 42) + + scope.cancel() + + #expect(!launch.isCancelled) + } + @Test func cancellationOnlyAffectsOperationsFromTheMatchingRequest() { let first = NativeExecutionOperations.Scope() let second = NativeExecutionOperations.Scope() diff --git a/Tests/XcodeMCPNativeRuntimeTests/NativeMCPSessionTests.swift b/Tests/XcodeMCPNativeRuntimeTests/NativeMCPSessionTests.swift index 429c671d..b181dc5a 100644 --- a/Tests/XcodeMCPNativeRuntimeTests/NativeMCPSessionTests.swift +++ b/Tests/XcodeMCPNativeRuntimeTests/NativeMCPSessionTests.swift @@ -7,6 +7,29 @@ import XcodeMCPWire @Suite(.timeLimit(.minutes(1))) @MainActor struct NativeMCPSessionTests { + @Test(arguments: [false, true]) func cleanupReceivesTheFinalNativeOutcome(finalError: Bool) async throws { + try await withNativeSession { harness in + _ = try await harness.initialize() + try await harness.call("RunProject", id: "launch") + let execution = try await harness.backend.nextExecution() + let output = JSONValue.object(["buildErrors": .array([]), "processIdentifier": .number(.int(42))]) + try execution.emit("completed", data: output) + if finalError { try execution.emit("error", data: .string("launch failed")) } + execution.finish() + + let response = try await harness.nextMessage() + #expect(try nativeTestField(response, "result", "isError") == .bool(finalError)) + let outcome = try #require(harness.backend.finishedOutcomes.last) + switch outcome { + case .succeeded(let actual): + #expect(!finalError) + #expect(actual == output) + case .failed: + #expect(finalError) + } + } + } + @Test func initializationAdvertisesTheNativeToolCapability() async throws { try await withNativeSession { harness in let response = try await harness.initialize() @@ -1190,6 +1213,7 @@ private final class NativeSessionBackendProbe: NativeToolBackend { private(set) var metadataOutputs: [JSONValue?] = [] private(set) var executions: [NativeSessionExecution] = [] private(set) var finishedContexts: [NativeToolContext] = [] + private(set) var finishedOutcomes: [NativeToolOutcome] = [] private(set) var listCalls = 0 private(set) var shutdownCalls = 0 private(set) var beginShutdownCalls = 0 @@ -1234,8 +1258,9 @@ private final class NativeSessionBackendProbe: NativeToolBackend { onBeginShutdown?() } - func finishExecution(context: NativeToolContext) async throws { + func finishExecution(context: NativeToolContext, outcome: NativeToolOutcome) async throws { finishedContexts.append(context) + finishedOutcomes.append(outcome) try await onFinish?(context) }