From 1ebdaf4889121edfe868df2c84a16938738cc422 Mon Sep 17 00:00:00 2001 From: Kamil Tomaszek Date: Tue, 6 Oct 2026 01:38:01 -0700 Subject: [PATCH] refactor(tokt): prepare ADK Java run settings conversion for live runs PiperOrigin-RevId: 994231843 --- .../adk/tokt/adapters/JavaPluginToKt.kt | 6 +- .../codecs/AudioTranscriptionConfigCodec.kt | 66 ++ .../com/google/adk/tokt/codecs/EventCodec.kt | 40 +- .../adk/tokt/codecs/GroundingMetadataCodec.kt | 5 +- .../adk/tokt/codecs/LlmResponseCodec.kt | 44 +- .../google/adk/tokt/codecs/RunConfigCodec.kt | 139 ++- .../adk/tokt/codecs/SpeechConfigCodec.kt | 132 +++ .../adk/tokt/codecs/TranscriptionCodec.kt | 67 ++ .../google/adk/tokt/KtRunnerInteropTest.kt | 1019 +++++++++++++++++ 9 files changed, 1487 insertions(+), 31 deletions(-) create mode 100644 tokt/src/main/kotlin/com/google/adk/tokt/codecs/AudioTranscriptionConfigCodec.kt create mode 100644 tokt/src/main/kotlin/com/google/adk/tokt/codecs/SpeechConfigCodec.kt 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/AudioTranscriptionConfigCodec.kt b/tokt/src/main/kotlin/com/google/adk/tokt/codecs/AudioTranscriptionConfigCodec.kt new file mode 100644 index 000000000..b75755855 --- /dev/null +++ b/tokt/src/main/kotlin/com/google/adk/tokt/codecs/AudioTranscriptionConfigCodec.kt @@ -0,0 +1,66 @@ +/* + * 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.AudioTranscriptionConfig as KtAudioTranscriptionConfig +import com.google.adk.kt.types.AudioTranscriptionConfigMode as KtAudioTranscriptionConfigMode +import com.google.genai.types.AudioTranscriptionConfig as GenaiAudioTranscriptionConfig +import com.google.genai.types.AudioTranscriptionConfigMode as GenaiAudioTranscriptionConfigMode +import kotlin.jvm.optionals.getOrNull + +/** + * Converts a live transcription setting between the ADK Kotlin [KtAudioTranscriptionConfig] and the + * genai [GenaiAudioTranscriptionConfig] that the ADK Java `RunConfig` carries. The genai-only + * fields, and a `mode` Kotlin has no constant for, cannot cross, so [unsupportedFields] names the + * ones a Java caller set. + */ +internal object AudioTranscriptionConfigCodec { + + /** Returns the genai [GenaiAudioTranscriptionConfig] view of the Kotlin [config]. */ + fun toJava(config: KtAudioTranscriptionConfig): GenaiAudioTranscriptionConfig { + val builder = GenaiAudioTranscriptionConfig.builder() + config.languageCodes?.let { builder.languageCodes(it) } + config.customVocabulary?.let { builder.customVocabulary(it) } + config.diarization?.let { builder.diarization(it) } + config.wordTimestamp?.let { builder.wordTimestamp(it) } + config.mode?.let { builder.mode(GenaiAudioTranscriptionConfigMode(it.name)) } + return builder.build() + } + + /** Returns the Kotlin [KtAudioTranscriptionConfig] view of the genai [config]. */ + fun fromJava(config: GenaiAudioTranscriptionConfig): KtAudioTranscriptionConfig = + KtAudioTranscriptionConfig( + languageCodes = config.languageCodes().getOrNull(), + customVocabulary = config.customVocabulary().getOrNull(), + diarization = config.diarization().getOrNull(), + wordTimestamp = config.wordTimestamp().getOrNull(), + mode = config.mode().getOrNull()?.let(::modeFromJava), + ) + + /** The fields set on [config] that [fromJava] cannot carry. */ + fun unsupportedFields(config: GenaiAudioTranscriptionConfig): List = buildList { + if (config.languageAuto().isPresent) add("languageAuto") + if (config.languageHints().isPresent) add("languageHints") + if (config.adaptationPhrases().isPresent) add("adaptationPhrases") + config.mode().getOrNull()?.let { if (modeFromJava(it) == null) add("mode") } + } + + private fun modeFromJava( + mode: GenaiAudioTranscriptionConfigMode + ): KtAudioTranscriptionConfigMode? = + enumByNameOrNull(mode.knownEnum().name) +} 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..0c6416bfe 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]), `groundingMetadata` ([GroundingMetadataCodec]), `avgLogProbs`, + * `modelVersion`, `customMetadata`, 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,31 @@ 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`, + * `turnComplete`, `interrupted` and `usageMetadata` 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, + interrupted = javaResult.interrupted().getOrNull() ?: event.interrupted, + usageMetadata = result.usageMetadata ?: event.usageMetadata, + 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/RunConfigCodec.kt b/tokt/src/main/kotlin/com/google/adk/tokt/codecs/RunConfigCodec.kt index dfe1bf825..f0e03fd15 100644 --- a/tokt/src/main/kotlin/com/google/adk/tokt/codecs/RunConfigCodec.kt +++ b/tokt/src/main/kotlin/com/google/adk/tokt/codecs/RunConfigCodec.kt @@ -17,47 +17,69 @@ package com.google.adk.tokt.codecs import com.google.adk.agents.RunConfig as JavaRunConfig +import com.google.adk.kt.agents.LiveRunConfig as KtLiveRunConfig import com.google.adk.kt.agents.RunConfig as KtRunConfig import com.google.adk.kt.agents.StreamingMode as KtStreamingMode +import com.google.adk.kt.annotations.ExperimentalLiveApi +import com.google.adk.kt.types.AudioTranscriptionConfig as KtAudioTranscriptionConfig +import com.google.adk.kt.types.Modality as KtModality +import com.google.genai.types.Modality as GenaiModality /** * Converts between the Kotlin `kt.agents.RunConfig` and the ADK Java [JavaRunConfig]. * - * Only three settings cross both ways: streaming mode, the LLM call budget, and custom metadata. - * [toJava] leaves the Java-only settings at their defaults; [fromJava] instead rejects a Java - * setting the engine cannot honor rather than dropping it silently. + * Streaming mode, the LLM call budget and custom metadata cross both ways; [toJava] also shows a + * BIDI run's live settings (modalities, speech, transcriptions), and [fromJavaLive] reads them for + * a live run. [fromJava] and [fromJavaLive] reject a Java setting the engine cannot honor rather + * than dropping it silently. */ internal object RunConfigCodec { - /** Returns the Java [JavaRunConfig] view of the Kotlin [config]. */ - fun toJava(config: KtRunConfig): JavaRunConfig = - JavaRunConfig.builder() - // Kotlin has no BIDI, so a by-name match always resolves; NONE is the shared default. - .streamingMode( - enumByNameOrNull(config.streamingMode.name) - ?: JavaRunConfig.StreamingMode.NONE - ) - .maxLlmCalls(config.maxLlmCalls) - .customMetadata(config.customMetadata.orEmpty()) - .build() + /** + * Returns the Java [JavaRunConfig] view of the Kotlin [config]. Only a BIDI run shows live + * settings, since Kotlin turns transcription on for every run. + */ + fun toJava(config: KtRunConfig): JavaRunConfig { + val builder = + JavaRunConfig.builder() + .streamingMode( + when (config.streamingMode) { + KtStreamingMode.NONE -> JavaRunConfig.StreamingMode.NONE + KtStreamingMode.SSE -> JavaRunConfig.StreamingMode.SSE + KtStreamingMode.BIDI -> JavaRunConfig.StreamingMode.BIDI + } + ) + .maxLlmCalls(config.maxLlmCalls) + .customMetadata(config.customMetadata.orEmpty()) + if (config.streamingMode == KtStreamingMode.BIDI) { + builder + // ADK Java and Python default a live run's response modalities to AUDIO. + .responseModalities( + config.live.responseModalities + .ifEmpty { listOf(KtModality.AUDIO) } + .map { GenaiModality(it.name) } + ) + .speechConfig(config.live.speechConfig?.let { SpeechConfigCodec.toJava(it) }) + .outputAudioTranscription( + config.live.outputAudioTranscription?.let { AudioTranscriptionConfigCodec.toJava(it) } + ) + .inputAudioTranscription( + config.live.inputAudioTranscription?.let { AudioTranscriptionConfigCodec.toJava(it) } + ) + } + return builder.build() + } /** Returns the Kotlin [KtRunConfig] view of the Java [config]. */ - @Suppress( - "deprecation" - ) // Reads the deprecated groupFunctionResponsesInHistoryOverride to reject it. fun fromJava(config: JavaRunConfig): KtRunConfig { val unsupported = buildList { if (config.streamingMode() == JavaRunConfig.StreamingMode.BIDI) add("streamingMode=BIDI") if (config.saveInputBlobsAsArtifacts()) add("saveInputBlobsAsArtifacts") - if (config.toolExecutionMode() != JavaRunConfig.ToolExecutionMode.NONE) - add("toolExecutionMode") + addUnsupportedByEveryRun(config) if (config.responseModalities().isNotEmpty()) add("responseModalities") if (config.speechConfig() != null) add("speechConfig") - if (config.avatarConfig() != null) add("avatarConfig") if (config.outputAudioTranscription() != null) add("outputAudioTranscription") if (config.inputAudioTranscription() != null) add("inputAudioTranscription") - if (config.groupFunctionResponsesInHistoryOverride().isPresent) - add("groupFunctionResponsesInHistoryOverride") } require(unsupported.isEmpty()) { "RunConfig settings not supported by the ADK Kotlin engine: $unsupported" @@ -70,4 +92,77 @@ internal object RunConfigCodec { customMetadata = config.customMetadata().takeIf { it.isNotEmpty() }, ) } + + /** + * Returns the Kotlin [KtRunConfig] for a live run, applying the ADK Java runner's live defaults: + * no modality means AUDIO, and a missing transcription setting turns transcription on, except + * output transcription when the modalities include text. Rejects a Java setting the engine cannot + * honor, as [fromJava] does, but ignores `saveInputBlobsAsArtifacts`, which ADK Java's own live + * run never reads. + */ + @OptIn(ExperimentalLiveApi::class) + fun fromJavaLive(config: JavaRunConfig): KtRunConfig { + val output = config.outputAudioTranscription() + val input = config.inputAudioTranscription() + val unsupported = buildList { + addUnsupportedByEveryRun(config) + output?.let { + addAll( + AudioTranscriptionConfigCodec.unsupportedFields(it).map { "outputAudioTranscription.$it" } + ) + } + input?.let { + addAll( + AudioTranscriptionConfigCodec.unsupportedFields(it).map { "inputAudioTranscription.$it" } + ) + } + } + require(unsupported.isEmpty()) { + "RunConfig settings not supported by the ADK Kotlin engine: $unsupported" + } + val modalities = + config.responseModalities().map { modality -> + // genai reads any name it does not know as MODALITY_UNSPECIFIED. + requireNotNull( + enumByNameOrNull(modality.knownEnum().name)?.takeIf { + it != KtModality.MODALITY_UNSPECIFIED + } + ) { + "Response modality not supported by the ADK Kotlin engine" + } + } + // The ADK Java runner's live defaults (Runner.newInvocationContextForLive). + val responseModalities = modalities.ifEmpty { listOf(KtModality.AUDIO) } + val textRequested = KtModality.TEXT in responseModalities + return KtRunConfig( + // ADK Java's runLive keeps the given mode; BIDI is what shows Java plugins the live settings. + streamingMode = KtStreamingMode.BIDI, + maxLlmCalls = config.maxLlmCalls(), + customMetadata = config.customMetadata().takeIf { it.isNotEmpty() }, + live = + KtLiveRunConfig( + responseModalities = responseModalities, + speechConfig = config.speechConfig()?.let { SpeechConfigCodec.fromJava(it) }, + outputAudioTranscription = + output?.let { AudioTranscriptionConfigCodec.fromJava(it) } + ?: if (textRequested) null else KtAudioTranscriptionConfig(), + inputAudioTranscription = + input?.let { AudioTranscriptionConfigCodec.fromJava(it) } + ?: KtAudioTranscriptionConfig(), + ), + ) + } + + /** Adds the Java settings that neither a turn-based nor a live run can honor. */ + // Reads the deprecated groupFunctionResponsesInHistoryOverride to reject it. + @Suppress("deprecation") + private fun MutableList.addUnsupportedByEveryRun(config: JavaRunConfig) { + if (config.toolExecutionMode() != JavaRunConfig.ToolExecutionMode.NONE) { + add("toolExecutionMode") + } + if (config.avatarConfig() != null) add("avatarConfig") + if (config.groupFunctionResponsesInHistoryOverride().isPresent) { + add("groupFunctionResponsesInHistoryOverride") + } + } } diff --git a/tokt/src/main/kotlin/com/google/adk/tokt/codecs/SpeechConfigCodec.kt b/tokt/src/main/kotlin/com/google/adk/tokt/codecs/SpeechConfigCodec.kt new file mode 100644 index 000000000..2e79fde87 --- /dev/null +++ b/tokt/src/main/kotlin/com/google/adk/tokt/codecs/SpeechConfigCodec.kt @@ -0,0 +1,132 @@ +/* + * 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.MultiSpeakerVoiceConfig as KtMultiSpeakerVoiceConfig +import com.google.adk.kt.types.PrebuiltVoiceConfig as KtPrebuiltVoiceConfig +import com.google.adk.kt.types.ReplicatedVoiceConfig as KtReplicatedVoiceConfig +import com.google.adk.kt.types.SpeakerVoiceConfig as KtSpeakerVoiceConfig +import com.google.adk.kt.types.SpeechConfig as KtSpeechConfig +import com.google.adk.kt.types.VoiceConfig as KtVoiceConfig +import com.google.adk.kt.types.VoiceConsentSignature as KtVoiceConsentSignature +import com.google.genai.types.MultiSpeakerVoiceConfig as GenaiMultiSpeakerVoiceConfig +import com.google.genai.types.PrebuiltVoiceConfig as GenaiPrebuiltVoiceConfig +import com.google.genai.types.ReplicatedVoiceConfig as GenaiReplicatedVoiceConfig +import com.google.genai.types.SpeakerVoiceConfig as GenaiSpeakerVoiceConfig +import com.google.genai.types.SpeechConfig as GenaiSpeechConfig +import com.google.genai.types.VoiceConfig as GenaiVoiceConfig +import com.google.genai.types.VoiceConsentSignature as GenaiVoiceConsentSignature +import kotlin.jvm.optionals.getOrNull + +/** + * Converts a live voice setting, with its voices and speakers, between the ADK Kotlin + * [KtSpeechConfig] and the genai [GenaiSpeechConfig] that the ADK Java `RunConfig` carries. Every + * field exists on both sides, so nothing is lost in either direction. + */ +internal object SpeechConfigCodec { + + /** Returns the genai [GenaiSpeechConfig] view of the Kotlin [config]. */ + fun toJava(config: KtSpeechConfig): GenaiSpeechConfig { + val builder = GenaiSpeechConfig.builder() + config.voiceConfig?.let { builder.voiceConfig(voiceToJava(it)) } + config.languageCode?.let { builder.languageCode(it) } + config.multiSpeakerVoiceConfig?.let { builder.multiSpeakerVoiceConfig(multiSpeakerToJava(it)) } + return builder.build() + } + + /** Returns the Kotlin [KtSpeechConfig] view of the genai [config]. */ + fun fromJava(config: GenaiSpeechConfig): KtSpeechConfig = + KtSpeechConfig( + voiceConfig = config.voiceConfig().getOrNull()?.let(::voiceFromJava), + languageCode = config.languageCode().getOrNull(), + multiSpeakerVoiceConfig = + config.multiSpeakerVoiceConfig().getOrNull()?.let { multi -> + KtMultiSpeakerVoiceConfig( + speakerVoiceConfigs = multi.speakerVoiceConfigs().getOrNull()?.map(::speakerFromJava) + ) + }, + ) + + private fun voiceToJava(voice: KtVoiceConfig): GenaiVoiceConfig { + val builder = GenaiVoiceConfig.builder() + voice.prebuiltVoiceConfig?.let { builder.prebuiltVoiceConfig(prebuiltToJava(it)) } + voice.replicatedVoiceConfig?.let { builder.replicatedVoiceConfig(replicatedToJava(it)) } + voice.voice?.let { builder.voice(it) } + return builder.build() + } + + private fun replicatedToJava(replicated: KtReplicatedVoiceConfig): GenaiReplicatedVoiceConfig { + val builder = GenaiReplicatedVoiceConfig.builder() + replicated.mimeType?.let { builder.mimeType(it) } + replicated.voiceSampleAudio?.let { builder.voiceSampleAudio(it) } + replicated.consentAudio?.let { builder.consentAudio(it) } + replicated.voiceConsentSignature?.let { builder.voiceConsentSignature(consentToJava(it)) } + return builder.build() + } + + private fun multiSpeakerToJava(multi: KtMultiSpeakerVoiceConfig): GenaiMultiSpeakerVoiceConfig { + val builder = GenaiMultiSpeakerVoiceConfig.builder() + multi.speakerVoiceConfigs?.let { builder.speakerVoiceConfigs(it.map(::speakerToJava)) } + return builder.build() + } + + private fun prebuiltToJava(prebuilt: KtPrebuiltVoiceConfig): GenaiPrebuiltVoiceConfig { + val builder = GenaiPrebuiltVoiceConfig.builder() + prebuilt.voiceName?.let { builder.voiceName(it) } + return builder.build() + } + + private fun consentToJava(consent: KtVoiceConsentSignature): GenaiVoiceConsentSignature { + val builder = GenaiVoiceConsentSignature.builder() + consent.signature?.let { builder.signature(it) } + return builder.build() + } + + private fun speakerToJava(speaker: KtSpeakerVoiceConfig): GenaiSpeakerVoiceConfig { + val builder = GenaiSpeakerVoiceConfig.builder() + speaker.speaker?.let { builder.speaker(it) } + speaker.voiceConfig?.let { builder.voiceConfig(voiceToJava(it)) } + return builder.build() + } + + private fun voiceFromJava(voice: GenaiVoiceConfig): KtVoiceConfig = + KtVoiceConfig( + prebuiltVoiceConfig = + voice.prebuiltVoiceConfig().getOrNull()?.let { + KtPrebuiltVoiceConfig(voiceName = it.voiceName().getOrNull()) + }, + replicatedVoiceConfig = voice.replicatedVoiceConfig().getOrNull()?.let(::replicatedFromJava), + voice = voice.voice().getOrNull(), + ) + + private fun replicatedFromJava(replicated: GenaiReplicatedVoiceConfig): KtReplicatedVoiceConfig = + KtReplicatedVoiceConfig( + mimeType = replicated.mimeType().getOrNull(), + voiceSampleAudio = replicated.voiceSampleAudio().getOrNull(), + consentAudio = replicated.consentAudio().getOrNull(), + voiceConsentSignature = + replicated.voiceConsentSignature().getOrNull()?.let { + KtVoiceConsentSignature(signature = it.signature().getOrNull()) + }, + ) + + private fun speakerFromJava(speaker: GenaiSpeakerVoiceConfig): KtSpeakerVoiceConfig = + KtSpeakerVoiceConfig( + speaker = speaker.speaker().getOrNull(), + voiceConfig = speaker.voiceConfig().getOrNull()?.let(::voiceFromJava), + ) +} 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..eb6d884eb 100644 --- a/tokt/src/test/kotlin/com/google/adk/tokt/KtRunnerInteropTest.kt +++ b/tokt/src/test/kotlin/com/google/adk/tokt/KtRunnerInteropTest.kt @@ -14,6 +14,8 @@ * limitations under the License. */ +@file:OptIn(ExperimentalLiveApi::class) + package com.google.adk.tokt import com.google.adk.agents.BaseAgent as JavaBaseAgent @@ -27,16 +29,22 @@ 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.LiveRunConfig as KtLiveRunConfig import com.google.adk.kt.agents.LlmAgent as KtLlmAgent import com.google.adk.kt.agents.ResumabilityConfig as KtResumabilityConfig import com.google.adk.kt.agents.RunConfig as KtRunConfig import com.google.adk.kt.agents.StreamingMode as KtStreamingMode import com.google.adk.kt.agents.TypedData +import com.google.adk.kt.annotations.ExperimentalLiveApi 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 @@ -48,7 +56,11 @@ import com.google.adk.kt.sessions.SessionService as KtSessionService 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.AudioTranscriptionConfig as KtAudioTranscriptionConfig +import com.google.adk.kt.types.AudioTranscriptionConfigMode as KtAudioTranscriptionConfigMode 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,26 +72,46 @@ 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.Modality as KtModality +import com.google.adk.kt.types.MultiSpeakerVoiceConfig as KtMultiSpeakerVoiceConfig import com.google.adk.kt.types.Part as KtPart import com.google.adk.kt.types.PartialArgValue as KtPartialArgValue +import com.google.adk.kt.types.PrebuiltVoiceConfig as KtPrebuiltVoiceConfig +import com.google.adk.kt.types.ReplicatedVoiceConfig as KtReplicatedVoiceConfig import com.google.adk.kt.types.Retrieval as KtRetrieval import com.google.adk.kt.types.SafetySetting as KtSafetySetting import com.google.adk.kt.types.Schema as KtSchema import com.google.adk.kt.types.ServiceTier as KtServiceTier +import com.google.adk.kt.types.SpeakerVoiceConfig as KtSpeakerVoiceConfig +import com.google.adk.kt.types.SpeechConfig as KtSpeechConfig 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.UsageMetadata as KtUsageMetadata 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.VoiceConfig as KtVoiceConfig +import com.google.adk.kt.types.VoiceConsentSignature as KtVoiceConsentSignature +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 +128,8 @@ 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.AudioTranscriptionConfigCodec +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,14 +141,21 @@ 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.SpeechConfigCodec +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.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 import com.google.adk.tools.BaseToolset as JavaBaseToolset import com.google.adk.tools.ToolContext as JavaToolContext import com.google.errorprone.annotations.CanIgnoreReturnValue +import com.google.genai.types.AudioTranscriptionConfig as GenaiAudioTranscriptionConfig +import com.google.genai.types.AudioTranscriptionConfigMode as GenaiAudioTranscriptionConfigMode +import com.google.genai.types.AvatarConfig as GenaiAvatarConfig import com.google.genai.types.CodeExecutionResult as GenaiCodeExecutionResult import com.google.genai.types.Content as GenaiContent import com.google.genai.types.CustomMetadata as GenaiCustomMetadata @@ -132,24 +173,37 @@ import com.google.genai.types.GroundingChunkRetrievedContext as GenaiGroundingCh import com.google.genai.types.GroundingChunkWeb as GenaiGroundingChunkWeb import com.google.genai.types.GroundingMetadata as GenaiGroundingMetadata import com.google.genai.types.GroundingSupport as GenaiGroundingSupport +import com.google.genai.types.LanguageAuto as GenaiLanguageAuto +import com.google.genai.types.LanguageHints as GenaiLanguageHints import com.google.genai.types.MediaModality as GenaiMediaModality +import com.google.genai.types.Modality as GenaiModality import com.google.genai.types.ModalityTokenCount as GenaiModalityTokenCount +import com.google.genai.types.MultiSpeakerVoiceConfig as GenaiMultiSpeakerVoiceConfig import com.google.genai.types.Part as GenaiPart import com.google.genai.types.PartMediaResolution as GenaiPartMediaResolution import com.google.genai.types.PartialArg as GenaiPartialArg +import com.google.genai.types.PrebuiltVoiceConfig as GenaiPrebuiltVoiceConfig +import com.google.genai.types.ReplicatedVoiceConfig as GenaiReplicatedVoiceConfig import com.google.genai.types.RetrievalMetadata as GenaiRetrievalMetadata import com.google.genai.types.Schema as GenaiSchema import com.google.genai.types.SearchEntryPoint as GenaiSearchEntryPoint import com.google.genai.types.Segment as GenaiSegment +import com.google.genai.types.SpeakerVoiceConfig as GenaiSpeakerVoiceConfig +import com.google.genai.types.SpeechConfig as GenaiSpeechConfig 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.VoiceConfig as GenaiVoiceConfig +import com.google.genai.types.VoiceConsentSignature as GenaiVoiceConsentSignature +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 +560,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( @@ -1728,6 +1795,230 @@ class KtRunnerInteropTest { } } + @Test + fun runConfigFromJavaLive_appliesTheJavaRunnersLiveDefaults() { + val audio = RunConfigCodec.fromJavaLive(JavaRunConfig.builder().build()) + val text = + RunConfigCodec.fromJavaLive( + JavaRunConfig.builder() + .responseModalities(listOf(GenaiModality(GenaiModality.Known.TEXT))) + .build() + ) + + assertEquals(KtStreamingMode.BIDI, audio.streamingMode) + assertEquals(listOf(KtModality.AUDIO), audio.live.responseModalities, "no modality means AUDIO") + assertEquals(KtAudioTranscriptionConfig(), audio.live.inputAudioTranscription) + assertEquals(KtAudioTranscriptionConfig(), audio.live.outputAudioTranscription) + assertEquals(listOf(KtModality.TEXT), text.live.responseModalities) + assertEquals(KtAudioTranscriptionConfig(), text.live.inputAudioTranscription) + assertNull(text.live.outputAudioTranscription, "a text reply turns output transcription off") + } + + @Test + fun runConfigFromJavaLive_speechAndTranscriptionSettings_convert() { + val java = + JavaRunConfig.builder() + .speechConfig(genaiSpeechConfig()) + .outputAudioTranscription(genaiTranscriptionConfig()) + .inputAudioTranscription( + GenaiAudioTranscriptionConfig.builder().languageCodes(listOf("de-DE")).build() + ) + .build() + + val kt = RunConfigCodec.fromJavaLive(java) + + assertEquals(ktSpeechConfig(), kt.live.speechConfig) + assertEquals(ktTranscriptionConfig(), kt.live.outputAudioTranscription) + assertEquals( + KtAudioTranscriptionConfig(languageCodes = listOf("de-DE")), + kt.live.inputAudioTranscription, + ) + } + + @Test + @Suppress("DEPRECATION") // Sets the deprecated grouping override to check that it is rejected. + fun runConfigFromJavaLive_settingsTheEngineCannotHonor_failNamingThem() { + val java = + JavaRunConfig.builder() + .toolExecutionMode(JavaRunConfig.ToolExecutionMode.SEQUENTIAL) + .avatarConfig(GenaiAvatarConfig.builder().avatarName("a").build()) + .groupFunctionResponsesInHistoryOverride(Optional.of(true)) + .outputAudioTranscription( + GenaiAudioTranscriptionConfig.builder() + .languageAuto(GenaiLanguageAuto.builder().build()) + .languageHints(GenaiLanguageHints.builder().languageCodes(listOf("pl-PL")).build()) + .adaptationPhrases(listOf("ADK")) + .mode(GenaiAudioTranscriptionConfigMode("m")) + .build() + ) + .inputAudioTranscription( + GenaiAudioTranscriptionConfig.builder().adaptationPhrases(listOf("ADK")).build() + ) + .build() + + val failure = assertFailsWith { RunConfigCodec.fromJavaLive(java) } + + assertEquals( + "RunConfig settings not supported by the ADK Kotlin engine: [toolExecutionMode, " + + "avatarConfig, groupFunctionResponsesInHistoryOverride, " + + "outputAudioTranscription.languageAuto, outputAudioTranscription.languageHints, " + + "outputAudioTranscription.adaptationPhrases, outputAudioTranscription.mode, " + + "inputAudioTranscription.adaptationPhrases]", + failure.message, + ) + } + + @Test + fun runConfigFromJavaLive_ignoresSaveInputBlobsAsArtifacts() { + // ADK Java's own live run never reads it, so a live run it would start must start here too. + val kt = + RunConfigCodec.fromJavaLive(JavaRunConfig.builder().saveInputBlobsAsArtifacts(true).build()) + + assertEquals(KtStreamingMode.BIDI, kt.streamingMode) + } + + @Test + fun runConfigFromJavaLive_unknownModality_fails() { + // genai reads a name it does not know as MODALITY_UNSPECIFIED, which must not reach the model. + val java = JavaRunConfig.builder().responseModalities(listOf(GenaiModality("DOCUMENT"))).build() + + val failure = assertFailsWith { RunConfigCodec.fromJavaLive(java) } + + assertEquals("Response modality not supported by the ADK Kotlin engine", failure.message) + } + + @Test + fun runConfigFromJavaLive_carriesTheCallBudgetAndMetadata() { + val kt = + RunConfigCodec.fromJavaLive( + JavaRunConfig.builder().maxLlmCalls(7).customMetadata(mapOf("k" to "v")).build() + ) + + assertEquals(7, kt.maxLlmCalls) + assertEquals?>(mapOf("k" to "v"), kt.customMetadata) + } + + @Test + fun runConfigFromJavaLive_outputTranscriptionDefault_followsTheJavaRunnersTextRule() { + // Keep an explicit setting with TEXT, and apply a default only when no modality is TEXT. + val textWithOutput = + RunConfigCodec.fromJavaLive( + JavaRunConfig.builder() + .responseModalities(listOf(GenaiModality(GenaiModality.Known.TEXT))) + .outputAudioTranscription(genaiTranscriptionConfig()) + .build() + ) + val audioAndText = + RunConfigCodec.fromJavaLive( + JavaRunConfig.builder() + .responseModalities( + listOf( + GenaiModality(GenaiModality.Known.AUDIO), + GenaiModality(GenaiModality.Known.TEXT), + ) + ) + .build() + ) + + assertEquals(ktTranscriptionConfig(), textWithOutput.live.outputAudioTranscription) + assertNull(audioAndText.live.outputAudioTranscription) + } + + @Test + fun runConfigToJava_bidiLiveSettings_convert() { + val kt = + KtRunConfig( + streamingMode = KtStreamingMode.BIDI, + live = + KtLiveRunConfig( + responseModalities = listOf(KtModality.AUDIO), + speechConfig = ktSpeechConfig(), + outputAudioTranscription = ktTranscriptionConfig(), + inputAudioTranscription = KtAudioTranscriptionConfig(languageCodes = listOf("de-DE")), + ), + ) + + val java = RunConfigCodec.toJava(kt) + + assertEquals(JavaRunConfig.StreamingMode.BIDI, java.streamingMode()) + assertEquals(listOf(GenaiModality(GenaiModality.Known.AUDIO)), java.responseModalities()) + assertEquals(genaiSpeechConfig().toJson(), java.speechConfig()?.toJson()) + assertEquals(genaiTranscriptionConfig(), java.outputAudioTranscription()) + assertEquals( + GenaiAudioTranscriptionConfig.builder().languageCodes(listOf("de-DE")).build(), + java.inputAudioTranscription(), + ) + } + + @Test + fun runConfigToJava_bidiWithoutModalities_showsAudio() { + // Plugins and tools see AUDIO when no modality is set, as in ADK Java and Python. + val java = RunConfigCodec.toJava(KtRunConfig(streamingMode = KtStreamingMode.BIDI)) + + assertEquals(listOf(GenaiModality(GenaiModality.Known.AUDIO)), java.responseModalities()) + } + + @Test + fun runConfigToJava_turnBasedConfig_showsNoLiveSettingsAndReadsBack() { + // Kotlin enables transcription by default; a turn-based Java view hides it and reads back. + val defaults = RunConfigCodec.toJava(KtRunConfig()) + val sse = + RunConfigCodec.toJava( + KtRunConfig( + streamingMode = KtStreamingMode.SSE, + live = + KtLiveRunConfig( + responseModalities = listOf(KtModality.AUDIO), + speechConfig = ktSpeechConfig(), + ), + ) + ) + + for (java in listOf(defaults, sse)) { + assertEquals(emptyList(), java.responseModalities()) + assertNull(java.speechConfig()) + assertNull(java.outputAudioTranscription()) + assertNull(java.inputAudioTranscription()) + } + assertEquals(KtRunConfig(), RunConfigCodec.fromJava(defaults)) + } + + @Test + fun audioTranscriptionConfigCodec_namesTheGenaiOnlyFieldsSet() { + val transcription = + GenaiAudioTranscriptionConfig.builder() + .languageAuto(GenaiLanguageAuto.builder().build()) + .languageHints(GenaiLanguageHints.builder().languageCodes(listOf("pl-PL")).build()) + .adaptationPhrases(listOf("ADK")) + .mode(GenaiAudioTranscriptionConfigMode("m")) + .build() + + assertEquals( + listOf("languageAuto", "languageHints", "adaptationPhrases", "mode"), + AudioTranscriptionConfigCodec.unsupportedFields(transcription), + ) + // Every field the Kotlin type models crosses, so a fully mapped config names nothing. + assertEquals( + emptyList(), + AudioTranscriptionConfigCodec.unsupportedFields(genaiTranscriptionConfig()), + ) + } + + @Test + fun speechAndTranscriptionConfigCodecs_convertEveryFieldInBothDirections() { + // As JSON: genai's equals compares the voice sample byte arrays by reference. + assertEquals(genaiSpeechConfig().toJson(), SpeechConfigCodec.toJava(ktSpeechConfig()).toJson()) + assertEquals(ktSpeechConfig(), SpeechConfigCodec.fromJava(genaiSpeechConfig())) + assertEquals( + genaiTranscriptionConfig(), + AudioTranscriptionConfigCodec.toJava(ktTranscriptionConfig()), + ) + assertEquals( + ktTranscriptionConfig(), + AudioTranscriptionConfigCodec.fromJava(genaiTranscriptionConfig()), + ) + } + @Test fun asJavaRunner_pluginManager_listsUnwrappedJavaPlugins() { // A Java plugin adapted onto the Kotlin runner is unwrapped back to the original instance, so a @@ -4041,6 +4332,499 @@ 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 = KtUsageMetadata(promptTokenCount = 3), + avgLogProbs = -0.5, + branch = "root.child", + groundingMetadata = + KtGroundingMetadata(webSearchQueries = listOf("q"), imageSearchQueries = listOf("i")), + 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 = KtUsageMetadata(promptTokenCount = 3), + finishReason = KtFinishReason.STOP, + errorMessage = "cut short", + modelVersion = "model-1", + groundingMetadata = + KtGroundingMetadata(webSearchQueries = listOf("q"), imageSearchQueries = listOf("i")), + 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 } + + @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")) + .partial(false) + .turnComplete(false) + .usageMetadata(GenaiUsageMetadata.builder().promptTokenCount(7).build()) + .build() + } + val response = + KtLlmResponse( + content = ContentCodec.fromJava(modelText("original")), + partial = true, + turnComplete = true, + usageMetadata = KtUsageMetadata(promptTokenCount = 3), + ) + + val result = JavaAdkToKt.asKtPlugin(plugin).afterModel(ktCallbackContext(), response) + + assertEquals(ContentCodec.fromJava(modelText("replaced")), result.content) + assertEquals(false, result.partial) + assertEquals(false, result.turnComplete) + assertEquals(KtUsageMetadata(promptTokenCount = 7), result.usageMetadata) + } + + @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 = KtUsageMetadata(promptTokenCount = 3), + ) + + 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_inheritsTheUnsetFlagsAndUsage() = 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, + interrupted = true, + usageMetadata = KtUsageMetadata(promptTokenCount = 3), + ) + + val result = JavaAdkToKt.asKtPlugin(plugin).onEvent(ktInvocationContext(), event) + + assertEquals(ContentCodec.fromJava(modelText("replaced")), result.content) + assertTrue(result.partial) + assertTrue(result.turnComplete) + assertTrue(result.interrupted) + assertEquals(event.usageMetadata, result.usageMetadata) + } + + @Test + fun javaPluginOnEvent_replacement_takesTheValuesJavaChanged() = runBlocking { + val plugin = EventOverridingJavaPlugin { + it + .toBuilder() + .partial(false) + .interrupted(false) + .usageMetadata(GenaiUsageMetadata.builder().promptTokenCount(7).build()) + .build() + } + val event = + KtEvent( + author = "model", + partial = true, + interrupted = true, + usageMetadata = KtUsageMetadata(promptTokenCount = 3), + ) + + val result = JavaAdkToKt.asKtPlugin(plugin).onEvent(ktInvocationContext(), event) + + assertFalse(result.partial) + assertFalse(result.interrupted) + assertEquals(KtUsageMetadata(promptTokenCount = 7), result.usageMetadata) + } + + @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 +4918,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 @@ -4763,6 +5583,25 @@ class KtRunnerInteropTest { ) } + @Test + @Suppress("DEPRECATION") // Sets the deprecated grouping override to check that it is rejected. + fun runConfigFromJava_settingsNoRunCanHonor_failNamingThem() { + val java = + JavaRunConfig.builder() + .toolExecutionMode(JavaRunConfig.ToolExecutionMode.SEQUENTIAL) + .avatarConfig(GenaiAvatarConfig.builder().avatarName("a").build()) + .groupFunctionResponsesInHistoryOverride(Optional.of(true)) + .build() + + val failure = assertFailsWith { RunConfigCodec.fromJava(java) } + + assertEquals( + "RunConfig settings not supported by the ADK Kotlin engine: [toolExecutionMode, " + + "avatarConfig, groupFunctionResponsesInHistoryOverride]", + failure.message, + ) + } + @Test fun runConfigFromJava_unsupportedField_failsRatherThanDroppingIt() { val java = JavaRunConfig.builder().maxLlmCalls(5).saveInputBlobsAsArtifacts(true).build() @@ -4816,5 +5655,185 @@ 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 speech setting with every field set, including a replicated voice and two speakers. */ + fun ktSpeechConfig() = + KtSpeechConfig( + voiceConfig = + KtVoiceConfig( + prebuiltVoiceConfig = KtPrebuiltVoiceConfig(voiceName = "Kore"), + voice = "voice-1", + ), + languageCode = "pl-PL", + multiSpeakerVoiceConfig = + KtMultiSpeakerVoiceConfig( + speakerVoiceConfigs = + listOf( + KtSpeakerVoiceConfig( + speaker = "a", + voiceConfig = + KtVoiceConfig( + prebuiltVoiceConfig = KtPrebuiltVoiceConfig(voiceName = "Puck"), + voice = "voice-a", + ), + ), + KtSpeakerVoiceConfig( + speaker = "b", + voiceConfig = + KtVoiceConfig( + replicatedVoiceConfig = + KtReplicatedVoiceConfig( + mimeType = "audio/wav", + voiceSampleAudio = byteArrayOf(1), + consentAudio = byteArrayOf(2), + voiceConsentSignature = KtVoiceConsentSignature(signature = "sig"), + ) + ), + ), + ) + ), + ) + + /** The genai twin of [ktSpeechConfig], built without the codec. */ + fun genaiSpeechConfig(): GenaiSpeechConfig = + GenaiSpeechConfig.builder() + .voiceConfig( + GenaiVoiceConfig.builder() + .prebuiltVoiceConfig(GenaiPrebuiltVoiceConfig.builder().voiceName("Kore").build()) + .voice("voice-1") + .build() + ) + .languageCode("pl-PL") + .multiSpeakerVoiceConfig( + GenaiMultiSpeakerVoiceConfig.builder() + .speakerVoiceConfigs( + listOf( + GenaiSpeakerVoiceConfig.builder() + .speaker("a") + .voiceConfig( + GenaiVoiceConfig.builder() + .prebuiltVoiceConfig( + GenaiPrebuiltVoiceConfig.builder().voiceName("Puck").build() + ) + .voice("voice-a") + .build() + ) + .build(), + GenaiSpeakerVoiceConfig.builder() + .speaker("b") + .voiceConfig( + GenaiVoiceConfig.builder() + .replicatedVoiceConfig( + GenaiReplicatedVoiceConfig.builder() + .mimeType("audio/wav") + .voiceSampleAudio(byteArrayOf(1)) + .consentAudio(byteArrayOf(2)) + .voiceConsentSignature( + GenaiVoiceConsentSignature.builder().signature("sig").build() + ) + .build() + ) + .build() + ) + .build(), + ) + ) + .build() + ) + .build() + + /** A transcription setting with every Kotlin field set. */ + fun ktTranscriptionConfig() = + KtAudioTranscriptionConfig( + languageCodes = listOf("pl-PL", "en-US"), + customVocabulary = listOf("ADK"), + diarization = true, + wordTimestamp = false, + mode = KtAudioTranscriptionConfigMode.VERBATIM, + ) + + /** The genai twin of [ktTranscriptionConfig], built without the codec. */ + fun genaiTranscriptionConfig(): GenaiAudioTranscriptionConfig = + GenaiAudioTranscriptionConfig.builder() + .languageCodes(listOf("pl-PL", "en-US")) + .customVocabulary(listOf("ADK")) + .diarization(true) + .wordTimestamp(false) + .mode(GenaiAudioTranscriptionConfigMode(GenaiAudioTranscriptionConfigMode.Known.VERBATIM)) + .build() + } + + /** 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), + ) + ) + } } }