mirror of
https://github.com/firebase/firebase-ios-sdk
synced 2026-11-09 22:36:01 +05:45
Replace `#if canImport(FoundationModels, _version: 2.1)` with `#if compiler(>=6.4.2)` around the `Transcript.Entry.data` case in `GeminiTranscriptTranslator` and its test. Xcode 27.2 beta 3 bumped the Swift compiler to 6.4.2, so the condition no longer depends on the underscored `_version` parameter of `canImport`. Earlier Xcode 27.2 betas report Swift 6.4 and skip the case, which is acceptable since beta 3 supersedes them.
734 lines
26 KiB
Swift
734 lines
26 KiB
Swift
// 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 canImport(FoundationModels) && compiler(>=6.4)
|
|
import Foundation
|
|
import FoundationModels
|
|
import GeminiAPIDataModels
|
|
import GeminiTestUtilities
|
|
import Testing
|
|
import UniformTypeIdentifiers
|
|
|
|
@testable import GeminiLanguageModel
|
|
|
|
@Suite("GeminiTranscriptTranslator Tests", .requireFoundationModels)
|
|
struct GeminiTranscriptTranslatorTests {
|
|
@Test
|
|
@available(macOS 27.0, iOS 27.0, watchOS 27.0, visionOS 27.0, *)
|
|
func translatesInstructionsAndPrompt() throws {
|
|
let transcript = Transcript(
|
|
entries: [
|
|
.instructions(
|
|
Transcript.Instructions(
|
|
segments: [.text(Transcript.TextSegment(content: "You are a helpful assistant."))],
|
|
toolDefinitions: []
|
|
)
|
|
),
|
|
.prompt(
|
|
Transcript.Prompt(
|
|
segments: [.text(Transcript.TextSegment(content: "Hello!"))]
|
|
)
|
|
),
|
|
]
|
|
)
|
|
|
|
let result = try GeminiTranscriptTranslator.translate(transcript)
|
|
|
|
let systemInstruction = try #require(result.systemInstruction)
|
|
#expect(
|
|
systemInstruction.parts?.first?.data == .text("You are a helpful assistant."))
|
|
#expect(result.contents.count == 1)
|
|
#expect(result.contents.first?.role == "user")
|
|
#expect(result.contents.first?.parts?.first?.data == .text("Hello!"))
|
|
}
|
|
|
|
@Test
|
|
@available(macOS 27.0, iOS 27.0, watchOS 27.0, visionOS 27.0, *)
|
|
func coalescesReasoningAndResponseIntoSingleModelTurn() throws {
|
|
let signatureData = Data("test-sig".utf8)
|
|
let transcript = Transcript(
|
|
entries: [
|
|
.prompt(
|
|
Transcript.Prompt(
|
|
segments: [.text(Transcript.TextSegment(content: "Explain 2+2."))]
|
|
)
|
|
),
|
|
.reasoning(
|
|
Transcript.Reasoning(
|
|
segments: [.text(Transcript.TextSegment(content: "Thinking about addition..."))],
|
|
signature: signatureData
|
|
)
|
|
),
|
|
.response(
|
|
Transcript.Response(
|
|
segments: [.text(Transcript.TextSegment(content: "2+2 is 4."))]
|
|
)
|
|
),
|
|
]
|
|
)
|
|
|
|
let result = try GeminiTranscriptTranslator.translate(transcript)
|
|
|
|
#expect(result.contents.count == 2)
|
|
#expect(result.contents[0].role == "user")
|
|
#expect(result.contents[1].role == "model")
|
|
|
|
let modelParts = try #require(result.contents[1].parts)
|
|
#expect(modelParts.count == 2)
|
|
#expect(modelParts[0].thought == true)
|
|
#expect(modelParts[0].thoughtSignature == "test-sig")
|
|
#expect(modelParts[0].data == .text("Thinking about addition..."))
|
|
#expect(modelParts[1].data == .text("2+2 is 4."))
|
|
}
|
|
|
|
@Test
|
|
@available(macOS 27.0, iOS 27.0, watchOS 27.0, visionOS 27.0, *)
|
|
func attachesSignatureOnlyReasoningToFollowingResponsePart() throws {
|
|
let signatureData = Data("sig-only".utf8)
|
|
let transcript = Transcript(
|
|
entries: [
|
|
.prompt(
|
|
Transcript.Prompt(
|
|
segments: [.text(Transcript.TextSegment(content: "Hi."))]
|
|
)
|
|
),
|
|
.reasoning(
|
|
Transcript.Reasoning(
|
|
segments: [],
|
|
signature: signatureData
|
|
)
|
|
),
|
|
.response(
|
|
Transcript.Response(
|
|
segments: [.text(Transcript.TextSegment(content: "Hello!"))]
|
|
)
|
|
),
|
|
]
|
|
)
|
|
|
|
let result = try GeminiTranscriptTranslator.translate(transcript)
|
|
|
|
#expect(result.contents.count == 2)
|
|
#expect(result.contents[1].role == "model")
|
|
let modelParts = try #require(result.contents[1].parts)
|
|
#expect(modelParts.count == 1)
|
|
#expect(modelParts[0].data == .text("Hello!"))
|
|
#expect(modelParts[0].thoughtSignature == "sig-only")
|
|
#expect(modelParts[0].thought == nil)
|
|
}
|
|
|
|
@Test
|
|
@available(macOS 27.0, iOS 27.0, watchOS 27.0, visionOS 27.0, *)
|
|
func attachesTrailingSignatureOnlyReasoningToPrecedingResponsePart() throws {
|
|
let signatureData = Data("trailing-sig".utf8)
|
|
let transcript = Transcript(
|
|
entries: [
|
|
.prompt(
|
|
Transcript.Prompt(
|
|
segments: [.text(Transcript.TextSegment(content: "Weather in Seattle?"))]
|
|
)
|
|
),
|
|
.response(
|
|
Transcript.Response(
|
|
segments: [.text(Transcript.TextSegment(content: "It is 22°C and sunny."))]
|
|
)
|
|
),
|
|
.reasoning(
|
|
Transcript.Reasoning(
|
|
segments: [],
|
|
signature: signatureData
|
|
)
|
|
),
|
|
.prompt(
|
|
Transcript.Prompt(
|
|
segments: [.text(Transcript.TextSegment(content: "What about Miami?"))]
|
|
)
|
|
),
|
|
]
|
|
)
|
|
|
|
let result = try GeminiTranscriptTranslator.translate(transcript)
|
|
|
|
#expect(result.contents.count == 3)
|
|
#expect(result.contents[1].role == "model")
|
|
let modelParts = try #require(result.contents[1].parts)
|
|
#expect(modelParts.count == 1)
|
|
#expect(modelParts[0].data == .text("It is 22°C and sunny."))
|
|
#expect(modelParts[0].thoughtSignature == "trailing-sig")
|
|
#expect(modelParts[0].thought == nil)
|
|
#expect(result.contents[2].role == "user")
|
|
#expect(result.contents[2].parts?.first?.data == .text("What about Miami?"))
|
|
}
|
|
|
|
@Test
|
|
@available(macOS 27.0, iOS 27.0, watchOS 27.0, visionOS 27.0, *)
|
|
func sequentialToolCallTurnsNeverEmitPartsWithoutData() throws {
|
|
let callSignature = Data("call-sig".utf8)
|
|
let responseSignature = Data("response-sig".utf8)
|
|
let args = try GeneratedContent(json: "{\"city\":\"Seattle\"}")
|
|
let toolCall = Transcript.ToolCall(id: "call-1", toolName: "getWeather", arguments: args)
|
|
let toolOutput = Transcript.ToolOutput(
|
|
id: "call-1",
|
|
toolName: "getWeather",
|
|
segments: [.text(Transcript.TextSegment(content: "22°C and sunny"))]
|
|
)
|
|
let transcript = Transcript(
|
|
entries: [
|
|
.prompt(
|
|
Transcript.Prompt(
|
|
segments: [.text(Transcript.TextSegment(content: "Weather in Seattle?"))]
|
|
)
|
|
),
|
|
.reasoning(Transcript.Reasoning(segments: [], signature: callSignature)),
|
|
.toolCalls(Transcript.ToolCalls(id: "calls-1", [toolCall])),
|
|
.toolOutput(toolOutput),
|
|
.response(
|
|
Transcript.Response(
|
|
segments: [.text(Transcript.TextSegment(content: "It is 22°C and sunny."))]
|
|
)
|
|
),
|
|
.reasoning(Transcript.Reasoning(segments: [], signature: responseSignature)),
|
|
.prompt(
|
|
Transcript.Prompt(
|
|
segments: [.text(Transcript.TextSegment(content: "What about Miami?"))]
|
|
)
|
|
),
|
|
]
|
|
)
|
|
|
|
let result = try GeminiTranscriptTranslator.translate(transcript)
|
|
|
|
#expect(result.contents.count == 5)
|
|
#expect(result.contents.map(\.role) == ["user", "model", "user", "model", "user"])
|
|
let allPartsHaveData = result.contents.allSatisfy { content in
|
|
content.parts?.allSatisfy { $0.data != nil } ?? true
|
|
}
|
|
#expect(allPartsHaveData)
|
|
let callParts = try #require(result.contents[1].parts)
|
|
#expect(callParts.count == 1)
|
|
guard case .functionCall(let functionCall) = callParts[0].data else {
|
|
Issue.record("Expected functionCall part data")
|
|
return
|
|
}
|
|
#expect(functionCall.name == "getWeather")
|
|
#expect(callParts[0].thoughtSignature == "call-sig")
|
|
let outputParts = try #require(result.contents[2].parts)
|
|
#expect(outputParts.count == 1)
|
|
guard case .functionResponse = outputParts[0].data else {
|
|
Issue.record("Expected functionResponse part data")
|
|
return
|
|
}
|
|
let responseParts = try #require(result.contents[3].parts)
|
|
#expect(responseParts.count == 1)
|
|
#expect(responseParts[0].data == .text("It is 22°C and sunny."))
|
|
#expect(responseParts[0].thoughtSignature == "response-sig")
|
|
#expect(responseParts[0].thought == nil)
|
|
}
|
|
|
|
@Test
|
|
@available(macOS 27.0, iOS 27.0, watchOS 27.0, visionOS 27.0, *)
|
|
func trailingSignatureFallsBackToEmptyThoughtPartWhenLastPartIsSigned() throws {
|
|
let firstSignature = Data("sig-a".utf8)
|
|
let secondSignature = Data("sig-b".utf8)
|
|
let transcript = Transcript(
|
|
entries: [
|
|
.prompt(
|
|
Transcript.Prompt(
|
|
segments: [.text(Transcript.TextSegment(content: "Hi."))]
|
|
)
|
|
),
|
|
.reasoning(Transcript.Reasoning(segments: [], signature: firstSignature)),
|
|
.response(
|
|
Transcript.Response(
|
|
segments: [.text(Transcript.TextSegment(content: "Hello!"))]
|
|
)
|
|
),
|
|
.reasoning(Transcript.Reasoning(segments: [], signature: secondSignature)),
|
|
.prompt(
|
|
Transcript.Prompt(
|
|
segments: [.text(Transcript.TextSegment(content: "Again?"))]
|
|
)
|
|
),
|
|
]
|
|
)
|
|
|
|
let result = try GeminiTranscriptTranslator.translate(transcript)
|
|
|
|
#expect(result.contents.count == 3)
|
|
#expect(result.contents[1].role == "model")
|
|
let modelParts = try #require(result.contents[1].parts)
|
|
#expect(modelParts.count == 2)
|
|
#expect(modelParts[0].data == .text("Hello!"))
|
|
#expect(modelParts[0].thoughtSignature == "sig-a")
|
|
#expect(modelParts[1].data == .text(""))
|
|
#expect(modelParts[1].thought == true)
|
|
#expect(modelParts[1].thoughtSignature == "sig-b")
|
|
}
|
|
|
|
@Test
|
|
@available(macOS 27.0, iOS 27.0, watchOS 27.0, visionOS 27.0, *)
|
|
func signatureOnlyReasoningWithoutModelPartEmitsEmptyThoughtPart() throws {
|
|
let signatureData = Data("orphan-sig".utf8)
|
|
let transcript = Transcript(
|
|
entries: [
|
|
.prompt(
|
|
Transcript.Prompt(
|
|
segments: [.text(Transcript.TextSegment(content: "Hi."))]
|
|
)
|
|
),
|
|
.reasoning(
|
|
Transcript.Reasoning(
|
|
segments: [],
|
|
signature: signatureData
|
|
)
|
|
),
|
|
]
|
|
)
|
|
|
|
let result = try GeminiTranscriptTranslator.translate(transcript)
|
|
|
|
#expect(result.contents.count == 2)
|
|
let modelParts = try #require(result.contents[1].parts)
|
|
#expect(modelParts.count == 1)
|
|
#expect(modelParts[0].thought == true)
|
|
#expect(modelParts[0].thoughtSignature == "orphan-sig")
|
|
#expect(modelParts[0].data == .text(""))
|
|
}
|
|
|
|
@Test
|
|
@available(macOS 27.0, iOS 27.0, watchOS 27.0, visionOS 27.0, *)
|
|
func translatesMultipleInstructionsEntriesIntoMultipleParts() throws {
|
|
let transcript = Transcript(
|
|
entries: [
|
|
.instructions(
|
|
Transcript.Instructions(
|
|
segments: [.text(Transcript.TextSegment(content: "First instruction."))],
|
|
toolDefinitions: []
|
|
)
|
|
),
|
|
.instructions(
|
|
Transcript.Instructions(
|
|
segments: [.text(Transcript.TextSegment(content: "Second instruction."))],
|
|
toolDefinitions: []
|
|
)
|
|
),
|
|
.prompt(
|
|
Transcript.Prompt(
|
|
segments: [.text(Transcript.TextSegment(content: "Hello!"))]
|
|
)
|
|
),
|
|
]
|
|
)
|
|
|
|
let result = try GeminiTranscriptTranslator.translate(transcript)
|
|
|
|
let systemInstruction = try #require(result.systemInstruction)
|
|
let parts = try #require(systemInstruction.parts)
|
|
#expect(parts.count == 2)
|
|
#expect(parts[0].data == .text("First instruction."))
|
|
#expect(parts[1].data == .text("Second instruction."))
|
|
}
|
|
|
|
@Test
|
|
@available(macOS 27.0, iOS 27.0, watchOS 27.0, visionOS 27.0, *)
|
|
func translatesToolCallsIntoModelTurn() throws {
|
|
let args = try GeneratedContent(json: "{\"location\":\"Paris\"}")
|
|
let toolCall = Transcript.ToolCall(
|
|
id: "call-1",
|
|
toolName: "get_weather",
|
|
arguments: args
|
|
)
|
|
let transcript = Transcript(
|
|
entries: [
|
|
.prompt(
|
|
Transcript.Prompt(
|
|
segments: [.text(Transcript.TextSegment(content: "Weather?"))]
|
|
)
|
|
),
|
|
.toolCalls(Transcript.ToolCalls(id: "calls-1", [toolCall])),
|
|
]
|
|
)
|
|
|
|
let result = try GeminiTranscriptTranslator.translate(transcript)
|
|
|
|
#expect(result.contents.count == 2)
|
|
#expect(result.contents[0].role == "user")
|
|
#expect(result.contents[1].role == "model")
|
|
let modelParts = try #require(result.contents[1].parts)
|
|
#expect(modelParts.count == 1)
|
|
guard case .functionCall(let functionCall) = modelParts[0].data else {
|
|
Issue.record("Expected functionCall part data")
|
|
return
|
|
}
|
|
#expect(functionCall.id == "call-1")
|
|
#expect(functionCall.name == "get_weather")
|
|
#expect(functionCall.args?["location"] == .string("Paris"))
|
|
}
|
|
|
|
@Test
|
|
@available(macOS 27.0, iOS 27.0, watchOS 27.0, visionOS 27.0, *)
|
|
func translatesToolOutputWithTextSegmentWrappingUnderResult() throws {
|
|
let toolOutput = Transcript.ToolOutput(
|
|
id: "call-1",
|
|
toolName: "get_weather",
|
|
segments: [.text(Transcript.TextSegment(content: "Sunny, 22°C"))]
|
|
)
|
|
let transcript = Transcript(entries: [.toolOutput(toolOutput)])
|
|
|
|
let result = try GeminiTranscriptTranslator.translate(transcript)
|
|
|
|
#expect(result.contents.count == 1)
|
|
#expect(result.contents[0].role == "user")
|
|
let userParts = try #require(result.contents[0].parts)
|
|
#expect(userParts.count == 1)
|
|
guard case .functionResponse(let functionResponse) = userParts[0].data else {
|
|
Issue.record("Expected functionResponse part data")
|
|
return
|
|
}
|
|
#expect(functionResponse.id == "call-1")
|
|
#expect(functionResponse.name == "get_weather")
|
|
#expect(functionResponse.response?["result"] == .string("Sunny, 22°C"))
|
|
}
|
|
|
|
@Test
|
|
@available(macOS 27.0, iOS 27.0, watchOS 27.0, visionOS 27.0, *)
|
|
func translatesToolOutputWithJSONTextPreservingStringUnderResult() throws {
|
|
let jsonString = "{\"temperature\":22,\"condition\":\"sunny\"}"
|
|
let toolOutput = Transcript.ToolOutput(
|
|
id: "call-1",
|
|
toolName: "get_weather",
|
|
segments: [
|
|
.text(Transcript.TextSegment(content: jsonString))
|
|
]
|
|
)
|
|
let transcript = Transcript(entries: [.toolOutput(toolOutput)])
|
|
|
|
let result = try GeminiTranscriptTranslator.translate(transcript)
|
|
|
|
let userParts = try #require(result.contents.first?.parts)
|
|
guard case .functionResponse(let functionResponse) = userParts.first?.data else {
|
|
Issue.record("Expected functionResponse part data")
|
|
return
|
|
}
|
|
#expect(functionResponse.id == "call-1")
|
|
#expect(functionResponse.name == "get_weather")
|
|
#expect(functionResponse.response?["result"] == .string(jsonString))
|
|
}
|
|
|
|
@Test
|
|
@available(macOS 27.0, iOS 27.0, watchOS 27.0, visionOS 27.0, *)
|
|
|
|
func translatesToolOutputWithStructuredSegmentUnpackingDirectly() throws {
|
|
let content = try GeneratedContent(json: "{\"temperature\":22}")
|
|
let structuredSegment = Transcript.StructuredSegment(
|
|
id: "seg-1",
|
|
schemaName: "Weather",
|
|
content: content
|
|
)
|
|
let toolOutput = Transcript.ToolOutput(
|
|
id: "call-1",
|
|
toolName: "get_weather",
|
|
segments: [.structure(structuredSegment)]
|
|
)
|
|
let transcript = Transcript(entries: [.toolOutput(toolOutput)])
|
|
|
|
let result = try GeminiTranscriptTranslator.translate(transcript)
|
|
|
|
let userParts = try #require(result.contents.first?.parts)
|
|
guard case .functionResponse(let functionResponse) = userParts.first?.data else {
|
|
Issue.record("Expected functionResponse part data")
|
|
return
|
|
}
|
|
#expect(functionResponse.id == "call-1")
|
|
#expect(functionResponse.name == "get_weather")
|
|
#expect(functionResponse.response?["temperature"] == .number(22))
|
|
}
|
|
|
|
@Test
|
|
@available(macOS 27.0, iOS 27.0, watchOS 27.0, visionOS 27.0, *)
|
|
func coalescesParallelToolCallsAndOutputs() throws {
|
|
let call1 = Transcript.ToolCall(
|
|
id: "call-1",
|
|
toolName: "get_weather",
|
|
arguments: try GeneratedContent(json: "{\"location\":\"Paris\"}")
|
|
)
|
|
let call2 = Transcript.ToolCall(
|
|
id: "call-2",
|
|
toolName: "get_time",
|
|
arguments: try GeneratedContent(json: "{\"location\":\"Tokyo\"}")
|
|
)
|
|
let output1 = Transcript.ToolOutput(
|
|
id: "call-1",
|
|
toolName: "get_weather",
|
|
segments: [.text(Transcript.TextSegment(content: "22°C"))]
|
|
)
|
|
let output2 = Transcript.ToolOutput(
|
|
id: "call-2",
|
|
toolName: "get_time",
|
|
segments: [.text(Transcript.TextSegment(content: "15:00"))]
|
|
)
|
|
let transcript = Transcript(
|
|
entries: [
|
|
.toolCalls(Transcript.ToolCalls(id: "calls-1", [call1, call2])),
|
|
.toolOutput(output1),
|
|
.toolOutput(output2),
|
|
]
|
|
)
|
|
|
|
let result = try GeminiTranscriptTranslator.translate(transcript)
|
|
|
|
#expect(result.contents.count == 2)
|
|
#expect(result.contents[0].role == "model")
|
|
let modelParts = try #require(result.contents[0].parts)
|
|
#expect(modelParts.count == 2)
|
|
guard case .functionCall(let fc1) = modelParts[0].data,
|
|
case .functionCall(let fc2) = modelParts[1].data
|
|
else {
|
|
Issue.record("Expected two functionCall parts")
|
|
return
|
|
}
|
|
#expect(fc1.id == "call-1")
|
|
#expect(fc2.id == "call-2")
|
|
#expect(result.contents[1].role == "user")
|
|
let userParts = try #require(result.contents[1].parts)
|
|
#expect(userParts.count == 2)
|
|
guard case .functionResponse(let fr1) = userParts[0].data,
|
|
case .functionResponse(let fr2) = userParts[1].data
|
|
else {
|
|
Issue.record("Expected two functionResponse parts in single user turn")
|
|
return
|
|
}
|
|
#expect(fr1.id == "call-1")
|
|
#expect(fr2.id == "call-2")
|
|
}
|
|
|
|
@Test
|
|
@available(macOS 27.0, iOS 27.0, watchOS 27.0, visionOS 27.0, *)
|
|
func translatesToolCallsWithEmptyArguments() throws {
|
|
let call = Transcript.ToolCall(
|
|
id: "call-1",
|
|
toolName: "get_time",
|
|
arguments: try GeneratedContent(json: "{}")
|
|
)
|
|
let transcript = Transcript(
|
|
entries: [.toolCalls(Transcript.ToolCalls(id: "calls-1", [call]))]
|
|
)
|
|
|
|
let result = try GeminiTranscriptTranslator.translate(transcript)
|
|
|
|
let modelParts = try #require(result.contents.first?.parts)
|
|
#expect(modelParts.count == 1)
|
|
guard case .functionCall(let functionCall) = modelParts[0].data else {
|
|
Issue.record("Expected functionCall part")
|
|
return
|
|
}
|
|
#expect(functionCall.id == "call-1")
|
|
#expect(functionCall.name == "get_time")
|
|
#expect(functionCall.args == nil)
|
|
}
|
|
|
|
@Test
|
|
@available(macOS 27.0, iOS 27.0, watchOS 27.0, visionOS 27.0, *)
|
|
func coalescesReasoningAndToolCallsIntoSingleModelTurn() throws {
|
|
let signatureData = Data("test-sig".utf8)
|
|
let call = Transcript.ToolCall(
|
|
id: "call-1",
|
|
toolName: "get_weather",
|
|
arguments: try GeneratedContent(json: "{\"location\":\"Paris\"}")
|
|
)
|
|
let transcript = Transcript(
|
|
entries: [
|
|
.prompt(Transcript.Prompt(segments: [.text(Transcript.TextSegment(content: "Hi."))])),
|
|
.reasoning(
|
|
Transcript.Reasoning(
|
|
segments: [.text(Transcript.TextSegment(content: "Need to check weather."))],
|
|
signature: signatureData
|
|
)
|
|
),
|
|
.toolCalls(Transcript.ToolCalls(id: "calls-1", [call])),
|
|
]
|
|
)
|
|
|
|
let result = try GeminiTranscriptTranslator.translate(transcript)
|
|
|
|
#expect(result.contents.count == 2)
|
|
#expect(result.contents[0].role == "user")
|
|
#expect(result.contents[1].role == "model")
|
|
let modelParts = try #require(result.contents[1].parts)
|
|
#expect(modelParts.count == 2)
|
|
#expect(modelParts[0].thought == true)
|
|
#expect(modelParts[0].thoughtSignature == nil)
|
|
#expect(modelParts[0].data == .text("Need to check weather."))
|
|
#expect(modelParts[1].thoughtSignature == "test-sig")
|
|
guard case .functionCall(let functionCall) = modelParts[1].data else {
|
|
Issue.record("Expected functionCall part")
|
|
return
|
|
}
|
|
#expect(functionCall.id == "call-1")
|
|
#expect(functionCall.name == "get_weather")
|
|
}
|
|
|
|
@Generable
|
|
@available(macOS 27.0, iOS 27.0, watchOS 27.0, visionOS 27.0, *)
|
|
@available(tvOS, unavailable)
|
|
struct City {
|
|
var name: String
|
|
}
|
|
|
|
@Test
|
|
@available(macOS 27.0, iOS 27.0, watchOS 27.0, visionOS 27.0, *)
|
|
func instructionsWithToolDefinitionsTranslatesCleanly() throws {
|
|
let toolDefinition = Transcript.ToolDefinition(
|
|
name: "get_weather",
|
|
description: "Get weather",
|
|
parameters: City.generationSchema
|
|
)
|
|
let transcript = Transcript(
|
|
entries: [
|
|
.instructions(
|
|
Transcript.Instructions(
|
|
segments: [.text(Transcript.TextSegment(content: "Be concise."))],
|
|
toolDefinitions: [toolDefinition]
|
|
)
|
|
),
|
|
.prompt(Transcript.Prompt(segments: [.text(Transcript.TextSegment(content: "Hello"))])),
|
|
]
|
|
)
|
|
|
|
let result = try GeminiTranscriptTranslator.translate(transcript)
|
|
|
|
let systemInstruction = try #require(result.systemInstruction)
|
|
#expect(systemInstruction.parts?.first?.data == Part.PartData.text("Be concise."))
|
|
}
|
|
|
|
@Test
|
|
@available(macOS 27.0, iOS 27.0, watchOS 27.0, visionOS 27.0, *)
|
|
func translatesToolOutputWithMultipleSegmentsIntoMultipleParts() throws {
|
|
let toolOutput = Transcript.ToolOutput(
|
|
id: "call-1",
|
|
toolName: "get_weather",
|
|
segments: [
|
|
.text(Transcript.TextSegment(content: "Part 1")),
|
|
.text(Transcript.TextSegment(content: "Part 2")),
|
|
]
|
|
)
|
|
let transcript = Transcript(entries: [.toolOutput(toolOutput)])
|
|
|
|
let result = try GeminiTranscriptTranslator.translate(transcript)
|
|
|
|
let userParts = try #require(result.contents.first?.parts)
|
|
#expect(userParts.count == 2)
|
|
guard case .functionResponse(let fr1) = userParts[0].data,
|
|
case .functionResponse(let fr2) = userParts[1].data
|
|
else {
|
|
Issue.record("Expected functionResponse part data for both segments")
|
|
return
|
|
}
|
|
#expect(fr1.id == "call-1")
|
|
#expect(fr1.name == "get_weather")
|
|
#expect(fr1.response?["result"] == .string("Part 1"))
|
|
#expect(fr2.id == "call-1")
|
|
#expect(fr2.name == "get_weather")
|
|
#expect(fr2.response?["result"] == .string("Part 2"))
|
|
}
|
|
|
|
@Test
|
|
@available(macOS 27.0, iOS 27.0, watchOS 27.0, visionOS 27.0, *)
|
|
func translatesToolOutputWithAttachmentThrowsUnsupportedTranscriptContent() throws {
|
|
let imageAttachment = Transcript.ImageAttachment(
|
|
imageURL: URL(fileURLWithPath: "/tmp/test.png")
|
|
)
|
|
let attachment = Transcript.Segment.attachment(
|
|
Transcript.AttachmentSegment(
|
|
id: "att-1",
|
|
content: .image(imageAttachment)
|
|
)
|
|
)
|
|
let toolOutput = Transcript.ToolOutput(
|
|
id: "call-1",
|
|
toolName: "get_weather",
|
|
segments: [
|
|
.text(Transcript.TextSegment(content: "Here is the image:")),
|
|
attachment,
|
|
]
|
|
)
|
|
let transcript = Transcript(entries: [.toolOutput(toolOutput)])
|
|
|
|
do {
|
|
_ = try GeminiTranscriptTranslator.translate(transcript)
|
|
Issue.record("Expected unsupportedTranscriptContent error")
|
|
} catch LanguageModelError.unsupportedTranscriptContent {
|
|
// Expected
|
|
} catch {
|
|
Issue.record("Unexpected error thrown: \(error)")
|
|
}
|
|
}
|
|
|
|
// `Transcript.Entry.data` was added in Xcode 27.2 (Swift 6.4.2+).
|
|
#if compiler(>=6.4.2)
|
|
@Test
|
|
@available(macOS 27.2, iOS 27.2, watchOS 27.2, visionOS 27.2, *)
|
|
@available(tvOS, unavailable)
|
|
func translatesDataEntryThrowsUnsupportedTranscriptContent() throws {
|
|
let dataEntry = Transcript.DataEntry(
|
|
id: "data-1",
|
|
contentType: .json,
|
|
content: Data(#"{"key":"value"}"#.utf8)
|
|
)
|
|
let transcript = Transcript(entries: [.data(dataEntry)])
|
|
|
|
do {
|
|
_ = try GeminiTranscriptTranslator.translate(transcript)
|
|
Issue.record("Expected unsupportedTranscriptContent error")
|
|
} catch LanguageModelError.unsupportedTranscriptContent(let context) {
|
|
#expect(context.unsupportedContent == [.data(dataEntry)])
|
|
} catch {
|
|
Issue.record("Unexpected error thrown: \(error)")
|
|
}
|
|
}
|
|
#endif // compiler(>=6.4.2)
|
|
|
|
@Test
|
|
@available(macOS 27.0, iOS 27.0, watchOS 27.0, visionOS 27.0, *)
|
|
func emptyToolCallsDoesNotDropPendingReasoningSignature() throws {
|
|
let signatureData = Data("test-sig".utf8)
|
|
let prompt = Transcript.Prompt(
|
|
segments: [.text(Transcript.TextSegment(content: "Hi"))]
|
|
)
|
|
let reasoning = Transcript.Reasoning(
|
|
segments: [.text(Transcript.TextSegment(content: "Thinking..."))],
|
|
signature: signatureData
|
|
)
|
|
let emptyToolCalls = Transcript.ToolCalls(id: "calls-empty", [])
|
|
let finalResponse = Transcript.Response(
|
|
segments: [.text(Transcript.TextSegment(content: "Hello!"))]
|
|
)
|
|
let transcript = Transcript(
|
|
entries: [
|
|
.prompt(prompt),
|
|
.reasoning(reasoning),
|
|
.toolCalls(emptyToolCalls),
|
|
.response(finalResponse),
|
|
]
|
|
)
|
|
|
|
let result = try GeminiTranscriptTranslator.translate(transcript)
|
|
|
|
let modelTurn = try #require(result.contents.last)
|
|
let modelParts = try #require(modelTurn.parts)
|
|
#expect(modelParts.first?.thoughtSignature == "test-sig")
|
|
}
|
|
}
|
|
#endif // canImport(FoundationModels) && compiler(>=6.4)
|