From 42a6a1c2c057d8f712fca17574f2a4e05cee78c2 Mon Sep 17 00:00:00 2001 From: Kamil Tomaszek Date: Thu, 15 Oct 2026 13:50:08 -0700 Subject: [PATCH] feat(tokt): merge a Java plugin's replacement onto the original PiperOrigin-RevId: 1000366430 --- .../adk/tokt/adapters/JavaPluginToKt.kt | 6 +- .../com/google/adk/tokt/codecs/EventCodec.kt | 38 +- .../adk/tokt/codecs/GroundingMetadataCodec.kt | 5 +- .../adk/tokt/codecs/LlmResponseCodec.kt | 44 +- .../adk/tokt/codecs/TranscriptionCodec.kt | 67 ++ .../google/adk/tokt/KtRunnerInteropTest.kt | 610 ++++++++++++++++++ 6 files changed, 761 insertions(+), 9 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/adapters/JavaPluginToKt.kt b/tokt/src/main/kotlin/com/google/adk/tokt/adapters/JavaPluginToKt.kt index 5f1b09d95..4b4f51cbb 100644 --- a/tokt/src/main/kotlin/com/google/adk/tokt/adapters/JavaPluginToKt.kt +++ b/tokt/src/main/kotlin/com/google/adk/tokt/adapters/JavaPluginToKt.kt @@ -32,6 +32,8 @@ import com.google.adk.tokt.codecs.ContentCodec import com.google.adk.tokt.codecs.EventCodec import com.google.adk.tokt.codecs.LlmRequestCodec import com.google.adk.tokt.codecs.LlmResponseCodec +import com.google.adk.tokt.codecs.reapplyJavaEvent +import com.google.adk.tokt.codecs.reapplyJavaResponse import com.google.adk.tokt.context.KtInvocationContextToJavaView import com.google.adk.tokt.context.javaAgentView import com.google.adk.tokt.context.ktCallbackContextToJava @@ -97,7 +99,7 @@ internal class JavaPluginToKt( override suspend fun onEvent(invocationContext: KtInvocationContext, event: KtEvent): KtEvent { val javaContext = KtInvocationContextToJavaView(invocationContext, dispatcher) val replacement = onDispatcher { plugin.onEventCallback(javaContext, EventCodec.toJava(event)) } - return replacement?.let { EventCodec.fromJava(it) } ?: event + return replacement?.let { reapplyJavaEvent(event, it) } ?: event } override suspend fun afterRun(invocationContext: KtInvocationContext) { @@ -157,7 +159,7 @@ internal class JavaPluginToKt( plugin.afterModelCallback(javaContext, LlmResponseCodec.toJava(response)) } reconcileActionsToKt(javaContext.eventActions(), context.eventActions) - return if (override != null) LlmResponseCodec.fromJava(override) else response + return if (override != null) reapplyJavaResponse(response, override) else response } override suspend fun onModelError( 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..dd6f6d605 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,10 @@ 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; [reapplyJavaEvent] keeps them when a Java plugin + * returns an event. */ internal object EventCodec { @@ -54,6 +56,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 +102,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). @@ -113,3 +121,29 @@ internal object EventCodec { ) } } + +/** + * Re-applies the event a Java plugin returned onto the original Kotlin [event]: fields [EventCodec] + * converts come from the result, and every other field keeps [event]'s value. `partial` and + * `turnComplete` keep [event]'s values when the result leaves them unset, as ADK Python does. + * `interactionStatus` stays only while the turn is complete. + */ +internal fun reapplyJavaEvent(event: KtEvent, javaResult: JavaEvent): KtEvent { + val result = EventCodec.fromJava(javaResult) + // fromJava reads unset flags as false, so check the Java values. + val turnComplete = javaResult.turnComplete().getOrNull() ?: event.turnComplete + return result.copy( + partial = javaResult.partial().getOrNull() ?: event.partial, + turnComplete = turnComplete, + actions = result.actions.copy(route = event.actions.route), + citationMetadata = event.citationMetadata, + cacheMetadata = event.cacheMetadata, + output = event.output, + nodeInfo = event.nodeInfo, + turnCompleteReason = event.turnCompleteReason, + interactionStatus = event.interactionStatus.takeIf { turnComplete }, + liveSessionId = event.liveSessionId, + liveSessionResumptionUpdate = event.liveSessionResumptionUpdate, + voiceActivity = event.voiceActivity, + ) +} 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..d8752348b 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,10 @@ 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; + * [reapplyJavaResponse] keeps them when a Java plugin returns a response. */ internal object LlmResponseCodec { @@ -40,6 +41,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 +64,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()) } @@ -69,3 +78,32 @@ internal object LlmResponseCodec { return builder.build() } } + +/** + * Re-applies the response a Java plugin returned onto the original Kotlin [response]: fields + * [LlmResponseCodec] converts come from the result, and every other field keeps [response]'s value. + * `partial`, `turnComplete` and `usageMetadata` keep [response]'s values when the result leaves + * them unset, as ADK Python does. `interactionStatus` stays only while the turn is complete. + */ +internal fun reapplyJavaResponse( + response: KtLlmResponse, + javaResult: JavaLlmResponse, +): KtLlmResponse { + val result = LlmResponseCodec.fromJava(javaResult) + val turnComplete = result.turnComplete ?: response.turnComplete + return result.copy( + // fromJava reads an unset partial as false, so check the Java value. + partial = javaResult.partial().getOrNull() ?: response.partial, + turnComplete = turnComplete, + usageMetadata = result.usageMetadata ?: response.usageMetadata, + citationMetadata = response.citationMetadata, + logprobsResult = response.logprobsResult, + cacheMetadata = response.cacheMetadata, + turnCompleteReason = response.turnCompleteReason, + interactionStatus = response.interactionStatus.takeIf { turnComplete == true }, + liveSessionId = response.liveSessionId, + liveSessionResumptionUpdate = response.liveSessionResumptionUpdate, + goAway = response.goAway, + voiceActivity = response.voiceActivity, + ) +} 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..dc79ae724 100644 --- a/tokt/src/test/kotlin/com/google/adk/tokt/KtRunnerInteropTest.kt +++ b/tokt/src/test/kotlin/com/google/adk/tokt/KtRunnerInteropTest.kt @@ -27,6 +27,7 @@ import com.google.adk.events.Event as JavaEvent import com.google.adk.events.EventActions as JavaEventActions import com.google.adk.events.EventCompaction as JavaEventCompaction import com.google.adk.kt.agents.BaseAgent as KtBaseAgent +import com.google.adk.kt.agents.CallbackContext as KtCallbackContext import com.google.adk.kt.agents.InvocationContext as KtInvocationContext import com.google.adk.kt.agents.LlmAgent as KtLlmAgent import com.google.adk.kt.agents.ResumabilityConfig as KtResumabilityConfig @@ -37,6 +38,9 @@ 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.events.EventCompaction as KtEventCompaction +import com.google.adk.kt.models.CacheMetadata as KtCacheMetadata +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 +53,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 +66,14 @@ 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.LogprobsResult as KtLogprobsResult 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 +85,18 @@ 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.kt.workflow.NodeInfo as KtNodeInfo +import com.google.adk.kt.workflow.Route as KtRoute import com.google.adk.memory.InMemoryMemoryService as JavaInMemoryMemoryService import com.google.adk.models.BaseLlm as JavaBaseLlm import com.google.adk.models.BaseLlmConnection as JavaBaseLlmConnection @@ -96,6 +113,7 @@ import com.google.adk.sessions.Session as JavaSession import com.google.adk.sessions.SessionKey as JavaSessionKey import com.google.adk.sessions.State as JavaState import com.google.adk.tokt.adapters.reconcileActionsToKt +import com.google.adk.tokt.codecs.ContentCodec import com.google.adk.tokt.codecs.EventCodec import com.google.adk.tokt.codecs.FunctionDeclarationCodec import com.google.adk.tokt.codecs.GenerateContentConfigCodec @@ -107,8 +125,12 @@ 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.UsageMetadataCodec import com.google.adk.tokt.codecs.agentStateFromJava import com.google.adk.tokt.codecs.agentStateToJava +import com.google.adk.tokt.codecs.reapplyJavaEvent +import com.google.adk.tokt.codecs.reapplyJavaResponse import com.google.adk.tokt.codecs.stateValueFromJava import com.google.adk.tokt.codecs.typedDataFromJava import com.google.adk.tools.BaseTool as JavaBaseTool @@ -145,11 +167,14 @@ 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 import io.reactivex.rxjava3.core.Single +import java.lang.reflect.Modifier import java.time.Instant import java.util.Optional import java.util.concurrent.ConcurrentHashMap @@ -506,6 +531,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 +4079,468 @@ 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 reapplyJavaEvent_unchangedJavaEvent_restoresEveryUnconvertedField() { + val original = + KtEvent( + author = "model", + invocationId = "inv-1", + turnComplete = true, + content = KtContent(role = "model", parts = listOf(KtPart(text = "hi"))), + actions = + KtEventActions( + transferToAgent = "child", + rewindBeforeInvocationId = "inv-0", + route = listOf(KtRoute.Tag("next")), + agentState = TypedData.MapValue(mapOf("phase" to TypedData.IntValue(2))), + compaction = + KtEventCompaction( + startTimestamp = 1_000L, + endTimestamp = 2_000L, + compactedContent = + KtContent(role = "model", parts = listOf(KtPart(text = "summary"))), + ), + ), + errorCode = "MAX_TOKENS", + errorMessage = "cut short", + finishReason = KtFinishReason.STOP, + usageMetadata = javaRoundTripUsage(), + avgLogProbs = -0.5, + branch = "root.child", + groundingMetadata = javaRoundTripGrounding(), + modelVersion = "model-1", + customMetadata = mapOf("k" to "v"), + inputTranscription = ktTranscription("in"), + outputTranscription = ktTranscription("out"), + citationMetadata = + KtCitationMetadata(citationSources = listOf(KtCitation(title = "t", uri = "u"))), + cacheMetadata = KtCacheMetadata(fingerprint = "f", contentsCount = 2), + output = "the node's output", + nodeInfo = KtNodeInfo(path = "root/child"), + turnCompleteReason = KtTurnCompleteReason.NEED_MORE_INPUT, + interactionStatus = KtInteractionStatus.IDLE, + liveSessionId = "live-1", + liveSessionResumptionUpdate = KtLiveServerSessionResumptionUpdate(newHandle = "handle"), + voiceActivity = KtVoiceActivity(audioOffset = 1.seconds), + ) + + // Every nullable field is set, so a field the re-apply forgets fails the last assertion. + assertEquals(emptyList(), unsetFieldsOf(original), "set every nullable KtEvent field") + assertEquals( + emptyList(), + unsetFieldsOf(original.actions), + "set every nullable KtEventActions field", + ) + assertEquals(original, reapplyJavaEvent(original, EventCodec.toJava(original))) + } + + @Test + fun reapplyJavaResponse_unchangedJavaResponse_restoresEveryUnconvertedField() { + val original = + KtLlmResponse( + content = KtContent(role = "model", parts = listOf(KtPart(text = "hi"))), + usageMetadata = javaRoundTripUsage(), + finishReason = KtFinishReason.STOP, + errorMessage = "cut short", + modelVersion = "model-1", + groundingMetadata = javaRoundTripGrounding(), + errorCode = "MAX_TOKENS", + customMetadata = mapOf("k" to "v"), + avgLogprobs = -0.5, + turnComplete = true, + inputTranscription = ktTranscription("in"), + outputTranscription = ktTranscription("out"), + citationMetadata = + KtCitationMetadata(citationSources = listOf(KtCitation(title = "t", uri = "u"))), + logprobsResult = KtLogprobsResult(logProbabilitySum = -1.5), + cacheMetadata = KtCacheMetadata(fingerprint = "f", contentsCount = 2), + 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), + ) + + assertEquals(emptyList(), unsetFieldsOf(original), "set every nullable KtLlmResponse field") + assertEquals(original, reapplyJavaResponse(original, LlmResponseCodec.toJava(original))) + } + + /** The names of [value]'s null fields, so a fixture can prove it sets every nullable one. */ + private fun unsetFieldsOf(value: Any): List = + value.javaClass.declaredFields + .filterNot { Modifier.isStatic(it.modifiers) } + .onEach { it.isAccessible = true } + .filter { it.get(value) == null } + .map { it.name } + + /** Usage metadata as the Java codec produces it, so it survives the round trip unchanged. */ + private fun javaRoundTripUsage() = + UsageMetadataCodec.fromJava(GenaiUsageMetadata.builder().promptTokenCount(3).build()) + + /** Grounding metadata as the Java codec produces it, so it survives the round trip unchanged. */ + private fun javaRoundTripGrounding() = + GroundingMetadataCodec.fromJava( + GenaiGroundingMetadata.builder() + .webSearchQueries(listOf("q")) + .imageSearchQueries(listOf("i")) + .build() + ) + + @Test + fun javaPluginAfterModel_override_keepsTheUnconvertedFields() = runBlocking { + val plugin = ResponseOverridingJavaPlugin { + it.toBuilder().content(modelText("replaced")).build() + } + val response = + KtLlmResponse( + content = ContentCodec.fromJava(modelText("original")), + goAway = KtLiveServerGoAway(timeLeft = 5.seconds), + liveSessionResumptionUpdate = KtLiveServerSessionResumptionUpdate(newHandle = "handle"), + ) + + val result = JavaAdkToKt.asKtPlugin(plugin).afterModel(ktCallbackContext(), response) + + assertEquals(ContentCodec.fromJava(modelText("replaced")), result.content) + assertEquals(response.goAway, result.goAway) + assertEquals(response.liveSessionResumptionUpdate, result.liveSessionResumptionUpdate) + } + + @Test + fun javaPluginAfterModel_override_takesTheValuesJavaChanged() = runBlocking { + val plugin = ResponseOverridingJavaPlugin { + it.toBuilder().content(modelText("replaced")).turnComplete(false).build() + } + val response = + KtLlmResponse(content = ContentCodec.fromJava(modelText("original")), turnComplete = true) + + val result = JavaAdkToKt.asKtPlugin(plugin).afterModel(ktCallbackContext(), response) + + assertEquals(ContentCodec.fromJava(modelText("replaced")), result.content) + assertEquals(false, result.turnComplete) + } + + @Test + fun javaPluginAfterModel_freshResponse_inheritsPartialTurnCompleteAndUsage() = runBlocking { + val plugin = ResponseOverridingJavaPlugin { + JavaLlmResponse.builder().content(modelText("replaced")).build() + } + val response = + KtLlmResponse( + content = ContentCodec.fromJava(modelText("original")), + partial = true, + turnComplete = true, + usageMetadata = javaRoundTripUsage(), + ) + + val result = JavaAdkToKt.asKtPlugin(plugin).afterModel(ktCallbackContext(), response) + + assertEquals(ContentCodec.fromJava(modelText("replaced")), result.content) + assertEquals(true, result.partial) + assertEquals(true, result.turnComplete) + assertEquals(response.usageMetadata, result.usageMetadata) + } + + @Test + fun javaPluginAfterModel_overrideThatReopensTheTurn_keepsTheReasonAndDropsTheStatus() = + runBlocking { + val plugin = ResponseOverridingJavaPlugin { it.toBuilder().turnComplete(false).build() } + val response = + KtLlmResponse( + turnComplete = true, + turnCompleteReason = KtTurnCompleteReason.NEED_MORE_INPUT, + interactionStatus = KtInteractionStatus.IDLE, + ) + + val result = JavaAdkToKt.asKtPlugin(plugin).afterModel(ktCallbackContext(), response) + + assertEquals(false, result.turnComplete) + assertEquals(KtTurnCompleteReason.NEED_MORE_INPUT, result.turnCompleteReason) + assertNull(result.interactionStatus) + } + + @Test + fun javaPluginAfterModel_unchangedContentResponse_keepsTheTurnCompleteReason() = runBlocking { + // Core puts the reason on content responses whose turnComplete is unset. + val plugin = ResponseOverridingJavaPlugin { it } + val response = + KtLlmResponse( + content = ContentCodec.fromJava(modelText("original")), + turnCompleteReason = KtTurnCompleteReason.NEED_MORE_INPUT, + ) + + val result = JavaAdkToKt.asKtPlugin(plugin).afterModel(ktCallbackContext(), response) + + assertNull(result.turnComplete) + assertEquals(KtTurnCompleteReason.NEED_MORE_INPUT, result.turnCompleteReason) + } + + @Test + fun javaPluginOnEvent_replacementThatReopensTheTurn_keepsTheReasonAndDropsTheStatus() = + runBlocking { + val plugin = EventOverridingJavaPlugin { it.toBuilder().turnComplete(false).build() } + val event = + KtEvent( + author = "model", + turnComplete = true, + turnCompleteReason = KtTurnCompleteReason.NEED_MORE_INPUT, + interactionStatus = KtInteractionStatus.IDLE, + ) + + val result = JavaAdkToKt.asKtPlugin(plugin).onEvent(ktInvocationContext(), event) + + assertEquals(false, result.turnComplete) + assertEquals(KtTurnCompleteReason.NEED_MORE_INPUT, result.turnCompleteReason) + assertNull(result.interactionStatus) + } + + @Test + fun javaPluginOnEvent_replacement_takesItsContentAndKeepsTheVoiceActivity() = runBlocking { + val plugin = EventOverridingJavaPlugin { it.toBuilder().content(modelText("replaced")).build() } + val event = + KtEvent( + author = "model", + content = ContentCodec.fromJava(modelText("original")), + voiceActivity = KtVoiceActivity(audioOffset = 1.seconds), + ) + + val result = JavaAdkToKt.asKtPlugin(plugin).onEvent(ktInvocationContext(), event) + + assertEquals(ContentCodec.fromJava(modelText("replaced")), result.content) + assertEquals(event.voiceActivity, result.voiceActivity) + } + + @Test + fun javaPluginOnEvent_newEvent_keepsTheUnconvertedFields() = runBlocking { + // EventCodec doesn't convert output or route, so a new event keeps the original's. + val plugin = EventOverridingJavaPlugin { + JavaEvent.builder().id("other").author("model").content(modelText("replaced")).build() + } + val event = + KtEvent( + author = "model", + content = ContentCodec.fromJava(modelText("original")), + actions = KtEventActions(route = listOf(KtRoute.Tag("next"))), + output = "the node's output", + ) + + val result = JavaAdkToKt.asKtPlugin(plugin).onEvent(ktInvocationContext(), event) + + assertEquals(ContentCodec.fromJava(modelText("replaced")), result.content) + assertEquals("the node's output", result.output) + assertEquals(listOf(KtRoute.Tag("next")), result.actions.route) + } + + @Test + fun javaPluginOnEvent_freshEvent_inheritsPartialAndTurnComplete() = runBlocking { + val plugin = EventOverridingJavaPlugin { + JavaEvent.builder().author("model").content(modelText("replaced")).build() + } + val event = + KtEvent( + author = "model", + content = ContentCodec.fromJava(modelText("original")), + partial = true, + turnComplete = true, + ) + + val result = JavaAdkToKt.asKtPlugin(plugin).onEvent(ktInvocationContext(), event) + + assertEquals(ContentCodec.fromJava(modelText("replaced")), result.content) + assertTrue(result.partial) + assertTrue(result.turnComplete) + } + + @Test + fun ktRunner_javaPluginReplacingAnEvent_keepsTheUnconvertedFields() = runBlocking { + val plugin = EventOverridingJavaPlugin { it.toBuilder().content(modelText("replaced")).build() } + val runner = + KtInMemoryRunner( + app = + KtApp( + appName = "app", + rootAgent = VoiceActivityKtAgent(), + plugins = listOf(JavaAdkToKt.asKtPlugin(plugin)), + ) + ) + + val emitted = runner.turn().single { it.author == "voice" } + val stored = + runner.sessionService.getSession(KtSessionKey("app", "u", "s"))!!.events.single { + it.author == "voice" + } + + for (event in listOf(emitted, stored)) { + assertEquals(ContentCodec.fromJava(modelText("replaced")), event.content) + assertEquals(KtVoiceActivity(audioOffset = 1.seconds), event.voiceActivity) + assertEquals("the agent's output", event.output) + } + } + + @Test + fun javaPlugin_withoutAnOverride_leavesTheOriginalUntouched() = runBlocking { + val plugin = JavaAdkToKt.asKtPlugin(ResponseOverridingJavaPlugin { null }) + val response = KtLlmResponse(goAway = KtLiveServerGoAway(timeLeft = 5.seconds)) + val event = KtEvent(author = "model", voiceActivity = KtVoiceActivity(audioOffset = 1.seconds)) + + assertSame(response, plugin.afterModel(ktCallbackContext(), response)) + assertSame(event, plugin.onEvent(ktInvocationContext(), event)) + } + @Test fun schemaCodec_carriesEveryFacet_roundTrips() { // Every facet the Kotlin Schema models must survive Java -> Kotlin -> Java, or structured @@ -4134,6 +4634,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 +5352,79 @@ 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() + + /** A Kotlin invocation context on a fresh session, for driving a plugin adapter directly. */ + fun ktInvocationContext() = + KtInvocationContext( + session = KtSession(KtSessionKey("app", "u", "s")), + agent = + KtLlmAgent(name = "a", model = JavaAdkToKt.asKtModel(SequentialJavaModel(emptyList()))), + ) + + /** A Kotlin callback context over [ktInvocationContext]. */ + fun ktCallbackContext() = KtCallbackContext(ktInvocationContext()) + } + + /** A Java plugin whose afterModel returns what [override] makes of the response, or nothing. */ + private class ResponseOverridingJavaPlugin( + private val override: (JavaLlmResponse) -> JavaLlmResponse? + ) : JavaBasePlugin("response_overriding_plugin") { + override fun afterModelCallback( + callbackContext: JavaCallbackContext, + llmResponse: JavaLlmResponse, + ): Maybe = override(llmResponse)?.let { Maybe.just(it) } ?: Maybe.empty() + } + + /** A Java plugin whose onEvent returns what [override] makes of the event, or nothing. */ + private class EventOverridingJavaPlugin(private val override: (JavaEvent) -> JavaEvent?) : + JavaBasePlugin("event_overriding_plugin") { + override fun onEventCallback( + invocationContext: JavaInvocationContext, + event: JavaEvent, + ): Maybe = override(event)?.let { Maybe.just(it) } ?: Maybe.empty() + } + + /** A native Kotlin agent emitting one event with fields [EventCodec] doesn't convert. */ + private class VoiceActivityKtAgent : KtBaseAgent(name = "voice") { + override fun runAsyncImpl(context: KtInvocationContext): Flow = flow { + emit( + KtEvent( + invocationId = context.invocationId, + author = name, + content = ContentCodec.fromJava(modelText("original")), + output = "the agent's output", + voiceActivity = KtVoiceActivity(audioOffset = 1.seconds), + ) + ) + } } }