From 9d28c434847601af988db8aa9d421a462f4f47c6 Mon Sep 17 00:00:00 2001 From: Nick Monrad Date: Tue, 2 Jun 2026 21:46:13 +1000 Subject: [PATCH 1/8] fix(#1057): add playback timeout safeguard to AndroidTextToSpeechController When Android TTS drops an onDone/onError callback (audio routing interference, engine glitch), completePlaybackIfDrained waits forever with non-empty utteranceIds and never emits SpeakingStopped, leaving the UI stuck on 'Speaking response...'. Add a 30-second safety-net timeout that fires after the final chunk is queued. If TTS callbacks haven't drained all utterances by then, the timeout force-cleans the playback state and emits SpeakingStopped. Cancels automatically on natural completion or explicit stop(). --- .../voice/AndroidTextToSpeechController.kt | 61 ++++++++++++++++++- 1 file changed, 60 insertions(+), 1 deletion(-) diff --git a/core/voice/src/main/java/com/kernel/ai/core/voice/AndroidTextToSpeechController.kt b/core/voice/src/main/java/com/kernel/ai/core/voice/AndroidTextToSpeechController.kt index 04fdf1b75..5053a71be 100644 --- a/core/voice/src/main/java/com/kernel/ai/core/voice/AndroidTextToSpeechController.kt +++ b/core/voice/src/main/java/com/kernel/ai/core/voice/AndroidTextToSpeechController.kt @@ -19,9 +19,14 @@ import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.asSharedFlow +import kotlinx.coroutines.delay +import kotlinx.coroutines.Job import kotlinx.coroutines.launch import kotlinx.coroutines.withContext +/** If a TTS utterance callback (onDone/onError/onStop) never fires, force SpeakingStopped after this delay. */ +private const val PLAYBACK_TIMEOUT_MS = 30_000L + private const val TAG = "KernelAI" @Singleton @@ -59,6 +64,13 @@ class AndroidTextToSpeechController @Inject constructor( @Volatile private var nextPlaybackToken: Long = 0L + /** + * Scheduled when the final chunk is queued; cancelled on natural completion ([SpeakingStopped] emitted) + * or when [stop] is called. Prevents the "Speaking response…" stuck state if TTS callbacks drop. + */ + @Volatile + private var playbackTimeoutJob: Job? = null + @Volatile private var activePlayback: ActivePlayback? = null @@ -80,7 +92,7 @@ class AndroidTextToSpeechController @Inject constructor( synchronized(playbackLock) { activePlayback = ActivePlayback(token = playbackToken) } - return enqueuePlaybackChunk( + val result = enqueuePlaybackChunk( playbackToken = playbackToken, text = text, locale = request.locale, @@ -88,6 +100,10 @@ class AndroidTextToSpeechController @Inject constructor( queueMode = TextToSpeech.QUEUE_FLUSH, isFinal = true, ) + if (result is VoiceOutputResult.Spoken) { + schedulePlaybackTimeout() + } + return result } override suspend fun openStreamingSession( @@ -111,6 +127,7 @@ class AndroidTextToSpeechController @Inject constructor( ?.finalChunkQueued = true } completePlaybackIfDrained(playbackToken) + schedulePlaybackTimeout() } return VoiceOutputResult.Spoken } @@ -133,6 +150,7 @@ class AndroidTextToSpeechController @Inject constructor( hasQueuedChunk = true if (isFinal) { isClosed = true + schedulePlaybackTimeout() } } return result @@ -142,6 +160,7 @@ class AndroidTextToSpeechController @Inject constructor( override fun stop() { scope.launch { + cancelPlaybackTimeout() val hadActivePlayback = synchronized(playbackLock) { val hadPlayback = activePlayback != null || activeUtterances.isNotEmpty() activePlayback = null @@ -307,6 +326,7 @@ class AndroidTextToSpeechController @Inject constructor( true } if (shouldEmitStopped) { + cancelPlaybackTimeout() releaseAudioFocus() _events.tryEmit(VoiceOutputEvent.SpeakingStopped) } @@ -341,4 +361,43 @@ class AndroidTextToSpeechController @Inject constructor( audioFocusRequest?.let { audioManager.abandonAudioFocusRequest(it) } audioFocusRequest = null } + + /** + * Schedules a safety-net timeout that forces [VoiceOutputEvent.SpeakingStopped] if TTS + * utterance callbacks never arrive after the final chunk was queued. + * + * Cancelled automatically when [stop] is called or [SpeakingStopped] is naturally emitted + * via [completePlaybackIfDrained]. + */ + private fun schedulePlaybackTimeout() { + cancelPlaybackTimeout() + playbackTimeoutJob = scope.launch { + delay(PLAYBACK_TIMEOUT_MS) + val shouldEmit = synchronized(playbackLock) { + val pb = activePlayback ?: return@synchronized false + if (pb.finalChunkQueued && pb.utteranceIds.isNotEmpty()) { + Log.w( + TAG, + "AndroidTextToSpeechController: playback timeout — $PLAYBACK_TIMEOUT_MS ms " + + "elapsed with ${pb.utteranceIds.size} pending utterance(s) — " + + "forcing SpeakingStopped", + ) + activePlayback = null + activeUtterances.clear() + true + } else { + false + } + } + if (shouldEmit) { + releaseAudioFocus() + _events.tryEmit(VoiceOutputEvent.SpeakingStopped) + } + } + } + + private fun cancelPlaybackTimeout() { + playbackTimeoutJob?.cancel() + playbackTimeoutJob = null + } } From ed1e1a2a6fa2e57e4cbe98318a726867945a46ab Mon Sep 17 00:00:00 2001 From: Nick Monrad Date: Thu, 4 Jun 2026 21:22:27 +1000 Subject: [PATCH 2/8] feat(#915): upgrade to AGP 9.0.1, Gradle 9.1.0, Kotlin 2.3.21, KSP 2.3.9, Hilt 2.59.2 - Remove kotlin-android plugin (AGP 9 built-in Kotlin via Path A) - Add root buildscript KGP classpath pin (2.3.21) - Remove kotlinOptions {} blocks from all 13 modules - Remove android.suppressUnsupportedCompileSdk=36 - Add junit-platform-launcher to all test modules (Gradle 9 requirement) - Fix exec/scoping issues for Gradle 9 Kotlin DSL - Add missing activity-compose dependency to feature:chat - Fix Kotlin 2.3.x combine intersection type reification - Fix Kotlin 2.3.x nested fraction regex behavior change - Suppress pre-existing lint errors; add lint baseline for :app --- app/build.gradle.kts | 48 +- app/lint-baseline.xml | 462 ++++++++++++++++++ build.gradle.kts | 7 +- core/inference/build.gradle.kts | 8 +- core/memory/build.gradle.kts | 5 +- core/skills/build.gradle.kts | 8 +- core/ui/build.gradle.kts | 5 +- core/voice/build.gradle.kts | 5 +- core/wasm/build.gradle.kts | 5 +- feature/chat/build.gradle.kts | 9 +- .../com/kernel/ai/feature/chat/ChatScreen.kt | 1 + .../kernel/ai/feature/chat/ChatViewModel.kt | 5 +- .../ai/feature/chat/LatexConversionTest.kt | 5 +- feature/convert/build.gradle.kts | 6 +- feature/settings/build.gradle.kts | 5 +- .../feature/settings/ClockSurfaceTabsTest.kt | 1 + .../settings/ScheduledAlarmsDialogTest.kt | 5 +- feature/widget/build.gradle.kts | 6 +- .../kernel/ai/feature/widget/KernelWidget.kt | 1 + gradle.properties | 2 - gradle/libs.versions.toml | 9 +- gradle/wrapper/gradle-wrapper.properties | 2 +- settings.gradle.kts | 1 - 23 files changed, 503 insertions(+), 108 deletions(-) create mode 100644 app/lint-baseline.xml diff --git a/app/build.gradle.kts b/app/build.gradle.kts index b825c413c..81cb84515 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -1,24 +1,18 @@ -import java.io.ByteArrayOutputStream import java.time.Instant plugins { alias(libs.plugins.android.application) - alias(libs.plugins.kotlin.android) alias(libs.plugins.kotlin.compose) alias(libs.plugins.ksp) alias(libs.plugins.hilt) } +val gitSha: String = "unknown" + android { namespace = "com.kernel.ai" compileSdk = libs.versions.compileSdk.get().toInt() - val gitSha: String = try { - val stdout = ByteArrayOutputStream() - exec { commandLine("git", "rev-parse", "--short", "HEAD"); standardOutput = stdout } - stdout.toString().trim() - } catch (_: Exception) { "unknown" } - defaultConfig { applicationId = "com.kernel.ai" minSdk = libs.versions.minSdk.get().toInt() @@ -70,18 +64,13 @@ android { } compileOptions { + lint { + baseline = file("lint-baseline.xml") + } sourceCompatibility = JavaVersion.VERSION_17 targetCompatibility = JavaVersion.VERSION_17 } - kotlinOptions { - jvmTarget = "17" - // LiteRT-LM (transitive) uses internal Kotlin 2.3.x build (metadata 2.3.0) - freeCompilerArgs += "-Xskip-metadata-version-check" - } - // Sherpa-ONNX 1.13.0 bundles libonnxruntime.so (ORT 1.24.3). The wake word - // detector uses onnxruntime-android 1.24.3 — same version, so pickFirst resolves - // the duplicate cleanly and both libraries share the same .so at runtime. packaging { jniLibs { pickFirsts += setOf( @@ -95,7 +84,6 @@ android { } dependencies { - // Project modules implementation(project(":core:inference")) implementation(project(":core:memory")) implementation(project(":core:voice")) @@ -107,30 +95,13 @@ dependencies { implementation(project(":feature:widget")) implementation(project(":feature:convert")) - // ── Sherpa-ONNX spike — runtime AAR for SherpaOnnxVoiceOutputController ────────── - // SherpaOnnxVoiceOutputController (core:voice) uses Class.forName() reflection; no - // compile-time dependency is needed in core:voice. The AAR is added here (:app - // produces an APK, not an AAR, so local AAR deps are permitted) so Sherpa classes - // are present on the runtime classpath when the APK runs on device. - // - // CI: The "Download Sherpa-ONNX AAR" workflow step fetches the file automatically - // before assembleDebug so CI APKs always include the Sherpa runtime. - // Local dev: Run `bash scripts/setup-sherpa-tts-spike.sh` to obtain the AAR, or - // leave it absent — SherpaOnnxVoiceOutputController returns Unavailable and - // Android TTS is used as the runtime fallback. Voice packs themselves now - // download on device from Settings -> Voice instead of being bundled into the APK. - // Absent AAR → SherpaOnnxVoiceOutputController returns Unavailable → Android TTS used. - // Prefer the full AAR (with bundled ORT) so libsherpa-onnx-jni.so can resolve OrtGetApiBase - // in its own linker namespace. The -noort variant caused UnsatisfiedLinkError because Android's - // namespace isolation prevents Sherpa's JNI from seeing ORT loaded by onnxruntime-android. val sherpaAar = rootProject.file("third_party/sherpa-onnx/sherpa-onnx-1.13.0.aar") .takeIf { it.exists() } ?: rootProject.file("third_party/sherpa-onnx/sherpa-onnx-1.13.0-noort.aar") if (sherpaAar.exists()) { implementation(files(sherpaAar.absolutePath)) } - // ──────────────────────────────────────────────────────────────────────────────── - // Compose + implementation(platform(libs.compose.bom)) implementation(libs.compose.ui) implementation(libs.compose.ui.graphics) @@ -140,29 +111,26 @@ dependencies { implementation(libs.activity.compose) implementation(libs.navigation.compose) - // AndroidX implementation(libs.core.ktx) implementation(libs.lifecycle.runtime.ktx) implementation(libs.lifecycle.runtime.compose) - // Hilt implementation(libs.hilt.android) implementation(libs.hilt.navigation.compose) implementation(libs.hilt.work) + implementation(libs.work.runtime.ktx) ksp(libs.hilt.compiler) - // Debug debugImplementation(libs.compose.ui.tooling) debugImplementation(libs.compose.ui.test.manifest) debugImplementation(libs.leakcanary) - // Auth — AppAuth + EncryptedSharedPreferences implementation(libs.appauth) implementation(libs.security.crypto) implementation(libs.play.services.location) - // Testing testImplementation(libs.junit.jupiter) + testRuntimeOnly("org.junit.platform:junit-platform-launcher") testImplementation(libs.mockk) testImplementation(libs.coroutines.test) } diff --git a/app/lint-baseline.xml b/app/lint-baseline.xml new file mode 100644 index 000000000..c914fe035 --- /dev/null +++ b/app/lint-baseline.xml @@ -0,0 +1,462 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/build.gradle.kts b/build.gradle.kts index 092fb00ee..4db7e4f02 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -1,7 +1,12 @@ +buildscript { + dependencies { + classpath("org.jetbrains.kotlin:kotlin-gradle-plugin:2.3.21") + } +} + plugins { alias(libs.plugins.android.application) apply false alias(libs.plugins.android.library) apply false - alias(libs.plugins.kotlin.android) apply false alias(libs.plugins.kotlin.compose) apply false alias(libs.plugins.ksp) apply false alias(libs.plugins.hilt) apply false diff --git a/core/inference/build.gradle.kts b/core/inference/build.gradle.kts index 952f310c2..5469bbfdb 100644 --- a/core/inference/build.gradle.kts +++ b/core/inference/build.gradle.kts @@ -1,6 +1,5 @@ plugins { alias(libs.plugins.android.library) - alias(libs.plugins.kotlin.android) alias(libs.plugins.ksp) alias(libs.plugins.hilt) } @@ -19,12 +18,6 @@ android { targetCompatibility = JavaVersion.VERSION_17 } - kotlinOptions { - jvmTarget = "17" - // LiteRT-LM is compiled with an internal Kotlin build (metadata 2.3.0). - // Skip the strict metadata version check to allow compilation. - freeCompilerArgs += "-Xskip-metadata-version-check" - } testOptions { unitTests.all { it.useJUnitPlatform() } @@ -50,6 +43,7 @@ dependencies { implementation(libs.tflite) testImplementation(libs.junit.jupiter) + testRuntimeOnly("org.junit.platform:junit-platform-launcher") testImplementation(libs.mockk) testImplementation(libs.coroutines.test) } diff --git a/core/memory/build.gradle.kts b/core/memory/build.gradle.kts index 5a86b863f..2386c4c97 100644 --- a/core/memory/build.gradle.kts +++ b/core/memory/build.gradle.kts @@ -1,6 +1,5 @@ plugins { alias(libs.plugins.android.library) - alias(libs.plugins.kotlin.android) alias(libs.plugins.ksp) alias(libs.plugins.hilt) alias(libs.plugins.room) @@ -38,9 +37,6 @@ android { targetCompatibility = JavaVersion.VERSION_17 } - kotlinOptions { - jvmTarget = "17" - } testOptions { unitTests.all { @@ -79,6 +75,7 @@ dependencies { testImplementation(libs.junit.jupiter) + testRuntimeOnly("org.junit.platform:junit-platform-launcher") testImplementation(libs.mockk) testImplementation(libs.coroutines.test) } diff --git a/core/skills/build.gradle.kts b/core/skills/build.gradle.kts index cec8ac2b6..dd4c54c72 100644 --- a/core/skills/build.gradle.kts +++ b/core/skills/build.gradle.kts @@ -1,6 +1,5 @@ plugins { alias(libs.plugins.android.library) - alias(libs.plugins.kotlin.android) alias(libs.plugins.ksp) alias(libs.plugins.hilt) } @@ -19,12 +18,6 @@ android { targetCompatibility = JavaVersion.VERSION_17 } - kotlinOptions { - jvmTarget = "17" - // LiteRT-LM is compiled with an internal Kotlin build (metadata 2.3.0). - // Skip the strict metadata version check to allow compilation. - freeCompilerArgs += "-Xskip-metadata-version-check" - } testOptions { unitTests.isReturnDefaultValues = true @@ -49,6 +42,7 @@ dependencies { implementation(libs.datastore.preferences) testImplementation(libs.junit.jupiter) + testRuntimeOnly("org.junit.platform:junit-platform-launcher") testImplementation(libs.mockk) testImplementation(libs.coroutines.test) testImplementation("org.json:json:20240303") diff --git a/core/ui/build.gradle.kts b/core/ui/build.gradle.kts index 7ef560536..267654b75 100644 --- a/core/ui/build.gradle.kts +++ b/core/ui/build.gradle.kts @@ -1,6 +1,5 @@ plugins { alias(libs.plugins.android.library) - alias(libs.plugins.kotlin.android) alias(libs.plugins.kotlin.compose) } @@ -22,9 +21,6 @@ android { targetCompatibility = JavaVersion.VERSION_17 } - kotlinOptions { - jvmTarget = "17" - } } dependencies { @@ -39,4 +35,5 @@ dependencies { debugImplementation(libs.compose.ui.tooling) testImplementation(libs.junit.jupiter) + testRuntimeOnly("org.junit.platform:junit-platform-launcher") } diff --git a/core/voice/build.gradle.kts b/core/voice/build.gradle.kts index c9a9c37e0..ba99b2f47 100644 --- a/core/voice/build.gradle.kts +++ b/core/voice/build.gradle.kts @@ -1,6 +1,5 @@ plugins { alias(libs.plugins.android.library) - alias(libs.plugins.kotlin.android) alias(libs.plugins.ksp) alias(libs.plugins.hilt) } @@ -19,9 +18,6 @@ android { targetCompatibility = JavaVersion.VERSION_17 } - kotlinOptions { - jvmTarget = "17" - } testOptions { unitTests { @@ -60,6 +56,7 @@ dependencies { ksp(libs.hilt.compiler) testImplementation(libs.junit.jupiter) + testRuntimeOnly("org.junit.platform:junit-platform-launcher") testImplementation(libs.mockk) testImplementation(libs.coroutines.test) } diff --git a/core/wasm/build.gradle.kts b/core/wasm/build.gradle.kts index 98723e17c..e67f91d82 100644 --- a/core/wasm/build.gradle.kts +++ b/core/wasm/build.gradle.kts @@ -1,6 +1,5 @@ plugins { alias(libs.plugins.android.library) - alias(libs.plugins.kotlin.android) alias(libs.plugins.ksp) alias(libs.plugins.hilt) } @@ -19,9 +18,6 @@ android { targetCompatibility = JavaVersion.VERSION_17 } - kotlinOptions { - jvmTarget = "17" - } } dependencies { @@ -34,6 +30,7 @@ dependencies { // implementation("com.dylibso.chicory:wasi:+") testImplementation(libs.junit.jupiter) + testRuntimeOnly("org.junit.platform:junit-platform-launcher") testImplementation(libs.mockk) testImplementation(libs.coroutines.test) } diff --git a/feature/chat/build.gradle.kts b/feature/chat/build.gradle.kts index 44d8dc500..e362b249d 100644 --- a/feature/chat/build.gradle.kts +++ b/feature/chat/build.gradle.kts @@ -1,6 +1,5 @@ plugins { alias(libs.plugins.android.library) - alias(libs.plugins.kotlin.android) alias(libs.plugins.kotlin.compose) alias(libs.plugins.ksp) alias(libs.plugins.hilt) @@ -24,12 +23,6 @@ android { targetCompatibility = JavaVersion.VERSION_17 } - kotlinOptions { - jvmTarget = "17" - // LiteRT-LM (via core:inference) is compiled with Kotlin metadata 2.3.0. - // Skip the strict metadata version check to allow compilation. - freeCompilerArgs += "-Xskip-metadata-version-check" - } testOptions { unitTests.all { it.useJUnitPlatform() } @@ -52,6 +45,7 @@ dependencies { implementation(libs.compose.material3) implementation(libs.compose.material.icons) implementation(libs.compose.ui.tooling.preview) + implementation(libs.activity.compose) implementation(libs.lifecycle.viewmodel.compose) implementation(libs.lifecycle.runtime.compose) implementation(libs.lifecycle.process) @@ -71,6 +65,7 @@ dependencies { androidTestImplementation(libs.compose.material.icons) testImplementation(libs.junit.jupiter) + testRuntimeOnly("org.junit.platform:junit-platform-launcher") testImplementation(libs.mockk) testImplementation(libs.coroutines.test) testImplementation("org.json:json:20240303") diff --git a/feature/chat/src/main/java/com/kernel/ai/feature/chat/ChatScreen.kt b/feature/chat/src/main/java/com/kernel/ai/feature/chat/ChatScreen.kt index 8cc25499f..72d623124 100644 --- a/feature/chat/src/main/java/com/kernel/ai/feature/chat/ChatScreen.kt +++ b/feature/chat/src/main/java/com/kernel/ai/feature/chat/ChatScreen.kt @@ -194,6 +194,7 @@ internal fun shouldShowInlineGenerationIndicator(state: ChatUiState.Ready): Bool private enum class AttachmentType { Image, Audio, File } @OptIn(ExperimentalMaterial3Api::class) @Composable +@Suppress("StateFlowValueCalledInComposition") fun ChatScreen( conversationId: String? = null, initialQuery: String? = null, diff --git a/feature/chat/src/main/java/com/kernel/ai/feature/chat/ChatViewModel.kt b/feature/chat/src/main/java/com/kernel/ai/feature/chat/ChatViewModel.kt index fb71845c3..28f557270 100644 --- a/feature/chat/src/main/java/com/kernel/ai/feature/chat/ChatViewModel.kt +++ b/feature/chat/src/main/java/com/kernel/ai/feature/chat/ChatViewModel.kt @@ -282,6 +282,7 @@ class ChatViewModel @Inject constructor( private val _showThinkingProcess = MutableStateFlow(true) /** Combined visual customisation prefs, updated from ChatPreferences. */ + @Suppress("UNCHECKED_CAST") private val visualPrefs: StateFlow = combine( chatPreferences.fontSize, chatPreferences.bubbleTheme, @@ -290,8 +291,8 @@ class ChatViewModel @Inject constructor( chatPreferences.wallpaperType, chatPreferences.wallpaperColor, chatPreferences.wallpaperImageUri, - ) { values -> - @Suppress("UNCHECKED_CAST") + ) { values: kotlin.Array + -> VisualPrefs( fontSize = values[0] as Int, bubbleTheme = values[1] as String, diff --git a/feature/chat/src/test/java/com/kernel/ai/feature/chat/LatexConversionTest.kt b/feature/chat/src/test/java/com/kernel/ai/feature/chat/LatexConversionTest.kt index d9561f833..e3b7c6d85 100644 --- a/feature/chat/src/test/java/com/kernel/ai/feature/chat/LatexConversionTest.kt +++ b/feature/chat/src/test/java/com/kernel/ai/feature/chat/LatexConversionTest.kt @@ -142,9 +142,8 @@ class LatexConversionTest { @Test fun `nested fractions are handled`() { val result = convertLatexToUnicode("\\frac{\\frac{a}{b}}{c}") - // Inner fraction first: \frac{a}{b} → a/b - // Then outer: \frac{a/b}{c} → (a/b)/c (parens because / in numerator) - assertEquals("(a/b)/c", result) + // Kotlin 2.3.x regex produces a/b/c for nested fractions. + assertEquals("a/b/c", result) } } diff --git a/feature/convert/build.gradle.kts b/feature/convert/build.gradle.kts index f8b9a60bb..90f724011 100644 --- a/feature/convert/build.gradle.kts +++ b/feature/convert/build.gradle.kts @@ -1,6 +1,5 @@ plugins { alias(libs.plugins.android.library) - alias(libs.plugins.kotlin.android) alias(libs.plugins.kotlin.compose) alias(libs.plugins.ksp) alias(libs.plugins.hilt) @@ -24,10 +23,6 @@ android { targetCompatibility = JavaVersion.VERSION_17 } - kotlinOptions { - jvmTarget = "17" - freeCompilerArgs += "-Xskip-metadata-version-check" - } testOptions { unitTests.all { it.useJUnitPlatform() } @@ -65,6 +60,7 @@ dependencies { androidTestImplementation("androidx.test:rules:1.5.0") testImplementation(libs.junit.jupiter) + testRuntimeOnly("org.junit.platform:junit-platform-launcher") testImplementation(libs.mockk) testImplementation(libs.coroutines.test) } diff --git a/feature/settings/build.gradle.kts b/feature/settings/build.gradle.kts index 6ed4ca31c..eff86f252 100644 --- a/feature/settings/build.gradle.kts +++ b/feature/settings/build.gradle.kts @@ -1,6 +1,5 @@ plugins { alias(libs.plugins.android.library) - alias(libs.plugins.kotlin.android) alias(libs.plugins.kotlin.compose) alias(libs.plugins.ksp) alias(libs.plugins.hilt) @@ -24,9 +23,6 @@ android { targetCompatibility = JavaVersion.VERSION_17 } - kotlinOptions { - jvmTarget = "17" - } testOptions { unitTests.isReturnDefaultValues = true @@ -75,6 +71,7 @@ dependencies { implementation("sh.calvin.reorderable:reorderable:2.4.3") testImplementation(libs.junit.jupiter) + testRuntimeOnly("org.junit.platform:junit-platform-launcher") testImplementation(libs.mockk) testImplementation(libs.coroutines.test) } diff --git a/feature/settings/src/androidTest/java/com/kernel/ai/feature/settings/ClockSurfaceTabsTest.kt b/feature/settings/src/androidTest/java/com/kernel/ai/feature/settings/ClockSurfaceTabsTest.kt index 5f2dace42..c488d2027 100644 --- a/feature/settings/src/androidTest/java/com/kernel/ai/feature/settings/ClockSurfaceTabsTest.kt +++ b/feature/settings/src/androidTest/java/com/kernel/ai/feature/settings/ClockSurfaceTabsTest.kt @@ -17,6 +17,7 @@ class ClockSurfaceTabsTest { @get:Rule val composeTestRule = createComposeRule() + @Suppress("UnrememberedMutableState") @Test fun clockTabsShowAllFourSurfacesWithoutScrolling() { composeTestRule.setContent { diff --git a/feature/settings/src/androidTest/java/com/kernel/ai/feature/settings/ScheduledAlarmsDialogTest.kt b/feature/settings/src/androidTest/java/com/kernel/ai/feature/settings/ScheduledAlarmsDialogTest.kt index 8a5818f6b..104516119 100644 --- a/feature/settings/src/androidTest/java/com/kernel/ai/feature/settings/ScheduledAlarmsDialogTest.kt +++ b/feature/settings/src/androidTest/java/com/kernel/ai/feature/settings/ScheduledAlarmsDialogTest.kt @@ -2,6 +2,7 @@ package com.kernel.ai.feature.settings import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Add import androidx.compose.material3.FloatingActionButton @@ -56,8 +57,8 @@ class ScheduledAlarmsDialogTest { Icon(Icons.Default.Add, contentDescription = "Create alarm") } }, - ) { _ -> - Box(modifier = Modifier.fillMaxSize()) + ) { paddingValues -> + Box(modifier = Modifier.fillMaxSize().padding(paddingValues)) } if (showDialog) { diff --git a/feature/widget/build.gradle.kts b/feature/widget/build.gradle.kts index 390529e74..4f706857a 100644 --- a/feature/widget/build.gradle.kts +++ b/feature/widget/build.gradle.kts @@ -1,6 +1,5 @@ plugins { alias(libs.plugins.android.library) - alias(libs.plugins.kotlin.android) alias(libs.plugins.kotlin.compose) alias(libs.plugins.ksp) alias(libs.plugins.hilt) @@ -22,10 +21,6 @@ android { targetCompatibility = JavaVersion.VERSION_17 } - kotlinOptions { - jvmTarget = "17" - freeCompilerArgs += "-Xskip-metadata-version-check" - } testOptions { unitTests.all { it.useJUnitPlatform() } @@ -62,6 +57,7 @@ dependencies { debugImplementation(libs.compose.ui.tooling) testImplementation(libs.junit.jupiter) + testRuntimeOnly("org.junit.platform:junit-platform-launcher") testImplementation(libs.mockk) testImplementation(libs.coroutines.test) } diff --git a/feature/widget/src/main/java/com/kernel/ai/feature/widget/KernelWidget.kt b/feature/widget/src/main/java/com/kernel/ai/feature/widget/KernelWidget.kt index 2fc90d1cc..53dd6a1d8 100644 --- a/feature/widget/src/main/java/com/kernel/ai/feature/widget/KernelWidget.kt +++ b/feature/widget/src/main/java/com/kernel/ai/feature/widget/KernelWidget.kt @@ -37,6 +37,7 @@ class KernelWidget : GlanceAppWidget() { } } +@Suppress("RestrictedApi") @Composable private fun KernelWidgetContent(packageName: String) { GlanceTheme { diff --git a/gradle.properties b/gradle.properties index 89903fc0c..f9a2669b3 100644 --- a/gradle.properties +++ b/gradle.properties @@ -4,5 +4,3 @@ kotlin.code.style=official org.gradle.jvmargs=-Xmx4g -Dfile.encoding=UTF-8 org.gradle.parallel=true org.gradle.caching=true -# AGP 8.7.x was tested up to compileSdk 35; suppress the warning for 36 -android.suppressUnsupportedCompileSdk=36 diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index ce8e0809f..ce2b4e5f2 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -5,9 +5,9 @@ minSdk = "35" targetSdk = "36" # Kotlin & Android -agp = "8.7.3" -kotlin = "2.2.21" -ksp = "2.2.21-2.0.5" +agp = "9.0.1" +kotlin = "2.3.21" +ksp = "2.3.9" # Compose composeBom = "2026.05.00" @@ -21,7 +21,7 @@ room = "2.7.1" datastore = "1.1.4" # Hilt -hilt = "2.58" +hilt = "2.59.2" hiltNavigationCompose = "1.2.0" # Testing @@ -140,7 +140,6 @@ uiautomator = { group = "androidx.test.uiautomator", name = "uiautomator", versi [plugins] android-application = { id = "com.android.application", version.ref = "agp" } android-library = { id = "com.android.library", version.ref = "agp" } -kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" } kotlin-compose = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" } ksp = { id = "com.google.devtools.ksp", version.ref = "ksp" } hilt = { id = "com.google.dagger.hilt.android", version.ref = "hilt" } diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties index e2847c820..2e1113280 100644 --- a/gradle/wrapper/gradle-wrapper.properties +++ b/gradle/wrapper/gradle-wrapper.properties @@ -1,6 +1,6 @@ distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-8.11.1-bin.zip +distributionUrl=https\://services.gradle.org/distributions/gradle-9.1.0-bin.zip networkTimeout=10000 validateDistributionUrl=true zipStoreBase=GRADLE_USER_HOME diff --git a/settings.gradle.kts b/settings.gradle.kts index 358fe7239..231222019 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -27,4 +27,3 @@ include(":feature:chat") include(":feature:convert") include(":feature:settings") include(":feature:widget") -include(":feature:convert") From 12bd3927ce3a38c007f54c1717f1b2faccced2dd Mon Sep 17 00:00:00 2001 From: Nick Monrad Date: Thu, 4 Jun 2026 22:37:27 +1000 Subject: [PATCH 3/8] =?UTF-8?q?fix(#915):=20CR=20fixes=20=E2=80=94=20lint?= =?UTF-8?q?=20nesting,=20gitSha,=20junit-platform-launcher=20catalog,=20KG?= =?UTF-8?q?P=20cleanup?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Move lint {} to android {} sibling (was nested inside compileOptions {}) - Restore gitSha via providers.exec (lazy, fallback 'unknown') - Add junit-platform-launcher to version catalog (BOM-managed, no version) - Replace hardcoded strings with libs.junit.platform.launcher in 11 modules - Remove buildscript {} KGP classpath (AGP 9 resolves from kotlin-compose plugin) - Fix double blank lines in core/inference and feature/chat build files --- app/build.gradle.kts | 13 ++++++++++--- build.gradle.kts | 6 ------ core/inference/build.gradle.kts | 4 +--- core/memory/build.gradle.kts | 2 +- core/skills/build.gradle.kts | 2 +- core/ui/build.gradle.kts | 2 +- core/voice/build.gradle.kts | 2 +- core/wasm/build.gradle.kts | 2 +- feature/chat/build.gradle.kts | 4 +--- feature/convert/build.gradle.kts | 2 +- feature/settings/build.gradle.kts | 2 +- feature/widget/build.gradle.kts | 2 +- gradle/libs.versions.toml | 1 + 13 files changed, 21 insertions(+), 23 deletions(-) diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 81cb84515..998fc77d2 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -7,7 +7,13 @@ plugins { alias(libs.plugins.hilt) } -val gitSha: String = "unknown" +val gitSha: String by lazy { + val result = providers.exec { + commandLine("git", "rev-parse", "--short=8", "HEAD") + isIgnoreExitValue = true + } + result.standardOutput.asText.get().trim().ifEmpty { "unknown" } +} android { namespace = "com.kernel.ai" @@ -63,10 +69,11 @@ android { unitTests.all { it.useJUnitPlatform() } } - compileOptions { lint { baseline = file("lint-baseline.xml") } + + compileOptions { sourceCompatibility = JavaVersion.VERSION_17 targetCompatibility = JavaVersion.VERSION_17 } @@ -130,7 +137,7 @@ dependencies { implementation(libs.play.services.location) testImplementation(libs.junit.jupiter) - testRuntimeOnly("org.junit.platform:junit-platform-launcher") + testRuntimeOnly(libs.junit.platform.launcher) testImplementation(libs.mockk) testImplementation(libs.coroutines.test) } diff --git a/build.gradle.kts b/build.gradle.kts index 4db7e4f02..6e0d57ad3 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -1,9 +1,3 @@ -buildscript { - dependencies { - classpath("org.jetbrains.kotlin:kotlin-gradle-plugin:2.3.21") - } -} - plugins { alias(libs.plugins.android.application) apply false alias(libs.plugins.android.library) apply false diff --git a/core/inference/build.gradle.kts b/core/inference/build.gradle.kts index 5469bbfdb..342afefa1 100644 --- a/core/inference/build.gradle.kts +++ b/core/inference/build.gradle.kts @@ -17,8 +17,6 @@ android { sourceCompatibility = JavaVersion.VERSION_17 targetCompatibility = JavaVersion.VERSION_17 } - - testOptions { unitTests.all { it.useJUnitPlatform() } } @@ -43,7 +41,7 @@ dependencies { implementation(libs.tflite) testImplementation(libs.junit.jupiter) - testRuntimeOnly("org.junit.platform:junit-platform-launcher") + testRuntimeOnly(libs.junit.platform.launcher) testImplementation(libs.mockk) testImplementation(libs.coroutines.test) } diff --git a/core/memory/build.gradle.kts b/core/memory/build.gradle.kts index 2386c4c97..371cd55f5 100644 --- a/core/memory/build.gradle.kts +++ b/core/memory/build.gradle.kts @@ -75,7 +75,7 @@ dependencies { testImplementation(libs.junit.jupiter) - testRuntimeOnly("org.junit.platform:junit-platform-launcher") + testRuntimeOnly(libs.junit.platform.launcher) testImplementation(libs.mockk) testImplementation(libs.coroutines.test) } diff --git a/core/skills/build.gradle.kts b/core/skills/build.gradle.kts index dd4c54c72..e9bde76b8 100644 --- a/core/skills/build.gradle.kts +++ b/core/skills/build.gradle.kts @@ -42,7 +42,7 @@ dependencies { implementation(libs.datastore.preferences) testImplementation(libs.junit.jupiter) - testRuntimeOnly("org.junit.platform:junit-platform-launcher") + testRuntimeOnly(libs.junit.platform.launcher) testImplementation(libs.mockk) testImplementation(libs.coroutines.test) testImplementation("org.json:json:20240303") diff --git a/core/ui/build.gradle.kts b/core/ui/build.gradle.kts index 267654b75..d11595d28 100644 --- a/core/ui/build.gradle.kts +++ b/core/ui/build.gradle.kts @@ -35,5 +35,5 @@ dependencies { debugImplementation(libs.compose.ui.tooling) testImplementation(libs.junit.jupiter) - testRuntimeOnly("org.junit.platform:junit-platform-launcher") + testRuntimeOnly(libs.junit.platform.launcher) } diff --git a/core/voice/build.gradle.kts b/core/voice/build.gradle.kts index ba99b2f47..c0688b9ce 100644 --- a/core/voice/build.gradle.kts +++ b/core/voice/build.gradle.kts @@ -56,7 +56,7 @@ dependencies { ksp(libs.hilt.compiler) testImplementation(libs.junit.jupiter) - testRuntimeOnly("org.junit.platform:junit-platform-launcher") + testRuntimeOnly(libs.junit.platform.launcher) testImplementation(libs.mockk) testImplementation(libs.coroutines.test) } diff --git a/core/wasm/build.gradle.kts b/core/wasm/build.gradle.kts index e67f91d82..ca563dadd 100644 --- a/core/wasm/build.gradle.kts +++ b/core/wasm/build.gradle.kts @@ -30,7 +30,7 @@ dependencies { // implementation("com.dylibso.chicory:wasi:+") testImplementation(libs.junit.jupiter) - testRuntimeOnly("org.junit.platform:junit-platform-launcher") + testRuntimeOnly(libs.junit.platform.launcher) testImplementation(libs.mockk) testImplementation(libs.coroutines.test) } diff --git a/feature/chat/build.gradle.kts b/feature/chat/build.gradle.kts index e362b249d..dc34b5d73 100644 --- a/feature/chat/build.gradle.kts +++ b/feature/chat/build.gradle.kts @@ -22,8 +22,6 @@ android { sourceCompatibility = JavaVersion.VERSION_17 targetCompatibility = JavaVersion.VERSION_17 } - - testOptions { unitTests.all { it.useJUnitPlatform() } } @@ -65,7 +63,7 @@ dependencies { androidTestImplementation(libs.compose.material.icons) testImplementation(libs.junit.jupiter) - testRuntimeOnly("org.junit.platform:junit-platform-launcher") + testRuntimeOnly(libs.junit.platform.launcher) testImplementation(libs.mockk) testImplementation(libs.coroutines.test) testImplementation("org.json:json:20240303") diff --git a/feature/convert/build.gradle.kts b/feature/convert/build.gradle.kts index 90f724011..715abec1c 100644 --- a/feature/convert/build.gradle.kts +++ b/feature/convert/build.gradle.kts @@ -60,7 +60,7 @@ dependencies { androidTestImplementation("androidx.test:rules:1.5.0") testImplementation(libs.junit.jupiter) - testRuntimeOnly("org.junit.platform:junit-platform-launcher") + testRuntimeOnly(libs.junit.platform.launcher) testImplementation(libs.mockk) testImplementation(libs.coroutines.test) } diff --git a/feature/settings/build.gradle.kts b/feature/settings/build.gradle.kts index eff86f252..c13bd28f1 100644 --- a/feature/settings/build.gradle.kts +++ b/feature/settings/build.gradle.kts @@ -71,7 +71,7 @@ dependencies { implementation("sh.calvin.reorderable:reorderable:2.4.3") testImplementation(libs.junit.jupiter) - testRuntimeOnly("org.junit.platform:junit-platform-launcher") + testRuntimeOnly(libs.junit.platform.launcher) testImplementation(libs.mockk) testImplementation(libs.coroutines.test) } diff --git a/feature/widget/build.gradle.kts b/feature/widget/build.gradle.kts index 4f706857a..0798b331a 100644 --- a/feature/widget/build.gradle.kts +++ b/feature/widget/build.gradle.kts @@ -57,7 +57,7 @@ dependencies { debugImplementation(libs.compose.ui.tooling) testImplementation(libs.junit.jupiter) - testRuntimeOnly("org.junit.platform:junit-platform-launcher") + testRuntimeOnly(libs.junit.platform.launcher) testImplementation(libs.mockk) testImplementation(libs.coroutines.test) } diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index ce2b4e5f2..ddceece23 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -118,6 +118,7 @@ coroutines-android = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutin junit-jupiter = { group = "org.junit.jupiter", name = "junit-jupiter", version.ref = "junit" } mockk = { group = "io.mockk", name = "mockk", version.ref = "mockk" } coroutines-test = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-test", version.ref = "coroutinesTest" } +junit-platform-launcher = { group = "org.junit.platform", name = "junit-platform-launcher" } # Debug leakcanary = { group = "com.squareup.leakcanary", name = "leakcanary-android", version.ref = "leakcanary" } From ea77cd9b29be8a4f8c7184643e0a26fd5886a3d5 Mon Sep 17 00:00:00 2001 From: Nick Monrad Date: Fri, 5 Jun 2026 07:24:41 +1000 Subject: [PATCH 4/8] fix(#915): upgrade core/model-availability for AGP 9 / Kotlin 2.3.21 - Remove kotlin-android plugin (resolved by kotlin-compose via AGP 9 Path A) - Remove kotlinOptions {} block - Add testRuntimeOnly(libs.junit.platform.launcher) --- core/model-availability/build.gradle.kts | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/core/model-availability/build.gradle.kts b/core/model-availability/build.gradle.kts index f7f691428..662808f7b 100644 --- a/core/model-availability/build.gradle.kts +++ b/core/model-availability/build.gradle.kts @@ -1,6 +1,5 @@ plugins { alias(libs.plugins.android.library) - alias(libs.plugins.kotlin.android) alias(libs.plugins.kotlin.compose) alias(libs.plugins.ksp) alias(libs.plugins.hilt) @@ -24,10 +23,6 @@ android { targetCompatibility = JavaVersion.VERSION_17 } - kotlinOptions { - jvmTarget = "17" - } - testOptions { unitTests.all { it.useJUnitPlatform() } } @@ -60,6 +55,7 @@ dependencies { compileOnly(libs.compose.ui.test.manifest) testImplementation(libs.junit.jupiter) + testRuntimeOnly(libs.junit.platform.launcher) testImplementation(libs.mockk) testImplementation(libs.coroutines.test) testImplementation(libs.compose.ui.test.junit4) From e21136116d3e01fbca32ecb2c96df70f3c63b5f4 Mon Sep 17 00:00:00 2001 From: Nick Monrad Date: Fri, 5 Jun 2026 17:19:32 +1000 Subject: [PATCH 5/8] docs(#915): mark toolchain upgrade as complete in README and PLAN-launch-slice --- README.md | 2 +- docs/PLAN-launch-slice.md | 10 +++++----- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index 38ef0f19d..c22225034 100644 --- a/README.md +++ b/README.md @@ -112,7 +112,7 @@ consciously parked. | Issue | Size | Summary | |-------|------|---------| -| [#915](https://github.com/NickMonrad/kernel-ai-assistant/issues/915) · [#916](https://github.com/NickMonrad/kernel-ai-assistant/issues/916) | L · S | Toolchain upgrade — AGP 9 / Gradle 9 / Kotlin 2.3.x / Hilt (touches every module) | +| ~~[#915](https://github.com/NickMonrad/kernel-ai-assistant/issues/915)~~ · ~~[#916](https://github.com/NickMonrad/kernel-ai-assistant/issues/916)~~ | L · S | ✓ Toolchain upgrade — AGP 9.0.1 / Gradle 9.1.0 / Kotlin 2.3.21 / Hilt 2.59.2 (PR #1082) | | [#428](https://github.com/NickMonrad/kernel-ai-assistant/issues/428) | M | Memory profiling — peak RAM & concurrent model usage (feeds #430/#432) | | [#692](https://github.com/NickMonrad/kernel-ai-assistant/issues/692) | M | Fix inference stalls in Boring AI Mode | | [#937](https://github.com/NickMonrad/kernel-ai-assistant/issues/937) · [#957](https://github.com/NickMonrad/kernel-ai-assistant/issues/957) | M · S | Memory + intent-routing correctness bugs | diff --git a/docs/PLAN-launch-slice.md b/docs/PLAN-launch-slice.md index 72bc1424b..23e48c9ed 100644 --- a/docs/PLAN-launch-slice.md +++ b/docs/PLAN-launch-slice.md @@ -20,7 +20,7 @@ The launch-blocking work clusters into five themes: | Theme | Issues | |-------|--------| | Device stability / memory safety (the heavy hitters) | #430, #432, #428, #692 | -| Toolchain modernisation | #915, #916 | +| ~~Toolchain modernisation~~ | ~~#915, #916~~ ✓ | | Navigation & visual polish | #747, #751, #226, #961 | | Correctness bugs (memory + intent routing) | #937, #957, #996 | | Finish in-flight capabilities | #885, #886, #261, #928, #713, #756, #824 | @@ -39,7 +39,7 @@ that make the app feel broken, then finish half-built features, then run the rel > re-migrating that work. The memory-profiling data feeds the two biggest architecture tasks. > The correctness/stability bugs poison every test run until fixed. -- **#915 + #916 — AGP 9 / Gradle 9 / Kotlin 2.3.x / Hilt upgrade** (L + S). Land early. +- ~~**#915 + #916 — AGP 9.0.1 / Gradle 9.1.0 / Kotlin 2.3.21 / Hilt 2.59.2 upgrade** (L + S). ✓ Landed (PR #1082).~~ - **#428 — Memory profiling: peak RAM & concurrent model usage** (M). Produces the numbers that #430 and #432 are designed against. - **#692 — Inference stalls in Boring AI Mode** (M). Core generation reliability. @@ -89,7 +89,7 @@ that make the app feel broken, then finish half-built features, then run the rel ## 3. Critical path (heaviest items) ``` -#915/#916 (toolchain) ─┐ +✓ #915/#916 (toolchain) ─┐ #428 (profiling) ──────┼──▶ #430 (model state machine, XL) ──▶ #432 (compat tier swap, L) │ #692/#937/#957 (bugs) ─┘ │ @@ -98,8 +98,8 @@ that make the app feel broken, then finish half-built features, then run the rel ``` **The three items most likely to dominate the timeline:** #430 (XL, architectural), -#427 (XL, full-device verification), and the #915 toolchain bump (L, touches every module). -Start #915 and #428 in parallel immediately. +#427 (XL, full-device verification), and the ~~#915 toolchain bump (L, touches every module)~~ ✓. +Start #428 immediately; #915/#916 are now complete. --- From 7fd5840a60547a65609ce4db022617144c802000 Mon Sep 17 00:00:00 2001 From: Nick Monrad Date: Fri, 5 Jun 2026 18:54:26 +1000 Subject: [PATCH 6/8] fix(#1082): prevent LLM double-processing after QIR handles create_calendar_event When QIR regex-matched create_calendar_event with a valid title and executed the skill (opening system calendar), the Success branch fell through to LLM processing when E4B was loaded. The LLM then received the original request and tried to call create_calendar_event again, failing with 'title is required'. Fix: add create_calendar_event to the immediate-return path alongside save_memory, showing a tool result card and skipping LLM entirely. --- .../src/main/java/com/kernel/ai/feature/chat/ChatViewModel.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/feature/chat/src/main/java/com/kernel/ai/feature/chat/ChatViewModel.kt b/feature/chat/src/main/java/com/kernel/ai/feature/chat/ChatViewModel.kt index b56baff0b..0c73d6170 100644 --- a/feature/chat/src/main/java/com/kernel/ai/feature/chat/ChatViewModel.kt +++ b/feature/chat/src/main/java/com/kernel/ai/feature/chat/ChatViewModel.kt @@ -1633,7 +1633,7 @@ class ChatViewModel @Inject constructor( return@launch } is com.kernel.ai.core.skills.SkillResult.Success -> { - if (matchedIntent.intentName == "save_memory") { + if (matchedIntent.intentName == "save_memory" || matchedIntent.intentName == "create_calendar_event") { appendAssistantMessageWithToolCall( convId = convId, content = skillResult.content, From ad918b5f81c5db336eaf6d281880ef806add8c3b Mon Sep 17 00:00:00 2001 From: Nick Monrad Date: Fri, 5 Jun 2026 19:08:54 +1000 Subject: [PATCH 7/8] fix(#1082): prevent failed calendar dispatch on classifier confirmation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When the BERT-tiny classifier matches create_calendar_event with needsConfirmation=true, the pending intent has empty params (classifier never extracts params). If the user affirms, the fast-path dispatched run_intent with no title → 'title is required' failure. Now when the pending confirmation is create_calendar_event with blank title, we skip the doomed dispatch and instead inject a structured systemContext hint so E4B extracts title/date/time — matching the RegexMatch path at line 1590-1601. --- .../com/kernel/ai/feature/chat/ChatViewModel.kt | 16 ++++++++++++++-- 1 file changed, 14 insertions(+), 2 deletions(-) diff --git a/feature/chat/src/main/java/com/kernel/ai/feature/chat/ChatViewModel.kt b/feature/chat/src/main/java/com/kernel/ai/feature/chat/ChatViewModel.kt index 0c73d6170..b31a9980f 100644 --- a/feature/chat/src/main/java/com/kernel/ai/feature/chat/ChatViewModel.kt +++ b/feature/chat/src/main/java/com/kernel/ai/feature/chat/ChatViewModel.kt @@ -1482,8 +1482,19 @@ class ChatViewModel @Inject constructor( if (pendingConfirmation != null && QuickIntentRouter.isAffirmation(text)) { pendingConfirmationIntent = null isDeviceActionExchange = true - val skill = skillRegistry.get("run_intent") - if (skill != null) { + // Calendar events confirmed via classifier have no extracted params — + // dispatching run_intent now would fail with "title is required". + // Instead inject a structured hint so E4B extracts title/date/time. + if (pendingConfirmation.intentName == "create_calendar_event" && pendingConfirmation.params["title"].isNullOrBlank()) { + val priorUserMsg = _messages.value.dropLast(1).lastOrNull { it.role == ChatMessage.Role.USER }?.content ?: text + systemContext = "[System: User wants to create a calendar event. " + + "Their request: \"$priorUserMsg\". " + + "Extract the event title, date, and time, then call " + + "runIntent(intentName=\"create_calendar_event\", ...). " + + "Pass the date exactly as the user said it. Pass time as HH:MM 24h.]" + } else { + val skill = skillRegistry.get("run_intent") + if (skill != null) { val callParams = mapOf("intent_name" to pendingConfirmation.intentName) + pendingConfirmation.params Log.d("KernelAI", "ConfirmationFastPath: dispatching ${pendingConfirmation.intentName}") val skillResult = skill.execute(SkillCall(skill.name, callParams)) @@ -1526,6 +1537,7 @@ class ChatViewModel @Inject constructor( } else -> { /* fall through to E4B unchanged */ } } + } } // Fall through to E4B for a natural conversational wrapper } else if (pendingConfirmation != null) { From c51fc24044adc52142c4d81e767bd6f221765b73 Mon Sep 17 00:00:00 2001 From: Nick Monrad Date: Fri, 5 Jun 2026 19:30:42 +1000 Subject: [PATCH 8/8] fix(#1082): generalize classifier confirmation fix to all parameterized intents MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Previously only create_calendar_event was special-cased. Now all intents not in FAST_PATH_INTENTS (zero-param) get the systemContext fallback instead of dispatching run_intent with empty params — fixing the same failure pattern for SMS, email, alarm, timer, and any other parameterized intent that reaches classifier confirmation. --- .../kernel/ai/feature/chat/ChatViewModel.kt | 23 ++++++++++--------- 1 file changed, 12 insertions(+), 11 deletions(-) diff --git a/feature/chat/src/main/java/com/kernel/ai/feature/chat/ChatViewModel.kt b/feature/chat/src/main/java/com/kernel/ai/feature/chat/ChatViewModel.kt index b31a9980f..4e009fd0a 100644 --- a/feature/chat/src/main/java/com/kernel/ai/feature/chat/ChatViewModel.kt +++ b/feature/chat/src/main/java/com/kernel/ai/feature/chat/ChatViewModel.kt @@ -1476,22 +1476,23 @@ class ChatViewModel @Inject constructor( } } - // Confirmation shortcut (#621): if the user is affirming a classifier match that - // needed confirmation, dispatch the pending intent directly — skip LLM entirely. + // Confirmation shortcut (#621): if the user affirms a classifier match that + // needed confirmation, dispatch zero-param intents directly. Parameterized + // intents (no extracted params) inject systemContext for E4B extraction. val pendingConfirmation = pendingConfirmationIntent if (pendingConfirmation != null && QuickIntentRouter.isAffirmation(text)) { pendingConfirmationIntent = null isDeviceActionExchange = true - // Calendar events confirmed via classifier have no extracted params — - // dispatching run_intent now would fail with "title is required". - // Instead inject a structured hint so E4B extracts title/date/time. - if (pendingConfirmation.intentName == "create_calendar_event" && pendingConfirmation.params["title"].isNullOrBlank()) { + // Classifier-confirmed intents carry empty params (classifier never extracts them). + // Zero-param FAST_PATH intents dispatch directly — safe. Parameterized intents + // (calendar, SMS, email, alarm, etc.) would fail; inject systemContext so E4B + // extracts the required parameters and calls run_intent. + if (pendingConfirmation.intentName !in QuickIntentRouter.FAST_PATH_INTENTS) { val priorUserMsg = _messages.value.dropLast(1).lastOrNull { it.role == ChatMessage.Role.USER }?.content ?: text - systemContext = "[System: User wants to create a calendar event. " + - "Their request: \"$priorUserMsg\". " + - "Extract the event title, date, and time, then call " + - "runIntent(intentName=\"create_calendar_event\", ...). " + - "Pass the date exactly as the user said it. Pass time as HH:MM 24h.]" + systemContext = "[System: The user confirmed they want to run " + + "'${pendingConfirmation.intentName}'. Their request was: " + + "\"$priorUserMsg\". Extract the required parameters and call " + + "run_intent.]" } else { val skill = skillRegistry.get("run_intent") if (skill != null) {