// Copyright 2026 Google LLC // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. #if compiler(>=6.4) import Foundation import Synchronization import Testing @testable import FoundationModelsFake /// Tests for `LanguageModelExecutorGenerationChannel`'s rendezvous delivery, ordering, /// termination, cancellation, and event inspection. /// /// The expected semantics are described in the file header of /// `LanguageModelExecutorGenerationChannel.swift`. struct LanguageModelExecutorGenerationChannelTests { /// The channel under test. typealias Channel = LanguageModelExecutorGenerationChannel /// An error thrown into channels by these tests. struct ChannelTestError: Error, Equatable { /// A message identifying the error. var message: String } // MARK: - Helpers /// A response event appending `text` with a token count of 1. static func text(_ text: String) -> Channel.Event { .response(action: .appendText(text, tokenCount: 1)) } /// The appended text of a response `appendText` event, or `nil` for any other event. static func appendedText(_ event: Channel.Event?) -> String? { guard case .response(let response) = event?.kind, case .appendText(let fragment) = response.action.kind else { return nil } return fragment.content } /// Iterates `channel` until it ends, returning the appended texts. static func collectTexts(_ channel: Channel) async throws -> [String] { var texts: [String] = [] for try await event in channel { texts.append(appendedText(event) ?? "") } return texts } /// Starts a task that sends `texts` in order and then finishes the channel. @discardableResult static func produce(_ texts: [String], into channel: Channel, finish: Bool = true) -> Task< Void, Never > { Task { for text in texts { await channel.send(Self.text(text)) } if finish { channel.finish() } } } /// Polls until `condition` holds, failing with its own issue after about two seconds. static func waitUntil( sourceLocation: SourceLocation = #_sourceLocation, _ condition: () -> Bool ) async throws { for _ in 0..<2000 where !condition() { try await Task.sleep(for: .milliseconds(1)) } try #require( condition(), "Timed out waiting for the condition", sourceLocation: sourceLocation) } // MARK: - Rendezvous delivery and ordering @Test func sendSuspendsUntilAConsumerTakesTheEvent() async throws { let channel = Channel() let delivered = Mutex(false) let sender = Task { await channel.send(Self.text("a")) delivered.withLock { $0 = true } } try await Self.waitUntil { channel.pendingSendCount == 1 } try await Task.sleep(for: .milliseconds(50)) let deliveredBeforeConsuming = delivered.withLock { $0 } var iterator = channel.makeAsyncIterator() let event = try await iterator.next() await sender.value #expect(!deliveredBeforeConsuming) #expect(channel.pendingSendCount == 0) #expect(Self.appendedText(event) == "a") #expect(delivered.withLock { $0 }) } @Test func sendIsHandedToASuspendedConsumer() async throws { let channel = Channel() let consumer = Task { var iterator = channel.makeAsyncIterator() return Self.appendedText(try await iterator.next()) } try await Self.waitUntil { channel.pendingNextCount == 1 } await channel.send(Self.text("handoff")) #expect(try await consumer.value == "handoff") #expect(channel.pendingNextCount == 0) } @Test func eventsFromOneSenderArriveInOrder() async throws { let channel = Channel() let texts = (0..<2000).map(String.init) Self.produce(texts, into: channel) let received = try await Self.collectTexts(channel) #expect(received == texts) } @Test func copiesOfTheChannelShareOneRendezvous() async throws { let channel = Channel() let copy = channel Self.produce(["via copy"], into: copy) let received = try await Self.collectTexts(channel) #expect(received == ["via copy"]) } @Test func iteratorsCompeteForEvents() async throws { let channel = Channel() Self.produce(["a", "b"], into: channel, finish: false) var first = channel.makeAsyncIterator() var second = channel.makeAsyncIterator() let fromFirst = Self.appendedText(try await first.next()) let fromSecond = Self.appendedText(try await second.next()) #expect(fromFirst == "a") #expect(fromSecond == "b") } @Test func concurrentProducersKeepTheirOwnOrder() async throws { let channel = Channel() let producerCount = 8 let eventsPerProducer = 100 Task { await withTaskGroup(of: Void.self) { group in for producer in 0..([]) channel.onTermination { termination in calls.withLock { $0.append("first: \(termination)") } } channel.onTermination { _ in calls.withLock { $0.append("second") } } channel.cancel() channel.finish() #expect(calls.withLock { $0 } == ["first: cancelled", "second"]) } @Test func onTerminationAfterTerminationRunsImmediately() { let channel = Channel() channel.finish() let calls = Mutex<[String]>([]) channel.onTermination { termination in calls.withLock { $0.append("\(termination)") } } #expect(calls.withLock { $0 } == ["finished"]) } // MARK: - Event inspection @Test func responseActionsExposeTheirPayloads() throws { let segment = Transcript.AttachmentSegment( id: "img-1", content: .image(Transcript.ImageAttachment(imageURL: URL(fileURLWithPath: "/tmp/a.png"))), label: "diagram" ) let events: [Channel.Event] = [ .response(entryID: "r", action: .appendText("hi", segmentID: "s1", tokenCount: 2)), .response(action: .replaceTextSegment("bye", tokenCount: 3)), .response(action: .addAttachmentSegment(segment)), .response(action: .removeAttachmentSegment(id: "img-1")), .response(action: .updateMetadata(["model": "m"])), .response( action: .updateUsage( input: .init(totalTokenCount: 10, cachedTokenCount: 4), output: .init(totalTokenCount: 5, reasoningTokenCount: 1), metadata: ["k": 1] ) ), ] let actions = try events.map { event -> Channel.Response.Action.Kind in guard case .response(let response) = event.kind else { throw ChannelTestError(message: "not a response event") } return response.action.kind } #expect(events.map(\.entryID) == ["r", nil, nil, nil, nil, nil]) guard case .appendText(let fragment) = actions[0] else { Issue.record("Expected appendText") return } #expect(fragment.content == "hi") #expect(fragment.segmentID == "s1") #expect(fragment.tokenCount == 2) guard case .replaceTextSegment(let replacement) = actions[1] else { Issue.record("Expected replaceTextSegment") return } #expect(replacement.content == "bye") #expect(replacement.segmentID == nil) #expect(replacement.tokenCount == 3) guard case .addAttachmentSegment(let added) = actions[2] else { Issue.record("Expected addAttachmentSegment") return } #expect(added == segment) guard case .removeAttachmentSegment(let removedID) = actions[3] else { Issue.record("Expected removeAttachmentSegment") return } #expect(removedID == "img-1") guard case .updateMetadata(let metadata) = actions[4] else { Issue.record("Expected updateMetadata") return } #expect(Array(metadata.values.keys) == ["model"]) guard case .updateUsage(let usage) = actions[5] else { Issue.record("Expected updateUsage") return } #expect(usage.input.totalTokenCount == 10) #expect(usage.input.cachedTokenCount == 4) #expect(usage.output.totalTokenCount == 5) #expect(usage.output.reasoningTokenCount == 1) #expect(Array(usage.metadata.keys) == ["k"]) } @Test func reasoningActionsExposeTheirPayloads() throws { let events: [Channel.Event] = [ .reasoning(entryID: "e", action: .appendText("think", segmentID: "rs", tokenCount: 4)), .reasoning(action: .replaceTextSegment("rethink", segmentID: "rs2", tokenCount: 5)), .reasoning(action: .updateSignature(Data([1, 2, 3]), tokenCount: 6)), .reasoning(action: .updateMetadata(["a": true])), .reasoning( action: .updateUsage( input: .init(totalTokenCount: 1, cachedTokenCount: 0), output: .init(totalTokenCount: 2, reasoningTokenCount: 2) ) ), ] let actions = try events.map { event -> Channel.Reasoning.Action.Kind in guard case .reasoning(let reasoning) = event.kind else { throw ChannelTestError(message: "not a reasoning event") } return reasoning.action.kind } #expect(events.map(\.entryID) == ["e", nil, nil, nil, nil]) guard case .appendText(let fragment) = actions[0] else { Issue.record("Expected appendText") return } #expect(fragment.content == "think") #expect(fragment.segmentID == "rs") #expect(fragment.tokenCount == 4) guard case .replaceTextSegment(let replacement) = actions[1] else { Issue.record("Expected replaceTextSegment") return } #expect(replacement.content == "rethink") #expect(replacement.segmentID == "rs2") #expect(replacement.tokenCount == 5) guard case .updateSignature(let signature) = actions[2] else { Issue.record("Expected updateSignature") return } #expect(signature.signature == Data([1, 2, 3])) #expect(signature.tokenCount == 6) guard case .updateMetadata(let metadata) = actions[3] else { Issue.record("Expected updateMetadata") return } #expect(Array(metadata.values.keys) == ["a"]) guard case .updateUsage(let usage) = actions[4] else { Issue.record("Expected updateUsage") return } #expect(usage.output.reasoningTokenCount == 2) #expect(usage.metadata.isEmpty) } @Test func toolCallsActionsExposeTheirPayloads() throws { let events: [Channel.Event] = [ .toolCalls( entryID: "tc", action: .toolCall( id: "call-1", name: "getWeather", action: .appendArguments("{\"city\":", tokenCount: 3) ) ), .toolCalls( action: .toolCall(id: "call-1", name: "getWeather", action: .updateMetadata(["m": 1])) ), .toolCalls(action: .removeToolCall(id: "call-1")), .toolCalls(action: .updateMetadata(["dropped": "yes"])), .toolCalls( action: .updateUsage( input: .init(totalTokenCount: 7, cachedTokenCount: 1), output: .init(totalTokenCount: 3, reasoningTokenCount: 0) ) ), ] let actions = try events.map { event -> Channel.ToolCalls.Action.Kind in guard case .toolCalls(let toolCalls) = event.kind else { throw ChannelTestError(message: "not a toolCalls event") } return toolCalls.action.kind } #expect(events.map(\.entryID) == ["tc", nil, nil, nil, nil]) guard case .toolCall(let call) = actions[0], case .appendArguments(let fragment) = call.action.kind else { Issue.record("Expected toolCall/appendArguments") return } #expect(call.id == "call-1") #expect(call.name == "getWeather") #expect(fragment.content == "{\"city\":") #expect(fragment.tokenCount == 3) guard case .toolCall(let metadataCall) = actions[1], case .updateMetadata(let callMetadata) = metadataCall.action.kind else { Issue.record("Expected toolCall/updateMetadata") return } #expect(Array(callMetadata.values.keys) == ["m"]) guard case .removeToolCall(let removedID) = actions[2] else { Issue.record("Expected removeToolCall") return } #expect(removedID == "call-1") guard case .updateMetadata(let metadata) = actions[3] else { Issue.record("Expected updateMetadata") return } #expect(Array(metadata.values.keys) == ["dropped"]) guard case .updateUsage(let usage) = actions[4] else { Issue.record("Expected updateUsage") return } #expect(usage.input.totalTokenCount == 7) #expect(usage.input.cachedTokenCount == 1) } @Test func publicPayloadStructsAreMutable() { var usage = Channel.Usage( input: .init(totalTokenCount: 1, cachedTokenCount: 0), output: .init(totalTokenCount: 2, reasoningTokenCount: 0) ) usage.input.totalTokenCount = 9 usage.output.reasoningTokenCount = 1 #expect(usage.input.totalTokenCount == 9) #expect(usage.output.reasoningTokenCount == 1) #expect(usage.metadata.isEmpty) } } extension LanguageModelExecutorGenerationChannel.Termination: CustomStringConvertible { /// A short name for the termination reason, for test messages. var description: String { switch self { case .finished: "finished" case .failed(let error): "failed(\(error))" case .cancelled: "cancelled" } } } #endif // compiler(>=6.4)