From 8a5aa7738bd750af1f2b196e9f2af816c29734aa Mon Sep 17 00:00:00 2001 From: Kamil Tomaszek Date: Tue, 6 Oct 2026 01:38:02 -0700 Subject: [PATCH] feat(tokt): carry live transcriptions and turnComplete across the interop PiperOrigin-RevId: 994231856 --- .../com/google/adk/tokt/codecs/EventCodec.kt | 11 +- .../adk/tokt/codecs/GroundingMetadataCodec.kt | 5 +- .../adk/tokt/codecs/LlmResponseCodec.kt | 14 +- .../adk/tokt/codecs/TranscriptionCodec.kt | 67 +++++ .../google/adk/tokt/KtRunnerInteropTest.kt | 246 ++++++++++++++++++ 5 files changed, 336 insertions(+), 7 deletions(-) create mode 100644 tokt/src/main/kotlin/com/google/adk/tokt/codecs/TranscriptionCodec.kt diff --git a/tokt/src/main/kotlin/com/google/adk/tokt/codecs/EventCodec.kt b/tokt/src/main/kotlin/com/google/adk/tokt/codecs/EventCodec.kt index 0e75fae53..f258a9175 100644 --- a/tokt/src/main/kotlin/com/google/adk/tokt/codecs/EventCodec.kt +++ b/tokt/src/main/kotlin/com/google/adk/tokt/codecs/EventCodec.kt @@ -33,8 +33,9 @@ import kotlin.jvm.optionals.getOrNull * skipSummarization, endOfAgent - the requested tool confirmations, the context-compaction summary, * the resumable-run `agentState`, and the rewind marker `rewindBeforeInvocationId`), the * streaming/error signals (`partial`, `turnComplete`, `interrupted`, `errorMessage`, `errorCode`, - * `finishReason`), the token `usageMetadata` ([UsageMetadataCodec]) and `groundingMetadata` - * ([GroundingMetadataCodec]), long-running tool ids, and the timestamp. + * `finishReason`), the live transcriptions ([TranscriptionCodec]), the token `usageMetadata` + * ([UsageMetadataCodec]) and `groundingMetadata` ([GroundingMetadataCodec]), long-running tool ids, + * and the timestamp. Every other field is dropped. */ internal object EventCodec { @@ -54,6 +55,8 @@ internal object EventCodec { if (event.partial) builder.partial(true) if (event.turnComplete) builder.turnComplete(true) if (event.interrupted) builder.interrupted(true) + event.inputTranscription?.let { builder.inputTranscription(TranscriptionCodec.toJava(it)) } + event.outputTranscription?.let { builder.outputTranscription(TranscriptionCodec.toJava(it)) } event.errorMessage?.let { builder.errorMessage(it) } event.errorCode?.let { builder.errorCode(GenaiFinishReason(it)) } event.finishReason?.let { builder.finishReason(it.toGenai()) } @@ -98,6 +101,10 @@ internal object EventCodec { partial = event.partial().getOrNull() ?: false, turnComplete = event.turnComplete().getOrNull() ?: false, interrupted = event.interrupted().getOrNull() ?: false, + inputTranscription = + event.inputTranscription().getOrNull()?.let { TranscriptionCodec.fromJava(it) }, + outputTranscription = + event.outputTranscription().getOrNull()?.let { TranscriptionCodec.fromJava(it) }, errorMessage = event.errorMessage().getOrNull(), // toString() preserves the raw value; knownEnum().name would collapse an unrecognized code to // FINISH_REASON_UNSPECIFIED (errorCode is a free-form String on the Kotlin side). diff --git a/tokt/src/main/kotlin/com/google/adk/tokt/codecs/GroundingMetadataCodec.kt b/tokt/src/main/kotlin/com/google/adk/tokt/codecs/GroundingMetadataCodec.kt index d0c1bf80a..65300b6cb 100644 --- a/tokt/src/main/kotlin/com/google/adk/tokt/codecs/GroundingMetadataCodec.kt +++ b/tokt/src/main/kotlin/com/google/adk/tokt/codecs/GroundingMetadataCodec.kt @@ -49,7 +49,7 @@ internal object GroundingMetadataCodec { /** Returns the Kotlin [KtGroundingMetadata] view of the genai [metadata]. */ fun fromJava(metadata: GenaiGroundingMetadata): KtGroundingMetadata = KtGroundingMetadata( - imageSearchQueries = metadata.imageSearchQueries().getOrNull().orEmpty(), + imageSearchQueries = metadata.imageSearchQueries().getOrNull(), groundingChunks = metadata.groundingChunks().getOrNull()?.map { chunkFromJava(it) }, groundingSupports = metadata.groundingSupports().getOrNull()?.map { supportFromJava(it) }, webSearchQueries = metadata.webSearchQueries().getOrNull(), @@ -62,7 +62,8 @@ internal object GroundingMetadataCodec { /** Returns the genai [GenaiGroundingMetadata] view of the Kotlin [metadata]. */ fun toJava(metadata: KtGroundingMetadata): GenaiGroundingMetadata { - val builder = GenaiGroundingMetadata.builder().imageSearchQueries(metadata.imageSearchQueries) + val builder = GenaiGroundingMetadata.builder() + metadata.imageSearchQueries?.let { builder.imageSearchQueries(it) } metadata.groundingChunks?.let { builder.groundingChunks(it.map { c -> chunkToJava(c) }) } metadata.groundingSupports?.let { builder.groundingSupports(it.map { s -> supportToJava(s) }) } metadata.webSearchQueries?.let { builder.webSearchQueries(it) } diff --git a/tokt/src/main/kotlin/com/google/adk/tokt/codecs/LlmResponseCodec.kt b/tokt/src/main/kotlin/com/google/adk/tokt/codecs/LlmResponseCodec.kt index c5544a737..b656b6534 100644 --- a/tokt/src/main/kotlin/com/google/adk/tokt/codecs/LlmResponseCodec.kt +++ b/tokt/src/main/kotlin/com/google/adk/tokt/codecs/LlmResponseCodec.kt @@ -27,9 +27,9 @@ import kotlin.jvm.optionals.getOrNull * ([JavaModelToKt][com.google.adk.tokt.adapters.JavaModelToKt]); Kotlin to Java hands a response to * a bridged Java plugin's `afterModel`. * - * Carries content, error message, streaming flags, model version, finish reason, usage metadata, - * grounding metadata, average log-probs, error code, and custom metadata. Citation metadata and - * logprobs results have no ADK Java facade field, so they are not mapped. + * Carries content, error message, streaming flags, `turnComplete`, the live transcriptions, model + * version, finish reason, usage metadata, grounding metadata, average log-probs, error code, and + * custom metadata. Every other field, such as citation metadata and logprobs results, is dropped. */ internal object LlmResponseCodec { @@ -40,6 +40,11 @@ internal object LlmResponseCodec { errorMessage = response.errorMessage().getOrNull(), partial = response.partial().getOrNull() ?: false, interrupted = response.interrupted().getOrNull() ?: false, + turnComplete = response.turnComplete().getOrNull(), + inputTranscription = + response.inputTranscription().getOrNull()?.let { TranscriptionCodec.fromJava(it) }, + outputTranscription = + response.outputTranscription().getOrNull()?.let { TranscriptionCodec.fromJava(it) }, modelVersion = response.modelVersion().getOrNull(), finishReason = response.finishReason().getOrNull()?.toKt(), usageMetadata = response.usageMetadata().getOrNull()?.let { UsageMetadataCodec.fromJava(it) }, @@ -58,6 +63,9 @@ internal object LlmResponseCodec { val builder = JavaLlmResponse.builder().partial(response.partial).interrupted(response.interrupted) response.content?.let { builder.content(ContentCodec.toJava(it)) } + response.turnComplete?.let { builder.turnComplete(it) } + response.inputTranscription?.let { builder.inputTranscription(TranscriptionCodec.toJava(it)) } + response.outputTranscription?.let { builder.outputTranscription(TranscriptionCodec.toJava(it)) } response.errorMessage?.let { builder.errorMessage(it) } response.modelVersion?.let { builder.modelVersion(it) } response.finishReason?.let { builder.finishReason(it.toGenai()) } diff --git a/tokt/src/main/kotlin/com/google/adk/tokt/codecs/TranscriptionCodec.kt b/tokt/src/main/kotlin/com/google/adk/tokt/codecs/TranscriptionCodec.kt new file mode 100644 index 000000000..82fc92bd1 --- /dev/null +++ b/tokt/src/main/kotlin/com/google/adk/tokt/codecs/TranscriptionCodec.kt @@ -0,0 +1,67 @@ +/* + * 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. + */ + +package com.google.adk.tokt.codecs + +import com.google.adk.kt.types.Transcription as KtTranscription +import com.google.adk.kt.types.WordInfo as KtWordInfo +import com.google.genai.types.Transcription as GenaiTranscription +import com.google.genai.types.WordInfo as GenaiWordInfo +import kotlin.jvm.optionals.getOrNull + +/** + * Converts an audio transcription, with its per-word timings, between the ADK Kotlin + * [KtTranscription] and the genai [GenaiTranscription] that ADK Java events and LLM responses + * carry. The genai `startOffset` and `endOffset` have no ADK Kotlin field, so they are dropped. + */ +internal object TranscriptionCodec { + + /** Returns the genai [GenaiTranscription] view of the Kotlin [transcription]. */ + fun toJava(transcription: KtTranscription): GenaiTranscription { + val builder = GenaiTranscription.builder() + transcription.text?.let { builder.text(it) } + transcription.finished?.let { builder.finished(it) } + transcription.languageCode?.let { builder.languageCode(it) } + transcription.speakerLabel?.let { builder.speakerLabel(it) } + transcription.words?.let { words -> builder.words(words.map(::wordToJava)) } + return builder.build() + } + + /** Returns the Kotlin [KtTranscription] view of the genai [transcription]. */ + fun fromJava(transcription: GenaiTranscription): KtTranscription = + KtTranscription( + text = transcription.text().getOrNull(), + finished = transcription.finished().getOrNull(), + languageCode = transcription.languageCode().getOrNull(), + speakerLabel = transcription.speakerLabel().getOrNull(), + words = transcription.words().getOrNull()?.map(::wordFromJava), + ) + + private fun wordToJava(word: KtWordInfo): GenaiWordInfo { + val builder = GenaiWordInfo.builder() + word.word?.let { builder.word(it) } + word.startOffset?.let { builder.startOffset(it) } + word.endOffset?.let { builder.endOffset(it) } + return builder.build() + } + + private fun wordFromJava(word: GenaiWordInfo): KtWordInfo = + KtWordInfo( + word = word.word().getOrNull(), + startOffset = word.startOffset().getOrNull(), + endOffset = word.endOffset().getOrNull(), + ) +} diff --git a/tokt/src/test/kotlin/com/google/adk/tokt/KtRunnerInteropTest.kt b/tokt/src/test/kotlin/com/google/adk/tokt/KtRunnerInteropTest.kt index b69dd387d..89325b232 100644 --- a/tokt/src/test/kotlin/com/google/adk/tokt/KtRunnerInteropTest.kt +++ b/tokt/src/test/kotlin/com/google/adk/tokt/KtRunnerInteropTest.kt @@ -37,6 +37,7 @@ import com.google.adk.kt.apps.App as KtApp import com.google.adk.kt.callbacks.AfterModelCallback as KtAfterModelCallback import com.google.adk.kt.events.Event as KtEvent import com.google.adk.kt.events.EventActions as KtEventActions +import com.google.adk.kt.models.LlmRequest as KtLlmRequest import com.google.adk.kt.models.LlmResponse as KtLlmResponse import com.google.adk.kt.runners.InMemoryRunner as KtInMemoryRunner import com.google.adk.kt.runners.Runner as KtRunner @@ -49,6 +50,8 @@ import com.google.adk.kt.sessions.State as KtState import com.google.adk.kt.tools.BaseTool as KtBaseTool import com.google.adk.kt.tools.ToolContext as KtToolContext import com.google.adk.kt.types.Blob as KtBlob +import com.google.adk.kt.types.Citation as KtCitation +import com.google.adk.kt.types.CitationMetadata as KtCitationMetadata import com.google.adk.kt.types.Content as KtContent import com.google.adk.kt.types.FileData as KtFileData import com.google.adk.kt.types.FinishReason as KtFinishReason @@ -60,9 +63,13 @@ import com.google.adk.kt.types.GenerationConfigRoutingConfig as KtRoutingConfig import com.google.adk.kt.types.GenerationConfigRoutingConfigManualRoutingMode as KtManualRoutingMode import com.google.adk.kt.types.GoogleMaps as KtGoogleMaps import com.google.adk.kt.types.GoogleSearch as KtGoogleSearch +import com.google.adk.kt.types.GroundingMetadata as KtGroundingMetadata import com.google.adk.kt.types.HarmBlockMethod as KtHarmBlockMethod import com.google.adk.kt.types.HarmBlockThreshold as KtHarmBlockThreshold import com.google.adk.kt.types.HarmCategory as KtHarmCategory +import com.google.adk.kt.types.InteractionStatus as KtInteractionStatus +import com.google.adk.kt.types.LiveServerGoAway as KtLiveServerGoAway +import com.google.adk.kt.types.LiveServerSessionResumptionUpdate as KtLiveServerSessionResumptionUpdate import com.google.adk.kt.types.MediaResolution as KtMediaResolution import com.google.adk.kt.types.Part as KtPart import com.google.adk.kt.types.PartialArgValue as KtPartialArgValue @@ -74,12 +81,16 @@ import com.google.adk.kt.types.ThinkingConfig as KtThinkingConfig import com.google.adk.kt.types.ThinkingLevel as KtThinkingLevel import com.google.adk.kt.types.Tool as KtTool import com.google.adk.kt.types.ToolConfig as KtToolConfig +import com.google.adk.kt.types.Transcription as KtTranscription +import com.google.adk.kt.types.TurnCompleteReason as KtTurnCompleteReason import com.google.adk.kt.types.Type as KtType import com.google.adk.kt.types.UrlContext as KtUrlContext import com.google.adk.kt.types.VertexAISearch as KtVertexAISearch import com.google.adk.kt.types.VertexAISearchDataStoreSpec as KtVertexAISearchDataStoreSpec import com.google.adk.kt.types.VertexRagStore as KtVertexRagStore import com.google.adk.kt.types.VertexRagStoreRagResource as KtVertexRagStoreRagResource +import com.google.adk.kt.types.VoiceActivity as KtVoiceActivity +import com.google.adk.kt.types.WordInfo as KtWordInfo import com.google.adk.memory.InMemoryMemoryService as JavaInMemoryMemoryService import com.google.adk.models.BaseLlm as JavaBaseLlm import com.google.adk.models.BaseLlmConnection as JavaBaseLlmConnection @@ -107,6 +118,7 @@ import com.google.adk.tokt.codecs.PartCodec import com.google.adk.tokt.codecs.RunConfigCodec import com.google.adk.tokt.codecs.SchemaCodec import com.google.adk.tokt.codecs.SessionCodec +import com.google.adk.tokt.codecs.TranscriptionCodec import com.google.adk.tokt.codecs.agentStateFromJava import com.google.adk.tokt.codecs.agentStateToJava import com.google.adk.tokt.codecs.stateValueFromJava @@ -145,7 +157,9 @@ import com.google.genai.types.ToolCall as GenaiToolCall import com.google.genai.types.ToolConfig as GenaiToolConfig import com.google.genai.types.ToolResponse as GenaiToolResponse import com.google.genai.types.TrafficType as GenaiTrafficType +import com.google.genai.types.Transcription as GenaiTranscription import com.google.genai.types.VideoMetadata as GenaiVideoMetadata +import com.google.genai.types.WordInfo as GenaiWordInfo import io.reactivex.rxjava3.core.Completable import io.reactivex.rxjava3.core.Flowable import io.reactivex.rxjava3.core.Maybe @@ -506,6 +520,19 @@ class KtRunnerInteropTest { } } + /** A native Kotlin agent emitting grounding metadata that has no image search queries. */ + private class GroundedKtAgent : KtBaseAgent(name = "grounded") { + override fun runAsyncImpl(context: KtInvocationContext): Flow = flow { + emit( + KtEvent( + invocationId = context.invocationId, + author = name, + groundingMetadata = KtGroundingMetadata(webSearchQueries = listOf("q")), + ) + ) + } + } + /** A Java plugin that requests an auth config the engine cannot represent, from before-agent. */ private class AuthConfigRequestingJavaPlugin : JavaBasePlugin("auth_config_plugin") { override fun beforeAgentCallback( @@ -4041,6 +4068,160 @@ class KtRunnerInteropTest { ) } + @Test + fun transcriptionCodec_everyField_convertsInBothDirections() { + // Built independently on each side, so the codec cannot agree with itself by accident. + assertEquals(genaiTranscription("in"), TranscriptionCodec.toJava(ktTranscription("in"))) + assertEquals(ktTranscription("in"), TranscriptionCodec.fromJava(genaiTranscription("in"))) + } + + @Test + fun eventCodec_liveTranscriptions_crossToTheJavaEventAndBack() { + // Wiring check: each slot crosses as itself, so a swap or a dropped slot fails. + val ktEvent = + KtEvent( + author = "model", + inputTranscription = ktTranscription("in"), + outputTranscription = ktTranscription("out"), + ) + + val javaEvent = EventCodec.toJava(ktEvent) + + assertEquals(Optional.of(genaiTranscription("in")), javaEvent.inputTranscription()) + assertEquals(Optional.of(genaiTranscription("out")), javaEvent.outputTranscription()) + val back = EventCodec.fromJava(javaEvent) + assertEquals(ktTranscription("in"), back.inputTranscription) + assertEquals(ktTranscription("out"), back.outputTranscription) + } + + @Test + fun eventCodec_fieldsAdkJavaLacks_leaveNoTraceOnTheJavaEvent() { + val base = KtEvent(author = "model") + val ktEvent = + base.copy( + citationMetadata = + KtCitationMetadata(citationSources = listOf(KtCitation(title = "t", uri = "u"))), + turnCompleteReason = KtTurnCompleteReason.NEED_MORE_INPUT, + interactionStatus = KtInteractionStatus.IDLE, + liveSessionId = "live-1", + liveSessionResumptionUpdate = + KtLiveServerSessionResumptionUpdate(newHandle = "handle", resumable = true), + voiceActivity = KtVoiceActivity(audioOffset = 1.seconds), + ) + + // A Kotlin-only field must not leak into a field ADK Java has. + assertEquals(EventCodec.toJava(base), EventCodec.toJava(ktEvent)) + } + + @Test + fun eventCodec_absentTranscriptions_stayAbsent() { + val javaEvent = EventCodec.toJava(KtEvent(author = "model")) + + assertEquals(Optional.empty(), javaEvent.inputTranscription()) + assertEquals(Optional.empty(), javaEvent.outputTranscription()) + assertNull(EventCodec.fromJava(javaEvent).inputTranscription) + assertNull(EventCodec.fromJava(javaEvent).outputTranscription) + } + + @Test + fun llmResponseCodec_turnCompleteAndTranscriptions_crossToTheJavaResponseAndBack() { + val ktResponse = + KtLlmResponse( + turnComplete = true, + inputTranscription = ktTranscription("in"), + outputTranscription = ktTranscription("out"), + ) + + val javaResponse = LlmResponseCodec.toJava(ktResponse) + + assertEquals(Optional.of(true), javaResponse.turnComplete()) + assertEquals(Optional.of(genaiTranscription("in")), javaResponse.inputTranscription()) + assertEquals(Optional.of(genaiTranscription("out")), javaResponse.outputTranscription()) + val back = LlmResponseCodec.fromJava(javaResponse) + assertEquals(true, back.turnComplete) + assertEquals(ktTranscription("in"), back.inputTranscription) + assertEquals(ktTranscription("out"), back.outputTranscription) + } + + @Test + fun llmResponseCodec_turnComplete_keepsFalseApartFromAbsent() { + assertEquals( + false, + LlmResponseCodec.fromJava(JavaLlmResponse.builder().turnComplete(false).build()).turnComplete, + ) + assertNull(LlmResponseCodec.fromJava(JavaLlmResponse.builder().build()).turnComplete) + assertEquals( + Optional.of(false), + LlmResponseCodec.toJava(KtLlmResponse(turnComplete = false)).turnComplete(), + ) + assertEquals(Optional.empty(), LlmResponseCodec.toJava(KtLlmResponse()).turnComplete()) + } + + @Test + fun llmResponseCodec_fieldsAdkJavaLacks_areDropped() { + val ktResponse = + KtLlmResponse( + turnComplete = true, + turnCompleteReason = KtTurnCompleteReason.NEED_MORE_INPUT, + interactionStatus = KtInteractionStatus.IDLE, + liveSessionId = "live-1", + liveSessionResumptionUpdate = KtLiveServerSessionResumptionUpdate(newHandle = "handle"), + goAway = KtLiveServerGoAway(timeLeft = 5.seconds), + voiceActivity = KtVoiceActivity(audioOffset = 1.seconds), + ) + + val back = LlmResponseCodec.fromJava(LlmResponseCodec.toJava(ktResponse)) + + assertEquals(KtLlmResponse(turnComplete = true), back) + } + + @Test + fun asKtModel_javaResponseWithTurnComplete_reachesTheKotlinEngine() = runBlocking { + // ADK Java sets turnComplete on live responses; a non-live Java model may set it too. + val javaModel = + object : JavaBaseLlm("turn-complete-model") { + override fun generateContent( + llmRequest: JavaLlmRequest, + stream: Boolean, + ): Flowable = + Flowable.just( + JavaLlmResponse.builder().content(modelText("done")).turnComplete(true).build() + ) + + override fun connect(llmRequest: JavaLlmRequest): JavaBaseLlmConnection = + throw UnsupportedOperationException() + } + + val responses = JavaAdkToKt.asKtModel(javaModel).generateContent(KtLlmRequest(), false).toList() + + assertEquals(true, responses.single().turnComplete) + } + + @Test + fun asKtSessionService_appendedTranscriptionEvent_isStoredWithItsTranscriptions() = runBlocking { + // Wiring check: a live run stores its transcription events through the adapted service. + val javaSessions = JavaInMemorySessionService() + val sessions = JavaAdkToKt.asKtSessionService(javaSessions) + val session = sessions.createSession(KtSessionKey("app", "u", "s")) + + val unused = + sessions.appendEvent( + session, + KtEvent( + author = "model", + inputTranscription = ktTranscription("in"), + outputTranscription = ktTranscription("out"), + ), + ) + + val stored = + javaSessions.getSession("app", "u", "s", Optional.empty()).blockingGet() + ?: fail("the adapted Java service should hold the session") + val event = stored.immutableEvents().single() + assertEquals(Optional.of(genaiTranscription("in")), event.inputTranscription()) + assertEquals(Optional.of(genaiTranscription("out")), event.outputTranscription()) + } + @Test fun schemaCodec_carriesEveryFacet_roundTrips() { // Every facet the Kotlin Schema models must survive Java -> Kotlin -> Java, or structured @@ -4134,6 +4315,42 @@ class KtRunnerInteropTest { assertEquals("the forecast", response.description().getOrNull(), "response description") } + @Test + fun groundingMetadataCodec_withoutImageSearchQueries_leavesThemUnset() { + // The Kotlin list is nullable, but the genai builder's setter rejects a null argument. + val kt = + GroundingMetadataCodec.fromJava( + GenaiGroundingMetadata.builder().webSearchQueries(listOf("q")).build() + ) + val java = GroundingMetadataCodec.toJava(KtGroundingMetadata(webSearchQueries = listOf("q"))) + + assertNull(kt.imageSearchQueries) + assertEquals(Optional.of(listOf("q")), java.webSearchQueries()) + assertEquals(Optional.empty(), java.imageSearchQueries()) + } + + @Test + fun asJavaRunner_groundingMetadataWithoutImageSearchQueries_streamsTheEvent() { + // The same metadata through the Java runAsync API, which converts every engine event. + val javaRunner = + KotlinAdkToJava.asJavaRunner(KtInMemoryRunner(agent = GroundedKtAgent(), appName = "app")) + + val events = + javaRunner + .runAsync( + "u", + "s", + GenaiContent.builder().role("user").parts(GenaiPart.fromText("hi")).build(), + JavaRunConfig.builder().autoCreateSession(true).build(), + ) + .toList() + .blockingGet() + + val metadata = assertNotNull(events.firstNotNullOfOrNull { it.groundingMetadata().getOrNull() }) + assertEquals(Optional.of(listOf("q")), metadata.webSearchQueries()) + assertEquals(Optional.empty(), metadata.imageSearchQueries()) + } + @Test fun groundingMetadataCodec_carriesMapsChunkText_roundTrip() { // A maps grounding chunk's text (the place answer) must survive Java -> Kotlin -> Java; the @@ -4816,5 +5033,34 @@ class KtRunnerInteropTest { fun modelText(text: String): GenaiContent = GenaiContent.builder().role("model").parts(GenaiPart.builder().text(text).build()).build() + + /** A transcription with every field set and two words, distinct per [text]. */ + fun ktTranscription(text: String) = + KtTranscription( + text = text, + finished = true, + languageCode = "en-US", + speakerLabel = "speaker-$text", + words = + listOf( + KtWordInfo(word = "$text-1", startOffset = "0.1s", endOffset = "0.4s"), + KtWordInfo(word = "$text-2", startOffset = "0.5s", endOffset = "0.9s"), + ), + ) + + /** The genai twin of [ktTranscription], built without the codec. */ + fun genaiTranscription(text: String): GenaiTranscription = + GenaiTranscription.builder() + .text(text) + .finished(true) + .languageCode("en-US") + .speakerLabel("speaker-$text") + .words( + listOf( + GenaiWordInfo.builder().word("$text-1").startOffset("0.1s").endOffset("0.4s").build(), + GenaiWordInfo.builder().word("$text-2").startOffset("0.5s").endOffset("0.9s").build(), + ) + ) + .build() } }