From 9d28c434847601af988db8aa9d421a462f4f47c6 Mon Sep 17 00:00:00 2001 From: Nick Monrad Date: Tue, 2 Jun 2026 21:46:13 +1000 Subject: [PATCH 01/12] 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 1f400dc0dd23cd1efa726ccaa636c3e2438e1b13 Mon Sep 17 00:00:00 2001 From: Nick Monrad Date: Thu, 4 Jun 2026 15:24:11 +1000 Subject: [PATCH 02/12] fix(#1079): retry plan generation on transient failures, fix empty-plan UX messages - Add MAX_PLAN_GENERATION_ATTEMPTS=2 retry loop in generatePlanForReview() - Retry on PLAN_NO_OUTPUT and PLAN_JSON_INVALID; no retry on variety repair - markGenerationFailure() only after all attempts exhausted - planGenerationFailedMessage() helper with stale-draft detection - All empty-plan UX surfaces now show actionable recovery copy - Structured logging with sessionId per attempt - 8 new tests + 1 updated test covering retry, recovery, and stale-draft scenarios --- .../skills/mealplan/MealPlannerCoordinator.kt | 121 ++++++--- .../mealplan/MealPlannerCoordinatorTest.kt | 254 +++++++++++++++++- 2 files changed, 328 insertions(+), 47 deletions(-) diff --git a/core/skills/src/main/java/com/kernel/ai/core/skills/mealplan/MealPlannerCoordinator.kt b/core/skills/src/main/java/com/kernel/ai/core/skills/mealplan/MealPlannerCoordinator.kt index b8068fdd1..4ea371f0e 100644 --- a/core/skills/src/main/java/com/kernel/ai/core/skills/mealplan/MealPlannerCoordinator.kt +++ b/core/skills/src/main/java/com/kernel/ai/core/skills/mealplan/MealPlannerCoordinator.kt @@ -396,41 +396,59 @@ class MealPlannerCoordinator @Inject constructor( onPlannerActivityChanged(generatingPlanActivity(snapshot)) val recentHistory = sessionRepository.getRecentMealHistory(RECENT_MEAL_HISTORY_LIMIT) val favouriteRecipes = sessionRepository.getFavouriteRecipes(MAX_FAVOURITE_PROMPT_RECIPES) - val rawPlan = inferenceEngine.generateStructuredOnce( - prompt = buildPlanUserPrompt(snapshot, recentHistory, favouriteRecipes), - spec = StructuredOutputSpec.MealPlan, - systemPrompt = buildPlanSystemPrompt(), - thinkingEnabled = false, - ) - if (rawPlan.isBlank()) { - sessionRepository.markGenerationFailure( - snapshot.sessionId, - null, - "PLAN_NO_OUTPUT", - "The model did not return a plan.", - ) - return@withSessionGeneration MealPlannerReply( - "I couldn't finish building the plan because the model didn't return one. Try replying with the same requirements again.", - ) - } - val parsedPlan = try { - jsonParser.parsePlanDraft(rawPlan, snapshot.daysCount ?: 0) - } catch (e: MealPlanValidationException) { - sessionRepository.markGenerationFailure(snapshot.sessionId, null, "PLAN_JSON_INVALID", e.message ?: "Invalid plan JSON") - return@withSessionGeneration MealPlannerReply("I couldn't generate a valid high-level plan yet. ${e.message} Try replying with the same requirements again or adjust them.") - } - val planDraft = try { - repairPlanVariety( - snapshot = snapshot, - draft = parsedPlan, - recentHistory = recentHistory, + var lastErrorCode: String? = null + var lastErrorMessage: String? = null + for (attempt in 1..MAX_PLAN_GENERATION_ATTEMPTS) { + val preAttempt = sessionRepository.getSession(snapshot.sessionId) ?: snapshot + if (preAttempt.status == MealPlanSessionStatus.CANCELLED) { + return@withSessionGeneration MealPlannerReply("Meal planning was cancelled.") + } + val rawPlan = inferenceEngine.generateStructuredOnce( + prompt = buildPlanUserPrompt(snapshot, recentHistory, favouriteRecipes), + spec = StructuredOutputSpec.MealPlan, + systemPrompt = buildPlanSystemPrompt(), + thinkingEnabled = false, ) - } catch (e: MealPlanValidationException) { - sessionRepository.markGenerationFailure(snapshot.sessionId, null, "PLAN_VARIETY_REPAIR_FAILED", e.message ?: "Plan was too repetitive") - return@withSessionGeneration MealPlannerReply("I couldn't generate a varied enough high-level plan yet. ${e.message} Try replying with the same requirements again or adjust them.") + if (rawPlan.isBlank()) { + lastErrorCode = "PLAN_NO_OUTPUT" + lastErrorMessage = "The model did not return a plan." + Log.w(TAG, "Plan generation failed: sessionId=${snapshot.sessionId}, attempt=$attempt/$MAX_PLAN_GENERATION_ATTEMPTS, errorCode=$lastErrorCode") + if (attempt < MAX_PLAN_GENERATION_ATTEMPTS) continue + sessionRepository.markGenerationFailure(snapshot.sessionId, null, lastErrorCode!!, lastErrorMessage!!) + return@withSessionGeneration MealPlannerReply(planGenerationFailedMessage(snapshot)) + } + val parsedPlan = try { + jsonParser.parsePlanDraft(rawPlan, snapshot.daysCount ?: 0) + } catch (e: MealPlanValidationException) { + lastErrorCode = "PLAN_JSON_INVALID" + lastErrorMessage = e.message ?: "Invalid plan JSON" + Log.w(TAG, "Plan generation failed: sessionId=${snapshot.sessionId}, attempt=$attempt/$MAX_PLAN_GENERATION_ATTEMPTS, errorCode=$lastErrorCode") + if (attempt < MAX_PLAN_GENERATION_ATTEMPTS) continue + sessionRepository.markGenerationFailure(snapshot.sessionId, null, lastErrorCode!!, lastErrorMessage!!) + return@withSessionGeneration MealPlannerReply(planGenerationFailedMessage(snapshot)) + } + val planDraft = try { + repairPlanVariety( + snapshot = snapshot, + draft = parsedPlan, + recentHistory = recentHistory, + ) + } catch (e: MealPlanValidationException) { + sessionRepository.markGenerationFailure(snapshot.sessionId, null, "PLAN_VARIETY_REPAIR_FAILED", e.message ?: "Plan was too repetitive") + Log.w(TAG, "Plan generation failed: sessionId=${snapshot.sessionId}, attempt=$attempt/$MAX_PLAN_GENERATION_ATTEMPTS, errorCode=PLAN_VARIETY_REPAIR_FAILED") + return@withSessionGeneration MealPlannerReply( + "I couldn't generate a varied enough high-level plan yet. ${e.message} Try replying with the same requirements again or adjust them.", + ) + } + val preSave = sessionRepository.getSession(snapshot.sessionId) ?: snapshot + if (preSave.status == MealPlanSessionStatus.CANCELLED) { + return@withSessionGeneration MealPlannerReply("Meal planning was cancelled.") + } + val planned = sessionRepository.savePlanDraft(snapshot.sessionId, planDraft.days) + return@withSessionGeneration MealPlannerReply(planReviewPrompt(planned)) } - val planned = sessionRepository.savePlanDraft(snapshot.sessionId, planDraft.days) - MealPlannerReply(planReviewPrompt(planned)) + sessionRepository.markGenerationFailure(snapshot.sessionId, null, "PLAN_GENERATION_FAILED", "Exhausted all attempts") + return@withSessionGeneration MealPlannerReply(planGenerationFailedMessage(snapshot)) } private suspend fun repairPlanVariety( @@ -1318,6 +1336,13 @@ class MealPlannerCoordinator @Inject constructor( MealPlanSessionStatus.COLLECTING_REQUIRED_SLOTS -> collectingActivity(snapshot) MealPlanSessionStatus.PLAN_REVIEW -> if (generationActive && snapshot.pendingGenerationKind == PendingGenerationKind.PLAN) { generatingPlanActivity(snapshot) + } else if (snapshot.days.isEmpty()) { + MealPlannerActivity( + title = "Review your meal plan", + subtitle = "The plan couldn't be built. Say 'generate recipes' to try again, or 'change preferences'.", + state = MealPlannerActivityState.WAITING, + suggestions = planReviewSuggestions(snapshot), + ) } else { MealPlannerActivity( title = "Review your meal plan", @@ -1469,7 +1494,7 @@ class MealPlannerCoordinator @Inject constructor( MealPlanSessionStatus.COLLECTING_REQUIRED_SLOTS -> collectingHelpPrompt(snapshot) MealPlanSessionStatus.PLAN_REVIEW -> if (snapshot.days.isEmpty()) { - "I still need to rebuild your meal plan draft. You can say 'generate recipes' to try again, 'change preferences' to edit the plan details, 'show current plan' to inspect what I have, or 'cancel' to stop." + "I couldn't build the meal plan yet. You can say 'generate recipes' to try again, 'change preferences' to edit the plan details, or 'cancel' to stop." } else { "You're reviewing the draft meal plan. You can say 'show current plan' to inspect it again, 'generate recipes' to build the recipe details, 'replace day 1' to swap one meal, 'change preferences' to edit people, days, dietary needs, proteins, or cuisines, or 'cancel' to stop." } @@ -1576,6 +1601,20 @@ class MealPlannerCoordinator @Inject constructor( if (snapshot.cuisinePreferences.isEmpty()) add("cuisine") } + private fun planGenerationFailedMessage(snapshot: MealPlanSnapshot): String { + if (snapshot.days.isNotEmpty()) { + return "I couldn't rebuild the meal plan with your updated preferences. Your previous draft is still shown — say 'generate recipes' to try again with the updated preferences, 'change preferences' to adjust your requirements, or 'help' for more options." + } + return "I couldn't build the meal plan. Say 'generate recipes' to try again, 'change preferences' to adjust your requirements, or 'help' for more options." + } + + private fun planReviewPrompt(snapshot: MealPlanSnapshot): String = + if (snapshot.days.isEmpty()) { + planGenerationFailedMessage(snapshot) + } else { + buildPlanSummary(snapshot) + "\n\n" + planReviewActionsPrompt() + } + private fun missingSlotPrompt(slot: String): String = when (slot) { "people" -> "- How many people are you cooking for?" "days" -> "- How many days do you want to plan for?" @@ -1585,12 +1624,6 @@ class MealPlannerCoordinator @Inject constructor( else -> "- $slot" } - private fun planReviewPrompt(snapshot: MealPlanSnapshot): String = - if (snapshot.days.isEmpty()) { - "I still need to rebuild your meal plan draft. Say 'generate recipes' to try again, 'change preferences', 'help' for more options, or 'cancel'." - } else { - buildPlanSummary(snapshot) + "\n\n" + planReviewActionsPrompt() - } private fun currentPlanReply(snapshot: MealPlanSnapshot): String = if (snapshot.days.isEmpty()) { @@ -2039,6 +2072,13 @@ Rules: else -> slot } private fun planReviewSuggestions(snapshot: MealPlanSnapshot): List = buildList { + if (snapshot.days.isEmpty()) { + add(suggestion("Generate recipes", "generate recipes")) + add(suggestion("Change preferences", "change preferences")) + add(suggestion("Help", "help")) + add(suggestion("Cancel plan", "cancel plan")) + return@buildList + } add(suggestion("Generate recipes", "generate recipes")) add(suggestion("Show current plan", "show current plan")) primaryEditableDay(snapshot)?.let { dayIndex -> @@ -2265,6 +2305,7 @@ Rules: private const val PENDING_COMPLETED_SUMMARY_LIMIT = 3 private const val MAX_PLAN_VARIETY_REPAIR_PASSES = 2 private const val MAX_DAY_VARIETY_REPAIR_ATTEMPTS = 2 + private const val MAX_PLAN_GENERATION_ATTEMPTS = 2 private const val MAX_PROMPT_HISTORY_TITLES = 6 private const val MAX_PROMPT_HISTORY_PATTERNS = 4 private val COMMON_PROTEINS = listOf( diff --git a/core/skills/src/test/java/com/kernel/ai/core/skills/mealplan/MealPlannerCoordinatorTest.kt b/core/skills/src/test/java/com/kernel/ai/core/skills/mealplan/MealPlannerCoordinatorTest.kt index 25dfa7a5a..3d74f5c13 100644 --- a/core/skills/src/test/java/com/kernel/ai/core/skills/mealplan/MealPlannerCoordinatorTest.kt +++ b/core/skills/src/test/java/com/kernel/ai/core/skills/mealplan/MealPlannerCoordinatorTest.kt @@ -20,6 +20,7 @@ import kotlinx.coroutines.async import kotlinx.coroutines.test.runTest import org.junit.jupiter.api.Assertions.assertEquals import org.junit.jupiter.api.Assertions.assertFalse +import org.junit.jupiter.api.Assertions.assertNotNull import org.junit.jupiter.api.Assertions.assertTrue import org.junit.jupiter.api.Test @@ -1575,7 +1576,7 @@ class MealPlannerCoordinatorTest { } @Test - fun `plan generation failure with empty draft stays recoverable and cannot finalize`() = runTest { + fun `plan generation failure with empty draft shows recovery message and cannot finalize`() = runTest { val collecting = collectingSnapshot().copy(peopleCount = 4, daysCount = 2) val ready = collecting.copy( dietaryRestrictions = listOf("low lactose"), @@ -1596,17 +1597,20 @@ class MealPlannerCoordinatorTest { proteinPreferences = listOf("chicken"), ) } returns ready + // After all 2 retry attempts exhausted, markGenerationFailure is called coEvery { sessionRepository.markGenerationFailure("session-1", null, "PLAN_NO_OUTPUT", "The model did not return a plan.") } returns failedPlan coEvery { inferenceEngine.generateStructuredOnce(any(), any(), any(), false) } returns "" val first = coordinator.ingestUserMessage("conv", "low lactose, chicken") - val second = coordinator.ingestUserMessage("conv", "hello") - val third = coordinator.ingestUserMessage("conv", "done meal planning") + val second = coordinator.ingestUserMessage("conv", "help") + val third = coordinator.ingestUserMessage("conv", "show current plan") - assertTrue(first.content.contains("didn't return one", ignoreCase = true)) - assertTrue(second.content.contains("rebuild your meal plan draft", ignoreCase = true)) - assertTrue(third.content.contains("rebuild your meal plan draft", ignoreCase = true)) + assertTrue(first.content.contains("couldn't build the meal plan", ignoreCase = true)) + assertTrue(second.content.contains("couldn't build the meal plan yet", ignoreCase = true)) + assertTrue(third.content.contains("couldn't build the meal plan", ignoreCase = true)) coVerify(exactly = 0) { sessionRepository.completeSession(any()) } + // 2 retry attempts = 2 calls to generateStructuredOnce + coVerify(exactly = 2) { inferenceEngine.generateStructuredOnce(any(), any(), any(), false) } } @Test @@ -1644,7 +1648,243 @@ class MealPlannerCoordinatorTest { coVerify { sessionRepository.completeSession("session-1") } coVerify { sessionRepository.buildFinalSummary("session-1") } coVerify { sessionRepository.markFinalSummaryWritten("session-1") } - coVerify(exactly = 1) { memoryRepository.addEpisodicMemory("conv", "Created a 2-day meal plan.", any()) } + } + + @Test + fun `blank plan output retries and succeeds on second attempt`() = runTest { + val collecting = collectingSnapshot().copy(peopleCount = 4, daysCount = 2) + val ready = collecting.copy( + dietaryRestrictions = listOf("low lactose"), + proteinPreferences = listOf("chicken"), + ) + val reviewed = planReviewSnapshot() + coEvery { sessionRepository.getActiveSession("conv") } returns collecting + coEvery { + sessionRepository.updateRequiredSlots( + sessionId = "session-1", + peopleCount = null, + daysCount = null, + dietaryRestrictions = listOf("low lactose"), + proteinPreferences = listOf("chicken"), + ) + } returns ready + coEvery { inferenceEngine.generateStructuredOnce(any(), any(), any(), false) } returnsMany listOf("", planJson()) + coEvery { sessionRepository.savePlanDraft("session-1", any()) } returns reviewed + + val reply = coordinator.ingestUserMessage("conv", "low lactose, chicken") + + assertTrue(reply.content.contains("Chicken", ignoreCase = true)) + coVerify(exactly = 2) { inferenceEngine.generateStructuredOnce(any(), any(), any(), false) } + coVerify(exactly = 1) { sessionRepository.savePlanDraft("session-1", any()) } + coVerify(exactly = 0) { sessionRepository.markGenerationFailure(any(), any(), any(), any()) } + } + + @Test + fun `invalid plan JSON retries and succeeds on second attempt`() = runTest { + val collecting = collectingSnapshot().copy(peopleCount = 4, daysCount = 2) + val ready = collecting.copy( + dietaryRestrictions = listOf("low lactose"), + proteinPreferences = listOf("chicken"), + ) + val reviewed = planReviewSnapshot() + coEvery { sessionRepository.getActiveSession("conv") } returns collecting + coEvery { + sessionRepository.updateRequiredSlots( + sessionId = "session-1", + peopleCount = null, + daysCount = null, + dietaryRestrictions = listOf("low lactose"), + proteinPreferences = listOf("chicken"), + ) + } returns ready + coEvery { inferenceEngine.generateStructuredOnce(any(), any(), any(), false) } returnsMany listOf("not valid json at all", planJson()) + coEvery { sessionRepository.savePlanDraft("session-1", any()) } returns reviewed + + val reply = coordinator.ingestUserMessage("conv", "low lactose, chicken") + + assertTrue(reply.content.contains("Chicken", ignoreCase = true)) + coVerify(exactly = 2) { inferenceEngine.generateStructuredOnce(any(), any(), any(), false) } + coVerify(exactly = 1) { sessionRepository.savePlanDraft("session-1", any()) } + coVerify(exactly = 0) { sessionRepository.markGenerationFailure(any(), any(), any(), any()) } + } + + @Test + fun `all plan generation attempts exhausted with blank output shows terminal recovery`() = runTest { + val collecting = collectingSnapshot().copy(peopleCount = 4, daysCount = 2) + val ready = collecting.copy( + dietaryRestrictions = listOf("low lactose"), + proteinPreferences = listOf("chicken"), + ) + val failedPlan = planReviewSnapshot().copy( + days = emptyList(), + pendingGenerationKind = null, + pendingGenerationDayIndex = null, + ) + coEvery { sessionRepository.getActiveSession("conv") } returnsMany listOf(collecting, failedPlan) + coEvery { + sessionRepository.updateRequiredSlots( + sessionId = "session-1", + peopleCount = null, + daysCount = null, + dietaryRestrictions = listOf("low lactose"), + proteinPreferences = listOf("chicken"), + ) + } returns ready + coEvery { sessionRepository.markGenerationFailure("session-1", null, "PLAN_NO_OUTPUT", "The model did not return a plan.") } returns failedPlan + coEvery { inferenceEngine.generateStructuredOnce(any(), any(), any(), false) } returns "" + + val reply = coordinator.ingestUserMessage("conv", "low lactose, chicken") + + assertTrue(reply.content.contains("couldn't build the meal plan", ignoreCase = true)) + coVerify(exactly = 2) { inferenceEngine.generateStructuredOnce(any(), any(), any(), false) } + coVerify(exactly = 1) { sessionRepository.markGenerationFailure("session-1", null, "PLAN_NO_OUTPUT", "The model did not return a plan.") } + coVerify(exactly = 0) { sessionRepository.savePlanDraft(any(), any()) } + } + + @Test + fun `all plan generation attempts exhausted with invalid JSON shows terminal recovery`() = runTest { + val collecting = collectingSnapshot().copy(peopleCount = 4, daysCount = 2) + val ready = collecting.copy( + dietaryRestrictions = listOf("low lactose"), + proteinPreferences = listOf("chicken"), + ) + val failedPlan = planReviewSnapshot().copy( + days = emptyList(), + pendingGenerationKind = null, + pendingGenerationDayIndex = null, + ) + coEvery { sessionRepository.getActiveSession("conv") } returnsMany listOf(collecting, failedPlan) + coEvery { + sessionRepository.updateRequiredSlots( + sessionId = "session-1", + peopleCount = null, + daysCount = null, + dietaryRestrictions = listOf("low lactose"), + proteinPreferences = listOf("chicken"), + ) + } returns ready + coEvery { sessionRepository.markGenerationFailure("session-1", null, "PLAN_JSON_INVALID", any()) } returns failedPlan + coEvery { inferenceEngine.generateStructuredOnce(any(), any(), any(), false) } returns "invalid json" + + val reply = coordinator.ingestUserMessage("conv", "low lactose, chicken") + + assertTrue(reply.content.contains("couldn't build the meal plan", ignoreCase = true)) + coVerify(exactly = 2) { inferenceEngine.generateStructuredOnce(any(), any(), any(), false) } + coVerify(exactly = 1) { sessionRepository.markGenerationFailure("session-1", null, "PLAN_JSON_INVALID", any()) } + coVerify(exactly = 0) { sessionRepository.savePlanDraft(any(), any()) } + } + + @Test + fun `variety repair failure does not trigger outer retry`() = runTest { + val emptyPlanReview = planReviewSnapshot().copy(days = emptyList()) + coEvery { sessionRepository.getActiveSession("conv") } returns emptyPlanReview + coEvery { sessionRepository.getRecentMealHistory(any()) } returns listOf( + RecentMealHistoryEntry(title = "Lemon chicken", summary = "Quick dinner", proteinTags = listOf("chicken")), + RecentMealHistoryEntry(title = "Beef bowls", summary = "Weeknight bowl", proteinTags = listOf("beef")), + ) + // Plan JSON parses to 2 days, but both match recent history exactly. + // Replacement attempts return planJson() (2 days, not 1), so parseSinglePlanDay rejects them. + // This exhausts replacement retries and throws PLAN_VARIETY_REPAIR_FAILED. + coEvery { inferenceEngine.generateStructuredOnce(any(), any(), any(), false) } returns planJson() + coEvery { sessionRepository.markGenerationFailure("session-1", null, "PLAN_VARIETY_REPAIR_FAILED", any()) } returns emptyPlanReview + + val reply = coordinator.ingestUserMessage("conv", "generate recipes") + + assertTrue(reply.content.contains("varied enough"), "Expected 'varied enough' but got: ${reply.content}") + coVerify(exactly = 0) { sessionRepository.savePlanDraft(any(), any()) } + coVerify(exactly = 1) { sessionRepository.markGenerationFailure("session-1", null, "PLAN_VARIETY_REPAIR_FAILED", any()) } + } + + @Test + fun `empty plan activity shows recovery subtitle and suggestions`() = runTest { + val failedPlan = planReviewSnapshot().copy( + days = emptyList(), + pendingGenerationKind = null, + pendingGenerationDayIndex = null, + ) + coEvery { sessionRepository.getActiveSession("conv") } returns failedPlan + val activity = coordinator.activeSessionActivity("conv") + + assertNotNull(activity) + assertEquals("Review your meal plan", activity!!.title) + assertTrue(activity.subtitle.contains("couldn't be built", ignoreCase = true)) + assertTrue(activity.suggestions.any { it.command == "generate recipes" }) + assertTrue(activity.suggestions.any { it.command == "change preferences" }) + assertTrue(activity.suggestions.any { it.command == "help" }) + assertTrue(activity.suggestions.any { it.command == "cancel plan" }) + assertFalse(activity.suggestions.any { it.command == "show current plan" }) + } + + @Test + fun `empty plan suggestions show recovery options not show current plan`() = runTest { + val collecting = collectingSnapshot().copy(peopleCount = 4, daysCount = 2) + val ready = collecting.copy( + dietaryRestrictions = listOf("low lactose"), + proteinPreferences = listOf("chicken"), + ) + val failedPlan = planReviewSnapshot().copy( + days = emptyList(), + pendingGenerationKind = null, + pendingGenerationDayIndex = null, + ) + coEvery { sessionRepository.getActiveSession("conv") } returnsMany listOf(collecting, failedPlan) + coEvery { + sessionRepository.updateRequiredSlots( + sessionId = "session-1", + peopleCount = null, + daysCount = null, + dietaryRestrictions = listOf("low lactose"), + proteinPreferences = listOf("chicken"), + ) + } returns ready + coEvery { sessionRepository.markGenerationFailure("session-1", null, "PLAN_NO_OUTPUT", "The model did not return a plan.") } returns failedPlan + coEvery { inferenceEngine.generateStructuredOnce(any(), any(), any(), false) } returns "" + + // After failure, ingest another message to reach plan review handler + coordinator.ingestUserMessage("conv", "low lactose, chicken") + + // Now check the activity reflects recovery state + coEvery { sessionRepository.getActiveSession("conv") } returns failedPlan + val activity = coordinator.activeSessionActivity("conv") + + assertNotNull(activity) + assertTrue(activity!!.suggestions.any { it.command == "generate recipes" }) + assertTrue(activity.suggestions.any { it.command == "change preferences" }) + assertFalse(activity.suggestions.any { it.command == "show current plan" }) + } + + @Test + fun `old draft remains visible after rebuild failure with preference edit`() = runTest { + val snapshotWithDays = planReviewSnapshot() + // Simulate returning to slot collection with old days preserved + val collecting = collectingSnapshot().copy( + peopleCount = 4, + daysCount = 2, + days = snapshotWithDays.days, + ) + val updated = collecting.copy( + dietaryRestrictions = listOf("low lactose"), + proteinPreferences = listOf("chicken"), + ) + coEvery { sessionRepository.getActiveSession("conv") } returnsMany listOf(collecting, snapshotWithDays) + coEvery { + sessionRepository.updateRequiredSlots( + sessionId = "session-1", + peopleCount = null, + daysCount = null, + dietaryRestrictions = listOf("low lactose"), + proteinPreferences = listOf("chicken"), + ) + } returns updated + coEvery { sessionRepository.markGenerationFailure("session-1", null, "PLAN_NO_OUTPUT", "The model did not return a plan.") } returns snapshotWithDays + coEvery { inferenceEngine.generateStructuredOnce(any(), any(), any(), false) } returns "" + + val reply = coordinator.ingestUserMessage("conv", "low lactose, chicken") + + // When days is not empty, failure message mentions previous draft + assertTrue(reply.content.contains("previous draft", ignoreCase = true)) + coVerify(exactly = 2) { inferenceEngine.generateStructuredOnce(any(), any(), any(), false) } + coVerify(exactly = 1) { sessionRepository.markGenerationFailure("session-1", null, "PLAN_NO_OUTPUT", "The model did not return a plan.") } } private fun collectingSnapshot(): MealPlanSnapshot = MealPlanSnapshot( From 048bd13fbc6191c908bead105a480894f55113ed Mon Sep 17 00:00:00 2001 From: Nick Monrad Date: Thu, 4 Jun 2026 16:50:33 +1000 Subject: [PATCH 03/12] test(#1079): add cancellation-during-retry regression test --- .../skills/mealplan/MealPlannerCoordinatorTest.kt | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/core/skills/src/test/java/com/kernel/ai/core/skills/mealplan/MealPlannerCoordinatorTest.kt b/core/skills/src/test/java/com/kernel/ai/core/skills/mealplan/MealPlannerCoordinatorTest.kt index 3d74f5c13..846d34b49 100644 --- a/core/skills/src/test/java/com/kernel/ai/core/skills/mealplan/MealPlannerCoordinatorTest.kt +++ b/core/skills/src/test/java/com/kernel/ai/core/skills/mealplan/MealPlannerCoordinatorTest.kt @@ -1795,6 +1795,21 @@ class MealPlannerCoordinatorTest { coVerify(exactly = 1) { sessionRepository.markGenerationFailure("session-1", null, "PLAN_VARIETY_REPAIR_FAILED", any()) } } + @Test + fun `cancellation during retry loop returns cancellation message`() = runTest { + val emptyPlanReview = planReviewSnapshot().copy(days = emptyList()) + coEvery { sessionRepository.getActiveSession("conv") } returns emptyPlanReview + coEvery { sessionRepository.getSession("session-1") } returns emptyPlanReview.copy( + status = MealPlanSessionStatus.CANCELLED, + ) + + val reply = coordinator.ingestUserMessage("conv", "generate recipes") + + assertTrue(reply.content.contains("cancelled", ignoreCase = true)) + coVerify(exactly = 0) { sessionRepository.savePlanDraft(any(), any()) } + coVerify(exactly = 0) { sessionRepository.markGenerationFailure(any(), any(), any(), any()) } + } + @Test fun `empty plan activity shows recovery subtitle and suggestions`() = runTest { val failedPlan = planReviewSnapshot().copy( From b6abb1ead17ec5b848d342298005d6033c214727 Mon Sep 17 00:00:00 2001 From: Nick Monrad Date: Fri, 5 Jun 2026 07:42:18 +1000 Subject: [PATCH 04/12] fix(#1079): increase retry attempts to 5, fix empty-days PLAN_REVIEW chips always showing recovery options MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - MAX_PLAN_GENERATION_ATTEMPTS 2→5: model needs more chances for valid JSON - activityForSnapshot: empty-days PLAN_REVIEW now shows recovery chips even during active generation (fixes #5: missing 'Generate recipes' chip after stall) - Title corrected from 'Review your meal plan' to 'Meal Plan' when no days exist - Updated 4 tests: verify counts bumped from 2 to 5 for all-blank/all-invalid scenarios --- .../core/skills/mealplan/MealPlannerCoordinator.kt | 10 +++++----- .../skills/mealplan/MealPlannerCoordinatorTest.kt | 13 ++++++------- 2 files changed, 11 insertions(+), 12 deletions(-) diff --git a/core/skills/src/main/java/com/kernel/ai/core/skills/mealplan/MealPlannerCoordinator.kt b/core/skills/src/main/java/com/kernel/ai/core/skills/mealplan/MealPlannerCoordinator.kt index 4ea371f0e..b32370e10 100644 --- a/core/skills/src/main/java/com/kernel/ai/core/skills/mealplan/MealPlannerCoordinator.kt +++ b/core/skills/src/main/java/com/kernel/ai/core/skills/mealplan/MealPlannerCoordinator.kt @@ -1334,15 +1334,15 @@ class MealPlannerCoordinator @Inject constructor( generationActive: Boolean = false, ): MealPlannerActivity? = when (snapshot.status) { MealPlanSessionStatus.COLLECTING_REQUIRED_SLOTS -> collectingActivity(snapshot) - MealPlanSessionStatus.PLAN_REVIEW -> if (generationActive && snapshot.pendingGenerationKind == PendingGenerationKind.PLAN) { - generatingPlanActivity(snapshot) - } else if (snapshot.days.isEmpty()) { + MealPlanSessionStatus.PLAN_REVIEW -> if (snapshot.days.isEmpty()) { MealPlannerActivity( - title = "Review your meal plan", + title = "Meal Plan", subtitle = "The plan couldn't be built. Say 'generate recipes' to try again, or 'change preferences'.", state = MealPlannerActivityState.WAITING, suggestions = planReviewSuggestions(snapshot), ) + } else if (generationActive && snapshot.pendingGenerationKind == PendingGenerationKind.PLAN) { + generatingPlanActivity(snapshot) } else { MealPlannerActivity( title = "Review your meal plan", @@ -2305,7 +2305,7 @@ Rules: private const val PENDING_COMPLETED_SUMMARY_LIMIT = 3 private const val MAX_PLAN_VARIETY_REPAIR_PASSES = 2 private const val MAX_DAY_VARIETY_REPAIR_ATTEMPTS = 2 - private const val MAX_PLAN_GENERATION_ATTEMPTS = 2 + private const val MAX_PLAN_GENERATION_ATTEMPTS = 5 private const val MAX_PROMPT_HISTORY_TITLES = 6 private const val MAX_PROMPT_HISTORY_PATTERNS = 4 private val COMMON_PROTEINS = listOf( diff --git a/core/skills/src/test/java/com/kernel/ai/core/skills/mealplan/MealPlannerCoordinatorTest.kt b/core/skills/src/test/java/com/kernel/ai/core/skills/mealplan/MealPlannerCoordinatorTest.kt index 846d34b49..fe0f80178 100644 --- a/core/skills/src/test/java/com/kernel/ai/core/skills/mealplan/MealPlannerCoordinatorTest.kt +++ b/core/skills/src/test/java/com/kernel/ai/core/skills/mealplan/MealPlannerCoordinatorTest.kt @@ -1597,7 +1597,7 @@ class MealPlannerCoordinatorTest { proteinPreferences = listOf("chicken"), ) } returns ready - // After all 2 retry attempts exhausted, markGenerationFailure is called + // After all retry attempts exhausted, markGenerationFailure is called coEvery { sessionRepository.markGenerationFailure("session-1", null, "PLAN_NO_OUTPUT", "The model did not return a plan.") } returns failedPlan coEvery { inferenceEngine.generateStructuredOnce(any(), any(), any(), false) } returns "" @@ -1609,8 +1609,7 @@ class MealPlannerCoordinatorTest { assertTrue(second.content.contains("couldn't build the meal plan yet", ignoreCase = true)) assertTrue(third.content.contains("couldn't build the meal plan", ignoreCase = true)) coVerify(exactly = 0) { sessionRepository.completeSession(any()) } - // 2 retry attempts = 2 calls to generateStructuredOnce - coVerify(exactly = 2) { inferenceEngine.generateStructuredOnce(any(), any(), any(), false) } + coVerify(exactly = 5) { inferenceEngine.generateStructuredOnce(any(), any(), any(), false) } } @Test @@ -1736,7 +1735,7 @@ class MealPlannerCoordinatorTest { val reply = coordinator.ingestUserMessage("conv", "low lactose, chicken") assertTrue(reply.content.contains("couldn't build the meal plan", ignoreCase = true)) - coVerify(exactly = 2) { inferenceEngine.generateStructuredOnce(any(), any(), any(), false) } + coVerify(exactly = 5) { inferenceEngine.generateStructuredOnce(any(), any(), any(), false) } coVerify(exactly = 1) { sessionRepository.markGenerationFailure("session-1", null, "PLAN_NO_OUTPUT", "The model did not return a plan.") } coVerify(exactly = 0) { sessionRepository.savePlanDraft(any(), any()) } } @@ -1769,7 +1768,7 @@ class MealPlannerCoordinatorTest { val reply = coordinator.ingestUserMessage("conv", "low lactose, chicken") assertTrue(reply.content.contains("couldn't build the meal plan", ignoreCase = true)) - coVerify(exactly = 2) { inferenceEngine.generateStructuredOnce(any(), any(), any(), false) } + coVerify(exactly = 5) { inferenceEngine.generateStructuredOnce(any(), any(), any(), false) } coVerify(exactly = 1) { sessionRepository.markGenerationFailure("session-1", null, "PLAN_JSON_INVALID", any()) } coVerify(exactly = 0) { sessionRepository.savePlanDraft(any(), any()) } } @@ -1821,7 +1820,7 @@ class MealPlannerCoordinatorTest { val activity = coordinator.activeSessionActivity("conv") assertNotNull(activity) - assertEquals("Review your meal plan", activity!!.title) + assertEquals("Meal Plan", activity!!.title) assertTrue(activity.subtitle.contains("couldn't be built", ignoreCase = true)) assertTrue(activity.suggestions.any { it.command == "generate recipes" }) assertTrue(activity.suggestions.any { it.command == "change preferences" }) @@ -1898,7 +1897,7 @@ class MealPlannerCoordinatorTest { // When days is not empty, failure message mentions previous draft assertTrue(reply.content.contains("previous draft", ignoreCase = true)) - coVerify(exactly = 2) { inferenceEngine.generateStructuredOnce(any(), any(), any(), false) } + coVerify(exactly = 5) { inferenceEngine.generateStructuredOnce(any(), any(), any(), false) } coVerify(exactly = 1) { sessionRepository.markGenerationFailure("session-1", null, "PLAN_NO_OUTPUT", "The model did not return a plan.") } } From 63a152634d1a128a6ee8d29e8964993ee91cecdf Mon Sep 17 00:00:00 2001 From: Nick Monrad Date: Fri, 5 Jun 2026 07:44:11 +1000 Subject: [PATCH 05/12] fix(#1079): add raw model response logging on generation failure - Blank output: logs responseWasBlank=true - JSON parse failure: logs first 200 chars of raw response - Variety repair failure: logs first 100 chars of conflict detail This lets us distinguish between timeout/blank, malformed JSON, and variety conflicts when debugging retry exhaustion. --- .../ai/core/skills/mealplan/MealPlannerCoordinator.kt | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/core/skills/src/main/java/com/kernel/ai/core/skills/mealplan/MealPlannerCoordinator.kt b/core/skills/src/main/java/com/kernel/ai/core/skills/mealplan/MealPlannerCoordinator.kt index b32370e10..724e6443d 100644 --- a/core/skills/src/main/java/com/kernel/ai/core/skills/mealplan/MealPlannerCoordinator.kt +++ b/core/skills/src/main/java/com/kernel/ai/core/skills/mealplan/MealPlannerCoordinator.kt @@ -412,7 +412,7 @@ class MealPlannerCoordinator @Inject constructor( if (rawPlan.isBlank()) { lastErrorCode = "PLAN_NO_OUTPUT" lastErrorMessage = "The model did not return a plan." - Log.w(TAG, "Plan generation failed: sessionId=${snapshot.sessionId}, attempt=$attempt/$MAX_PLAN_GENERATION_ATTEMPTS, errorCode=$lastErrorCode") + Log.w(TAG, "Plan generation failed: sessionId=${snapshot.sessionId}, attempt=$attempt/$MAX_PLAN_GENERATION_ATTEMPTS, errorCode=$lastErrorCode, responseWasBlank=true") if (attempt < MAX_PLAN_GENERATION_ATTEMPTS) continue sessionRepository.markGenerationFailure(snapshot.sessionId, null, lastErrorCode!!, lastErrorMessage!!) return@withSessionGeneration MealPlannerReply(planGenerationFailedMessage(snapshot)) @@ -422,7 +422,7 @@ class MealPlannerCoordinator @Inject constructor( } catch (e: MealPlanValidationException) { lastErrorCode = "PLAN_JSON_INVALID" lastErrorMessage = e.message ?: "Invalid plan JSON" - Log.w(TAG, "Plan generation failed: sessionId=${snapshot.sessionId}, attempt=$attempt/$MAX_PLAN_GENERATION_ATTEMPTS, errorCode=$lastErrorCode") + Log.w(TAG, "Plan generation failed: sessionId=${snapshot.sessionId}, attempt=$attempt/$MAX_PLAN_GENERATION_ATTEMPTS, errorCode=$lastErrorCode, responsePreview=${rawPlan.take(200)}") if (attempt < MAX_PLAN_GENERATION_ATTEMPTS) continue sessionRepository.markGenerationFailure(snapshot.sessionId, null, lastErrorCode!!, lastErrorMessage!!) return@withSessionGeneration MealPlannerReply(planGenerationFailedMessage(snapshot)) @@ -435,7 +435,7 @@ class MealPlannerCoordinator @Inject constructor( ) } catch (e: MealPlanValidationException) { sessionRepository.markGenerationFailure(snapshot.sessionId, null, "PLAN_VARIETY_REPAIR_FAILED", e.message ?: "Plan was too repetitive") - Log.w(TAG, "Plan generation failed: sessionId=${snapshot.sessionId}, attempt=$attempt/$MAX_PLAN_GENERATION_ATTEMPTS, errorCode=PLAN_VARIETY_REPAIR_FAILED") + Log.w(TAG, "Plan generation failed: sessionId=${snapshot.sessionId}, attempt=$attempt/$MAX_PLAN_GENERATION_ATTEMPTS, errorCode=PLAN_VARIETY_REPAIR_FAILED, conflictCount=${e.message?.take(100)}") return@withSessionGeneration MealPlannerReply( "I couldn't generate a varied enough high-level plan yet. ${e.message} Try replying with the same requirements again or adjust them.", ) From 19f9d3f95096cf06b869eb99c56b36cafffe07ee Mon Sep 17 00:00:00 2001 From: Nick Monrad Date: Fri, 5 Jun 2026 07:50:23 +1000 Subject: [PATCH 06/12] fix(#1079): smart reply chip mutual exclusivity for people/days/dietary/cuisine MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - People count chips: APPEND_COMMA → REPLACE (#1) - Days count chips: APPEND_COMMA → REPLACE (#2) - Dietary chips: 'no dietary requirements' → REPLACE, specific options → STRIP_NEGATION_IF_APPENDING Selecting a dietary restriction removes 'no dietary requirements' from text (#3) - Cuisine chips: 'no cuisine preference' → REPLACE, specific options → STRIP_NEGATION_IF_APPENDING Selecting a cuisine removes 'no cuisine preference' from text (#4) - Added STRIP_NEGATION_IF_APPENDING compose mode with stripNegationPrefixes() helper in ChatViewModel --- .../skills/mealplan/MealPlannerCoordinator.kt | 57 ++++++++++--------- .../kernel/ai/feature/chat/ChatViewModel.kt | 9 +++ 2 files changed, 38 insertions(+), 28 deletions(-) diff --git a/core/skills/src/main/java/com/kernel/ai/core/skills/mealplan/MealPlannerCoordinator.kt b/core/skills/src/main/java/com/kernel/ai/core/skills/mealplan/MealPlannerCoordinator.kt index 724e6443d..30c012a6c 100644 --- a/core/skills/src/main/java/com/kernel/ai/core/skills/mealplan/MealPlannerCoordinator.kt +++ b/core/skills/src/main/java/com/kernel/ai/core/skills/mealplan/MealPlannerCoordinator.kt @@ -2102,40 +2102,40 @@ Rules: } private fun MutableList.addPeopleSuggestions(isTop: Boolean) { - add(suggestion("2 people", "2 people", composeMode = MealPlannerSuggestionComposeMode.APPEND_COMMA)) + add(suggestion("2 people", "2 people", composeMode = MealPlannerSuggestionComposeMode.REPLACE)) if (isTop) { - add(suggestion("3 people", "3 people", composeMode = MealPlannerSuggestionComposeMode.APPEND_COMMA)) - add(suggestion("4 people", "4 people", composeMode = MealPlannerSuggestionComposeMode.APPEND_COMMA)) - add(suggestion("6 people", "6 people", composeMode = MealPlannerSuggestionComposeMode.APPEND_COMMA)) - add(suggestion("8 people", "8 people", composeMode = MealPlannerSuggestionComposeMode.APPEND_COMMA)) + add(suggestion("3 people", "3 people", composeMode = MealPlannerSuggestionComposeMode.REPLACE)) + add(suggestion("4 people", "4 people", composeMode = MealPlannerSuggestionComposeMode.REPLACE)) + add(suggestion("6 people", "6 people", composeMode = MealPlannerSuggestionComposeMode.REPLACE)) + add(suggestion("8 people", "8 people", composeMode = MealPlannerSuggestionComposeMode.REPLACE)) } else { - add(suggestion("4 people", "4 people", composeMode = MealPlannerSuggestionComposeMode.APPEND_COMMA)) + add(suggestion("4 people", "4 people", composeMode = MealPlannerSuggestionComposeMode.REPLACE)) } } private fun MutableList.addDaysSuggestions(isTop: Boolean) { - add(suggestion("4 days", "4 days", composeMode = MealPlannerSuggestionComposeMode.APPEND_COMMA)) + add(suggestion("4 days", "4 days", composeMode = MealPlannerSuggestionComposeMode.REPLACE)) if (isTop) { - add(suggestion("3 days", "3 days", composeMode = MealPlannerSuggestionComposeMode.APPEND_COMMA)) - add(suggestion("5 days", "5 days", composeMode = MealPlannerSuggestionComposeMode.APPEND_COMMA)) - add(suggestion("7 days", "7 days", composeMode = MealPlannerSuggestionComposeMode.APPEND_COMMA)) - add(suggestion("14 days", "14 days", composeMode = MealPlannerSuggestionComposeMode.APPEND_COMMA)) + add(suggestion("3 days", "3 days", composeMode = MealPlannerSuggestionComposeMode.REPLACE)) + add(suggestion("5 days", "5 days", composeMode = MealPlannerSuggestionComposeMode.REPLACE)) + add(suggestion("7 days", "7 days", composeMode = MealPlannerSuggestionComposeMode.REPLACE)) + add(suggestion("14 days", "14 days", composeMode = MealPlannerSuggestionComposeMode.REPLACE)) } else { - add(suggestion("7 days", "7 days", composeMode = MealPlannerSuggestionComposeMode.APPEND_COMMA)) + add(suggestion("7 days", "7 days", composeMode = MealPlannerSuggestionComposeMode.REPLACE)) } } private fun MutableList.addDietarySuggestions(isTop: Boolean) { - add(suggestion("no dietary requirements", "no dietary requirements", composeMode = MealPlannerSuggestionComposeMode.APPEND_COMMA)) + add(suggestion("no dietary requirements", "no dietary requirements", composeMode = MealPlannerSuggestionComposeMode.REPLACE)) if (isTop) { - add(suggestion("kid friendly", "kid friendly", composeMode = MealPlannerSuggestionComposeMode.APPEND_COMMA)) - add(suggestion("gluten free", "gluten free", composeMode = MealPlannerSuggestionComposeMode.APPEND_COMMA)) - add(suggestion("nut free", "nut free", composeMode = MealPlannerSuggestionComposeMode.APPEND_COMMA)) - add(suggestion("vegetarian", "vegetarian", composeMode = MealPlannerSuggestionComposeMode.APPEND_COMMA)) - add(suggestion("vegan", "vegan", composeMode = MealPlannerSuggestionComposeMode.APPEND_COMMA)) + add(suggestion("kid friendly", "kid friendly", composeMode = MealPlannerSuggestionComposeMode.STRIP_NEGATION_IF_APPENDING)) + add(suggestion("gluten free", "gluten free", composeMode = MealPlannerSuggestionComposeMode.STRIP_NEGATION_IF_APPENDING)) + add(suggestion("nut free", "nut free", composeMode = MealPlannerSuggestionComposeMode.STRIP_NEGATION_IF_APPENDING)) + add(suggestion("vegetarian", "vegetarian", composeMode = MealPlannerSuggestionComposeMode.STRIP_NEGATION_IF_APPENDING)) + add(suggestion("vegan", "vegan", composeMode = MealPlannerSuggestionComposeMode.STRIP_NEGATION_IF_APPENDING)) } else { - add(suggestion("kid friendly", "kid friendly", composeMode = MealPlannerSuggestionComposeMode.APPEND_COMMA)) - add(suggestion("gluten free", "gluten free", composeMode = MealPlannerSuggestionComposeMode.APPEND_COMMA)) + add(suggestion("kid friendly", "kid friendly", composeMode = MealPlannerSuggestionComposeMode.STRIP_NEGATION_IF_APPENDING)) + add(suggestion("gluten free", "gluten free", composeMode = MealPlannerSuggestionComposeMode.STRIP_NEGATION_IF_APPENDING)) } } @@ -2171,16 +2171,16 @@ Rules: } private fun MutableList.addCuisineSuggestions(isTop: Boolean) { - add(suggestion("no cuisine preference", "no cuisine preference", composeMode = MealPlannerSuggestionComposeMode.APPEND_COMMA)) + add(suggestion("no cuisine preference", "no cuisine preference", composeMode = MealPlannerSuggestionComposeMode.REPLACE)) if (isTop) { - add(suggestion("italian", "italian", composeMode = MealPlannerSuggestionComposeMode.APPEND_COMMA)) - add(suggestion("mexican", "mexican", composeMode = MealPlannerSuggestionComposeMode.APPEND_COMMA)) - add(suggestion("indian", "indian", composeMode = MealPlannerSuggestionComposeMode.APPEND_COMMA)) - add(suggestion("thai", "thai", composeMode = MealPlannerSuggestionComposeMode.APPEND_COMMA)) - add(suggestion("japanese", "japanese", composeMode = MealPlannerSuggestionComposeMode.APPEND_COMMA)) + add(suggestion("italian", "italian", composeMode = MealPlannerSuggestionComposeMode.STRIP_NEGATION_IF_APPENDING)) + add(suggestion("mexican", "mexican", composeMode = MealPlannerSuggestionComposeMode.STRIP_NEGATION_IF_APPENDING)) + add(suggestion("indian", "indian", composeMode = MealPlannerSuggestionComposeMode.STRIP_NEGATION_IF_APPENDING)) + add(suggestion("thai", "thai", composeMode = MealPlannerSuggestionComposeMode.STRIP_NEGATION_IF_APPENDING)) + add(suggestion("japanese", "japanese", composeMode = MealPlannerSuggestionComposeMode.STRIP_NEGATION_IF_APPENDING)) } else { - add(suggestion("italian", "italian", composeMode = MealPlannerSuggestionComposeMode.APPEND_COMMA)) - add(suggestion("mexican", "mexican", composeMode = MealPlannerSuggestionComposeMode.APPEND_COMMA)) + add(suggestion("italian", "italian", composeMode = MealPlannerSuggestionComposeMode.STRIP_NEGATION_IF_APPENDING)) + add(suggestion("mexican", "mexican", composeMode = MealPlannerSuggestionComposeMode.STRIP_NEGATION_IF_APPENDING)) } } @@ -2348,6 +2348,7 @@ data class MealPlannerSuggestion( enum class MealPlannerSuggestionComposeMode { REPLACE, APPEND_COMMA, + STRIP_NEGATION_IF_APPENDING, } data class MealPlannerActivity( 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 25f258e7b..991850ca4 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 @@ -1049,6 +1049,8 @@ class ChatViewModel @Inject constructor( MealPlannerSuggestionComposeMode.REPLACE -> onInputChanged(suggestion.command) MealPlannerSuggestionComposeMode.APPEND_COMMA -> onInputChanged(appendSmartReplyValue(_inputText.value, suggestion.command)) + MealPlannerSuggestionComposeMode.STRIP_NEGATION_IF_APPENDING -> + onInputChanged(appendSmartReplyValue(stripNegationPrefixes(_inputText.value), suggestion.command)) } } @@ -1066,6 +1068,13 @@ class ChatViewModel @Inject constructor( "$trimmedCurrent, $command" } } + + private fun stripNegationPrefixes(current: String): String { + return current + .replace(Regex("no dietary requirements\\s*,?\\s*", RegexOption.IGNORE_CASE), "") + .replace(Regex("no cuisine preferences?\\s*,?\\s*", RegexOption.IGNORE_CASE), "") + .trimEnd(',', ' ') + } /** Starts a one-shot voice capture: user speaks once, reply may be spoken, then loop ends. */ fun startVoiceInput() = startVoiceInput(VoiceMode.OneShot) From 67f7d74e1e8345f3f1ce1fb2751aa65b003b6431 Mon Sep 17 00:00:00 2001 From: Nick Monrad Date: Fri, 5 Jun 2026 08:51:13 +1000 Subject: [PATCH 07/12] fix(#1079): catch CancellationException in retry loop, protein chip mutual exclusivity - Catch CancellationException from generateStructuredOnce and mark generation as failed immediately instead of retrying (cancelled coroutine retries always fail) - Protein chips: 'no protein preference' uses REPLACE, specific proteins use STRIP_NEGATION_IF_APPENDING (same pattern as dietary/cuisine) - Extended stripNegationPrefixes() to strip 'no protein preference(s)' - Updated 3 tests for protein chip ordering changes --- .../skills/mealplan/MealPlannerCoordinator.kt | 32 +++++++++++-------- .../mealplan/MealPlannerCoordinatorTest.kt | 6 ++-- .../kernel/ai/feature/chat/ChatViewModel.kt | 1 + 3 files changed, 23 insertions(+), 16 deletions(-) diff --git a/core/skills/src/main/java/com/kernel/ai/core/skills/mealplan/MealPlannerCoordinator.kt b/core/skills/src/main/java/com/kernel/ai/core/skills/mealplan/MealPlannerCoordinator.kt index 30c012a6c..5f38d4819 100644 --- a/core/skills/src/main/java/com/kernel/ai/core/skills/mealplan/MealPlannerCoordinator.kt +++ b/core/skills/src/main/java/com/kernel/ai/core/skills/mealplan/MealPlannerCoordinator.kt @@ -18,6 +18,7 @@ import com.kernel.ai.core.memory.repository.MealPlanSessionRepository import com.kernel.ai.core.memory.repository.MemoryRepository import java.util.concurrent.ConcurrentHashMap import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.CancellationException import kotlinx.coroutines.sync.withLock import kotlinx.coroutines.withContext import java.time.LocalDateTime @@ -403,12 +404,18 @@ class MealPlannerCoordinator @Inject constructor( if (preAttempt.status == MealPlanSessionStatus.CANCELLED) { return@withSessionGeneration MealPlannerReply("Meal planning was cancelled.") } - val rawPlan = inferenceEngine.generateStructuredOnce( - prompt = buildPlanUserPrompt(snapshot, recentHistory, favouriteRecipes), - spec = StructuredOutputSpec.MealPlan, - systemPrompt = buildPlanSystemPrompt(), - thinkingEnabled = false, - ) + val rawPlan = try { + inferenceEngine.generateStructuredOnce( + prompt = buildPlanUserPrompt(snapshot, recentHistory, favouriteRecipes), + spec = StructuredOutputSpec.MealPlan, + systemPrompt = buildPlanSystemPrompt(), + thinkingEnabled = false, + ) + } catch (ce: CancellationException) { + Log.w(TAG, "Plan generation cancelled: sessionId=${snapshot.sessionId}, attempt=$attempt") + sessionRepository.markGenerationFailure(snapshot.sessionId, null, "PLAN_CANCELLED", "Generation job was cancelled") + return@withSessionGeneration MealPlannerReply("Meal planning was interrupted. Say 'generate recipes' to try again.") + } if (rawPlan.isBlank()) { lastErrorCode = "PLAN_NO_OUTPUT" lastErrorMessage = "The model did not return a plan." @@ -2153,19 +2160,18 @@ Rules: "tofu", "eggs", "chickpeas", - "no protein preference", ) val compatible = allOptions.filter { protein -> - protein == "no protein preference" || - detectProteinPreferenceConflicts(dietaryRestrictions, listOf(protein)).isEmpty() + detectProteinPreferenceConflicts(dietaryRestrictions, listOf(protein)).isEmpty() } + add(suggestion("no protein preference", "no protein preference", composeMode = MealPlannerSuggestionComposeMode.REPLACE)) if (isTop) { - compatible.take(6).forEach { protein -> - add(suggestion(protein.replaceFirstChar { ch -> ch.titlecase() }, protein, composeMode = MealPlannerSuggestionComposeMode.APPEND_COMMA)) + compatible.take(5).forEach { protein -> + add(suggestion(protein.replaceFirstChar { ch -> ch.titlecase() }, protein, composeMode = MealPlannerSuggestionComposeMode.STRIP_NEGATION_IF_APPENDING)) } } else { - compatible.take(3).forEach { protein -> - add(suggestion(protein.replaceFirstChar { ch -> ch.titlecase() }, protein, composeMode = MealPlannerSuggestionComposeMode.APPEND_COMMA)) + compatible.take(2).forEach { protein -> + add(suggestion(protein.replaceFirstChar { ch -> ch.titlecase() }, protein, composeMode = MealPlannerSuggestionComposeMode.STRIP_NEGATION_IF_APPENDING)) } } } diff --git a/core/skills/src/test/java/com/kernel/ai/core/skills/mealplan/MealPlannerCoordinatorTest.kt b/core/skills/src/test/java/com/kernel/ai/core/skills/mealplan/MealPlannerCoordinatorTest.kt index fe0f80178..026b19bca 100644 --- a/core/skills/src/test/java/com/kernel/ai/core/skills/mealplan/MealPlannerCoordinatorTest.kt +++ b/core/skills/src/test/java/com/kernel/ai/core/skills/mealplan/MealPlannerCoordinatorTest.kt @@ -1319,7 +1319,7 @@ class MealPlannerCoordinatorTest { "2 people", "3 people", "4 people", "6 people", "8 people", "4 days", "7 days", "no dietary requirements", "kid friendly", "gluten free", - "chicken", "beef mince", "beef", + "no protein preference", "chicken", "beef mince", "help", "cancel plan", ), activity?.suggestions?.map { it.command }, @@ -1350,7 +1350,7 @@ class MealPlannerCoordinatorTest { assertEquals( listOf( - "chicken", "beef mince", "beef", "lamb", "pork", "fish", + "no protein preference", "chicken", "beef mince", "beef", "lamb", "pork", "help", "cancel plan", ), activity?.suggestions?.map { it.command }, @@ -1388,7 +1388,7 @@ class MealPlannerCoordinatorTest { assertEquals( listOf( - "tofu", "eggs", "chickpeas", "no protein preference", + "no protein preference", "tofu", "eggs", "chickpeas", "help", "cancel plan", ), activity?.suggestions?.map { it.command }, 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 991850ca4..8c7f67d74 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 @@ -1072,6 +1072,7 @@ class ChatViewModel @Inject constructor( private fun stripNegationPrefixes(current: String): String { return current .replace(Regex("no dietary requirements\\s*,?\\s*", RegexOption.IGNORE_CASE), "") + .replace(Regex("no protein preferences?\\s*,?\\s*", RegexOption.IGNORE_CASE), "") .replace(Regex("no cuisine preferences?\\s*,?\\s*", RegexOption.IGNORE_CASE), "") .trimEnd(',', ' ') } From 0fffb3e08fce76651fc7d135dfef6b4aff1e7adb Mon Sep 17 00:00:00 2001 From: Nick Monrad Date: Fri, 5 Jun 2026 08:52:48 +1000 Subject: [PATCH 08/12] chore: add .omp/plans/ to gitignore --- .gitignore | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.gitignore b/.gitignore index 2d849c11e..b0708d96b 100644 --- a/.gitignore +++ b/.gitignore @@ -54,3 +54,6 @@ third_party/sherpa-onnx/ # Legacy local Sherpa voice assets (kept ignored if they still exist from old setup flows). core/voice/src/main/assets/sherpa-tts/ + +# Agent plans (local-only) +.omp/plans/ From 5f9d6769d4f1e00214e9c00f352d3d915dcb0d2e Mon Sep 17 00:00:00 2001 From: Nick Monrad Date: Fri, 5 Jun 2026 09:58:56 +1000 Subject: [PATCH 09/12] fix(#1079): fix generationMutex deadlock in generateStructuredOnce MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit generateStructuredOnce() called resetConversationForConfig() while still holding generationMutex. resetConversationForConfig() tries to acquire the same mutex at line 1210. Since Mutex is non-reentrant, this caused a self-deadlock — the coroutine suspended forever waiting on itself. The leaked mutex caused every subsequent generation attempt to time out in the delay(250) mutex acquisition loop (up to 5s), which threw CancellationException. Fix: move config restore AFTER generationMutex.unlock(), mirroring generateOnce(). Also re-throw CancellationException before the generic Exception catch. --- .../com/kernel/ai/core/inference/LiteRtInferenceEngine.kt | 8 +++++--- lsp.json | 1 + 2 files changed, 6 insertions(+), 3 deletions(-) diff --git a/core/inference/src/main/java/com/kernel/ai/core/inference/LiteRtInferenceEngine.kt b/core/inference/src/main/java/com/kernel/ai/core/inference/LiteRtInferenceEngine.kt index 46eedc986..869627e1c 100644 --- a/core/inference/src/main/java/com/kernel/ai/core/inference/LiteRtInferenceEngine.kt +++ b/core/inference/src/main/java/com/kernel/ai/core/inference/LiteRtInferenceEngine.kt @@ -1184,13 +1184,15 @@ class LiteRtInferenceEngine @Inject constructor( } } finally { resetExperimentalFlags() - if (shouldSwapConfig) { - resetConversationForConfig(config) - } } } finally { generationMutex.unlock() + if (shouldSwapConfig) { + resetConversationForConfig(config) + } } + } catch (ce: CancellationException) { + throw ce } catch (e: Exception) { Log.w(TAG, "generateStructuredOnce: error", e) "" diff --git a/lsp.json b/lsp.json index 9b8d18424..1708c0388 100644 --- a/lsp.json +++ b/lsp.json @@ -1,5 +1,6 @@ { "kotlin-lsp": { + "disabled": true, "warmupTimeoutMs": 90000 } } From de7687d3d48ab7bd0081b5908e70d5354d6ebf8a Mon Sep 17 00:00:00 2001 From: Nick Monrad Date: Fri, 5 Jun 2026 12:44:43 +1000 Subject: [PATCH 10/12] fix(#1079): add missing logs to silent early-return paths in generateStructuredOnce engine null (line 1027) and currentConfig null (line 969) both returned '' without any log, collapsing distinct failure modes into the blank-output retry path. Now logs warn when these occur so device-side debugging can identify the actual failure. Also adds per-attempt start log in MealPlannerCoordinator retry loop. --- .../kernel/ai/core/inference/LiteRtInferenceEngine.kt | 10 ++++++++-- .../ai/core/skills/mealplan/MealPlannerCoordinator.kt | 1 + 2 files changed, 9 insertions(+), 2 deletions(-) diff --git a/core/inference/src/main/java/com/kernel/ai/core/inference/LiteRtInferenceEngine.kt b/core/inference/src/main/java/com/kernel/ai/core/inference/LiteRtInferenceEngine.kt index 869627e1c..7b54472d4 100644 --- a/core/inference/src/main/java/com/kernel/ai/core/inference/LiteRtInferenceEngine.kt +++ b/core/inference/src/main/java/com/kernel/ai/core/inference/LiteRtInferenceEngine.kt @@ -966,7 +966,10 @@ class LiteRtInferenceEngine @Inject constructor( systemPrompt: String?, thinkingEnabled: Boolean?, ): String = withContext(LlmDispatcher) { - val config = currentConfig ?: return@withContext "" + val config = currentConfig ?: run { + Log.w(TAG, "generateStructuredOnce: currentConfig is null — engine not initialized?") + return@withContext "" + } Log.d( TAG, "generateStructuredOnce: spec='${spec.toolName}', schemaLen=${spec.jsonSchema.length}, thinking=$thinkingEnabled", @@ -1024,7 +1027,10 @@ class LiteRtInferenceEngine @Inject constructor( safeClose(conversation, "conversation") } - val eng = engine ?: return@withContext "" + val eng = engine ?: run { + Log.w(TAG, "generateStructuredOnce: engine is null — was it evicted?") + return@withContext "" + } val convConfig = buildConversationConfig(_activeBackend.value ?: BackendType.CPU, requestedConfig) // Isolate to synthetic tool only — no other tools interfere with constrained decoding. diff --git a/core/skills/src/main/java/com/kernel/ai/core/skills/mealplan/MealPlannerCoordinator.kt b/core/skills/src/main/java/com/kernel/ai/core/skills/mealplan/MealPlannerCoordinator.kt index 5f38d4819..2064d5d85 100644 --- a/core/skills/src/main/java/com/kernel/ai/core/skills/mealplan/MealPlannerCoordinator.kt +++ b/core/skills/src/main/java/com/kernel/ai/core/skills/mealplan/MealPlannerCoordinator.kt @@ -400,6 +400,7 @@ class MealPlannerCoordinator @Inject constructor( var lastErrorCode: String? = null var lastErrorMessage: String? = null for (attempt in 1..MAX_PLAN_GENERATION_ATTEMPTS) { + Log.d(TAG, "Plan generation attempt $attempt/$MAX_PLAN_GENERATION_ATTEMPTS: sessionId=${snapshot.sessionId}") val preAttempt = sessionRepository.getSession(snapshot.sessionId) ?: snapshot if (preAttempt.status == MealPlanSessionStatus.CANCELLED) { return@withSessionGeneration MealPlannerReply("Meal planning was cancelled.") From f65bd557db2d57bd7c1598ab80d002e789928c71 Mon Sep 17 00:00:00 2001 From: Nick Monrad Date: Fri, 5 Jun 2026 13:49:22 +1000 Subject: [PATCH 11/12] fix(#1079): revert meal planner to generateOnce from generateStructuredOnce PR #976 introduced generateStructuredOnce (synthetic tool call) which was documented as broken in issues #975/#977. The model produces valid tool calls but with day_index: null because LiteRT constrained decoding doesn't enforce the integer constraint on tool-call arguments. Revert all 4 call sites to generateOnce(stopOnFirstJsonObject=true) which was the working approach before PR #976. Also revert prompt builders to remove "call the tool" instructions. Retain the generateStructuredOnce deadlock fix (generationMutex unlock before resetConversationForConfig) for future use. --- .../skills/mealplan/MealPlannerCoordinator.kt | 29 +++-- .../mealplan/MealPlannerCoordinatorTest.kt | 112 +++++++++--------- 2 files changed, 70 insertions(+), 71 deletions(-) diff --git a/core/skills/src/main/java/com/kernel/ai/core/skills/mealplan/MealPlannerCoordinator.kt b/core/skills/src/main/java/com/kernel/ai/core/skills/mealplan/MealPlannerCoordinator.kt index 2064d5d85..738cbb660 100644 --- a/core/skills/src/main/java/com/kernel/ai/core/skills/mealplan/MealPlannerCoordinator.kt +++ b/core/skills/src/main/java/com/kernel/ai/core/skills/mealplan/MealPlannerCoordinator.kt @@ -3,7 +3,6 @@ package com.kernel.ai.core.skills.mealplan import android.util.Log import com.kernel.ai.core.inference.EmbeddingEngine import com.kernel.ai.core.inference.InferenceEngine -import com.kernel.ai.core.inference.StructuredOutputSpec import com.kernel.ai.core.memory.mealplan.FavouriteRecipeMode import com.kernel.ai.core.memory.mealplan.FavouriteRecipeSummary import com.kernel.ai.core.memory.mealplan.MealPlanDayStatus @@ -406,11 +405,11 @@ class MealPlannerCoordinator @Inject constructor( return@withSessionGeneration MealPlannerReply("Meal planning was cancelled.") } val rawPlan = try { - inferenceEngine.generateStructuredOnce( + inferenceEngine.generateOnce( prompt = buildPlanUserPrompt(snapshot, recentHistory, favouriteRecipes), - spec = StructuredOutputSpec.MealPlan, systemPrompt = buildPlanSystemPrompt(), thinkingEnabled = false, + stopOnFirstJsonObject = true, ) } catch (ce: CancellationException) { Log.w(TAG, "Plan generation cancelled: sessionId=${snapshot.sessionId}, attempt=$attempt") @@ -517,11 +516,11 @@ class MealPlannerCoordinator @Inject constructor( val enforceRecentPatternDiversity = shouldEnforceRecentPatternDiversity(snapshot) val favouriteRecipes = sessionRepository.getFavouriteRecipes(MAX_FAVOURITE_PROMPT_RECIPES) repeat(MAX_DAY_VARIETY_REPAIR_ATTEMPTS) { - val raw = inferenceEngine.generateStructuredOnce( + val raw = inferenceEngine.generateOnce( prompt = buildReplacementDayUserPrompt(snapshot, currentDays, dayIndex, recentHistory, favouriteRecipes), - spec = StructuredOutputSpec.ReplacementDay, systemPrompt = buildReplacementDaySystemPrompt(dayIndex), thinkingEnabled = false, + stopOnFirstJsonObject = true, ) if (raw.isBlank()) { return@repeat @@ -959,11 +958,11 @@ class MealPlannerCoordinator @Inject constructor( if (markPendingGeneration) { sessionRepository.markPendingGeneration(snapshot.sessionId, PendingGenerationKind.RECIPE, dayIndex) } - val rawRecipe = inferenceEngine.generateStructuredOnce( + val rawRecipe = inferenceEngine.generateOnce( prompt = buildRecipeUserPrompt(snapshot, dayIndex), - spec = StructuredOutputSpec.Recipe, systemPrompt = buildRecipeSystemPrompt(), thinkingEnabled = false, + stopOnFirstJsonObject = true, ) if (rawRecipe.isBlank()) { sessionRepository.markGenerationFailure( @@ -1243,11 +1242,11 @@ class MealPlannerCoordinator @Inject constructor( val recentHistory = sessionRepository.getRecentMealHistory(RECENT_MEAL_HISTORY_LIMIT) val favouriteRecipes = sessionRepository.getFavouriteRecipes(MAX_FAVOURITE_PROMPT_RECIPES) val enforceRecentPatternDiversity = shouldEnforceRecentPatternDiversity(snapshot) - val raw = inferenceEngine.generateStructuredOnce( + val raw = inferenceEngine.generateOnce( prompt = buildReplacementDayUserPrompt(snapshot, dayIndex, recentHistory, favouriteRecipes), - spec = StructuredOutputSpec.ReplacementDay, systemPrompt = buildReplacementDaySystemPrompt(dayIndex), thinkingEnabled = false, + stopOnFirstJsonObject = true, ) if (raw.isBlank()) { throw MealPlanValidationException("The model didn't return a replacement day.") @@ -1763,8 +1762,8 @@ class MealPlannerCoordinator @Inject constructor( private fun buildPlanSystemPrompt(): String = """ You generate a high-level meal plan for a local-first Android assistant. -You MUST call the tool `emit_meal_plan` with your plan as the single argument. -The argument must be a JSON object with this exact shape: +Output ONLY the JSON object — no other text, markdown, or code fences. +The output must have this exact shape: { "days": [ { @@ -1815,8 +1814,8 @@ Rules: private fun buildRecipeSystemPrompt(): String = """ You generate one recipe day for a local-first Android assistant. -You MUST call the tool `emit_recipe` with your recipe as the single argument. -The argument must be a JSON object with this exact shape: +Output ONLY the JSON object — no other text, markdown, or code fences. +The output must have this exact shape: { "title": "...", "servings": 4, @@ -1859,8 +1858,8 @@ Provide a practical Australia/New Zealand dinner recipe with a concise ingredien private fun buildReplacementDaySystemPrompt(dayIndex: Int): String = """ You generate a replacement high-level meal-plan day for a local-first Android assistant. -You MUST call the tool `emit_replacement_day` with your replacement day as the single argument. -The argument must be a JSON object with this exact shape: +Output ONLY the JSON object — no other text, markdown, or code fences. +The output must have this exact shape: { "days": [ { diff --git a/core/skills/src/test/java/com/kernel/ai/core/skills/mealplan/MealPlannerCoordinatorTest.kt b/core/skills/src/test/java/com/kernel/ai/core/skills/mealplan/MealPlannerCoordinatorTest.kt index 026b19bca..e100d7aaa 100644 --- a/core/skills/src/test/java/com/kernel/ai/core/skills/mealplan/MealPlannerCoordinatorTest.kt +++ b/core/skills/src/test/java/com/kernel/ai/core/skills/mealplan/MealPlannerCoordinatorTest.kt @@ -136,7 +136,7 @@ class MealPlannerCoordinatorTest { proteinPreferences = listOf("chicken"), ) } returns ready - coEvery { inferenceEngine.generateStructuredOnce(any(), any(), any(), false) } returns planJson() + coEvery { inferenceEngine.generateOnce(any(), any(), any(), any()) } returns planJson() coEvery { sessionRepository.savePlanDraft("session-1", any()) } returns reviewed val reply = coordinator.ingestUserMessage("conv", "low lactose, chicken") @@ -174,7 +174,7 @@ class MealPlannerCoordinatorTest { proteinTags = listOf("chicken"), ), ) - coEvery { inferenceEngine.generateStructuredOnce(any(), any(), any(), false) } returnsMany listOf( + coEvery { inferenceEngine.generateOnce(any(), any(), any(), any()) } returnsMany listOf( planJson(), "{}", replacementJson(dayIndex = 0, title = "Chicken schnitzel", proteinTag = "chicken"), @@ -188,16 +188,16 @@ class MealPlannerCoordinatorTest { assertEquals(listOf("Chicken schnitzel", "Beef bowls"), savedDays.map { it.title }) assertTrue(reply.content.contains("Chicken schnitzel", ignoreCase = true)) - coVerify(exactly = 3) { inferenceEngine.generateStructuredOnce(any(), any(), any(), false) } + coVerify(exactly = 3) { inferenceEngine.generateOnce(any(), any(), any(), any()) } coVerify { - inferenceEngine.generateStructuredOnce( + inferenceEngine.generateOnce( match { it.contains("Recent meals to avoid repeating", ignoreCase = true) && it.contains("Lemon chicken", ignoreCase = true) }, any(), any(), - false, + any(), ) } } @@ -228,7 +228,7 @@ class MealPlannerCoordinatorTest { proteinTags = listOf("chicken"), ), ) - coEvery { inferenceEngine.generateStructuredOnce(any(), any(), any(), false) } returnsMany listOf( + coEvery { inferenceEngine.generateOnce(any(), any(), any(), any()) } returnsMany listOf( planJson( day0Title = "Garlic chicken stir fry", day0Summary = "Fast skillet dinner", @@ -272,7 +272,7 @@ class MealPlannerCoordinatorTest { proteinTags = listOf("chicken"), ), ) - coEvery { inferenceEngine.generateStructuredOnce(any(), any(), any(), false) } returns planJson( + coEvery { inferenceEngine.generateOnce(any(), any(), any(), any()) } returns planJson( day0Title = "Garlic chicken stir fry", day0Summary = "Fast skillet dinner", day0Protein = "chicken", @@ -289,7 +289,7 @@ class MealPlannerCoordinatorTest { assertEquals(listOf("Garlic chicken stir fry", "Lemon chicken tray bake"), savedDays.take(2).map { it.title }) assertTrue(reply.content.contains("generate recipes", ignoreCase = true)) - coVerify(exactly = 1) { inferenceEngine.generateStructuredOnce(any(), any(), any(), false) } + coVerify(exactly = 1) { inferenceEngine.generateOnce(any(), any(), any(), any()) } } @Test @@ -327,7 +327,7 @@ class MealPlannerCoordinatorTest { proteinTags = listOf("beef"), ), ) - coEvery { inferenceEngine.generateStructuredOnce(any(), any(), any(), false) } returnsMany listOf( + coEvery { inferenceEngine.generateOnce(any(), any(), any(), any()) } returnsMany listOf( planJson( day0Title = "Garlic chicken skillet", day0Summary = "Fast stovetop dinner", @@ -369,7 +369,7 @@ class MealPlannerCoordinatorTest { proteinPreferences = listOf("chicken", "beef"), ) } returns ready - coEvery { inferenceEngine.generateStructuredOnce(any(), any(), any(), false) } returnsMany listOf( + coEvery { inferenceEngine.generateOnce(any(), any(), any(), any()) } returnsMany listOf( planJson( day0Title = "Chicken stir fry", day0Summary = "Quick dinner", @@ -420,7 +420,7 @@ class MealPlannerCoordinatorTest { proteinPreferences = null, ) } returns ready - coEvery { inferenceEngine.generateStructuredOnce(any(), any(), any(), false) } returns planJson() + coEvery { inferenceEngine.generateOnce(any(), any(), any(), any()) } returns planJson() coEvery { sessionRepository.savePlanDraft("session-1", any()) } returns reviewed val reply = coordinator.ingestUserMessage("conv", "No dietary requirements") @@ -459,7 +459,7 @@ class MealPlannerCoordinatorTest { proteinPreferences = null, ) } returns ready - coEvery { inferenceEngine.generateStructuredOnce(any(), any(), any(), false) } returns planJson() + coEvery { inferenceEngine.generateOnce(any(), any(), any(), any()) } returns planJson() coEvery { sessionRepository.savePlanDraft("session-1", any()) } returns reviewed val reply = coordinator.ingestUserMessage("conv", "No restrictions") @@ -498,7 +498,7 @@ class MealPlannerCoordinatorTest { proteinPreferences = null, ) } returns ready - coEvery { inferenceEngine.generateStructuredOnce(any(), any(), any(), false) } returns planJson() + coEvery { inferenceEngine.generateOnce(any(), any(), any(), any()) } returns planJson() coEvery { sessionRepository.savePlanDraft("session-1", any()) } returns reviewed val reply = coordinator.ingestUserMessage("conv", "No requirements") @@ -537,7 +537,7 @@ class MealPlannerCoordinatorTest { proteinPreferences = listOf("no protein preference"), ) } returns ready - coEvery { inferenceEngine.generateStructuredOnce(any(), any(), any(), false) } returns planJson() + coEvery { inferenceEngine.generateOnce(any(), any(), any(), any()) } returns planJson() coEvery { sessionRepository.savePlanDraft("session-1", any()) } returns reviewed val reply = coordinator.ingestUserMessage("conv", "Any") @@ -575,7 +575,7 @@ class MealPlannerCoordinatorTest { assertTrue(reply.content.contains("don't fit vegetarian", ignoreCase = true)) assertTrue(reply.content.contains("no protein preference", ignoreCase = true)) - coVerify(exactly = 0) { inferenceEngine.generateStructuredOnce(any(), any(), any(), false) } + coVerify(exactly = 0) { inferenceEngine.generateOnce(any(), any(), any(), any()) } } @Test @@ -607,7 +607,7 @@ class MealPlannerCoordinatorTest { assertTrue(reply.content.contains("egg free", ignoreCase = true)) assertTrue(reply.content.contains("What protein preferences should I use?", ignoreCase = true)) assertFalse(reply.content.contains("don't fit egg free", ignoreCase = true)) - coVerify(exactly = 0) { inferenceEngine.generateStructuredOnce(any(), any(), any(), false) } + coVerify(exactly = 0) { inferenceEngine.generateOnce(any(), any(), any(), any()) } } @Test @@ -639,7 +639,7 @@ class MealPlannerCoordinatorTest { assertTrue(reply.content.contains("gluten free", ignoreCase = true)) assertTrue(reply.content.contains("egg free", ignoreCase = true)) assertFalse(reply.content.contains("no gluten free", ignoreCase = true)) - coVerify(exactly = 0) { inferenceEngine.generateStructuredOnce(any(), any(), any(), false) } + coVerify(exactly = 0) { inferenceEngine.generateOnce(any(), any(), any(), any()) } } @Test @@ -660,7 +660,7 @@ class MealPlannerCoordinatorTest { coEvery { sessionRepository.getActiveSession("conv") } returns planReview coEvery { sessionRepository.getSession("session-1") } returns planReview - coEvery { inferenceEngine.generateStructuredOnce(any(), any(), any(), false) } returnsMany listOf(recipeJson("Lemon chicken"), recipeJson("Beef bowls")) + coEvery { inferenceEngine.generateOnce(any(), any(), any(), any()) } returnsMany listOf(recipeJson("Lemon chicken"), recipeJson("Beef bowls")) coEvery { sessionRepository.persistRecipeDraft("session-1", 0, any(), any(), any()) } returns afterDay1 coEvery { sessionRepository.persistRecipeDraft("session-1", 1, any(), any(), any()) } returns completed @@ -686,7 +686,7 @@ class MealPlannerCoordinatorTest { coEvery { sessionRepository.getActiveSession("conv") } returns planReview coEvery { sessionRepository.getSession("session-1") } returns planReview - coEvery { inferenceEngine.generateStructuredOnce(any(), any(), any(), false) } returns recipeJson("Lemon chicken") + coEvery { inferenceEngine.generateOnce(any(), any(), any(), any()) } returns recipeJson("Lemon chicken") coEvery { sessionRepository.persistRecipeDraft("session-1", 0, any(), any(), any()) } returns cancelled val reply = coordinator.ingestUserMessage("conv", "generate recipes", onPlannerMessage = { emitted += it }) @@ -713,7 +713,7 @@ class MealPlannerCoordinatorTest { coEvery { sessionRepository.getActiveSession("conv") } returns planReview coEvery { sessionRepository.getSession("session-1") } returns planReview - coEvery { inferenceEngine.generateStructuredOnce(any(), any(), any(), false) } returnsMany listOf(recipeJson("Lemon chicken"), recipeJson("Beef bowls")) + coEvery { inferenceEngine.generateOnce(any(), any(), any(), any()) } returnsMany listOf(recipeJson("Lemon chicken"), recipeJson("Beef bowls")) coEvery { sessionRepository.persistRecipeDraft("session-1", 0, any(), any(), any()) } returns afterDay1 coEvery { sessionRepository.persistRecipeDraft("session-1", 1, any(), any(), any()) } returns completed @@ -746,7 +746,7 @@ class MealPlannerCoordinatorTest { assertTrue(reply.content.contains("Day 1: Lemon chicken", ignoreCase = true)) assertTrue(reply.content.contains("Day 2: Turkey bowls", ignoreCase = true)) assertTrue(reply.content.contains("generate recipes", ignoreCase = true)) - coVerify(exactly = 0) { inferenceEngine.generateStructuredOnce(any(), any(), any(), any()) } + coVerify(exactly = 0) { inferenceEngine.generateOnce(any(), any(), any(), any()) } } @Test fun `plan review change preferences returns to editable slot collection`() = runTest { @@ -797,7 +797,7 @@ class MealPlannerCoordinatorTest { favouriteRecipeMode = FavouriteRecipeMode.PREFER, ) } - coVerify(exactly = 0) { inferenceEngine.generateStructuredOnce(any(), any(), any(), any()) } + coVerify(exactly = 0) { inferenceEngine.generateOnce(any(), any(), any(), any()) } } @Test @@ -835,7 +835,7 @@ class MealPlannerCoordinatorTest { ) val activities = mutableListOf() coEvery { sessionRepository.getActiveSession("conv") } returns planReview - coEvery { inferenceEngine.generateStructuredOnce(any(), any(), any(), false) } returns replacementJson(dayIndex = 0, title = "Turkey skillet") + coEvery { inferenceEngine.generateOnce(any(), any(), any(), any()) } returns replacementJson(dayIndex = 0, title = "Turkey skillet") coEvery { sessionRepository.replaceDayDraft("session-1", 0, "Turkey skillet", any(), any(), false) } returns replaced coordinator.ingestUserMessage( @@ -886,7 +886,7 @@ class MealPlannerCoordinatorTest { assertTrue(reply.content.contains("current plan details", ignoreCase = true)) coVerify(exactly = 0) { sessionRepository.markPendingGeneration("session-1", PendingGenerationKind.PLAN) } - coVerify(exactly = 0) { inferenceEngine.generateStructuredOnce(any(), any(), any(), any()) } + coVerify(exactly = 0) { inferenceEngine.generateOnce(any(), any(), any(), any()) } } @Test fun `collecting state can remove a single dietary restriction while preserving others`() = runTest { @@ -909,7 +909,7 @@ class MealPlannerCoordinatorTest { proteinPreferences = null, ) } returns reviewed - coEvery { inferenceEngine.generateStructuredOnce(any(), any(), any(), false) } returns planJson() + coEvery { inferenceEngine.generateOnce(any(), any(), any(), any()) } returns planJson() coEvery { sessionRepository.savePlanDraft("session-1", any()) } returns reviewed val reply = coordinator.ingestUserMessage("conv", "Remove gluten free") @@ -940,7 +940,7 @@ class MealPlannerCoordinatorTest { proteinPreferences = null, ) } returns reviewed - coEvery { inferenceEngine.generateStructuredOnce(any(), any(), any(), false) } returns planJson() + coEvery { inferenceEngine.generateOnce(any(), any(), any(), any()) } returns planJson() coEvery { sessionRepository.savePlanDraft("session-1", any()) } returns reviewed val reply = coordinator.ingestUserMessage("conv", "Remove no gluten free") @@ -972,7 +972,7 @@ class MealPlannerCoordinatorTest { proteinPreferences = null, ) } returns reviewed - coEvery { inferenceEngine.generateStructuredOnce(any(), any(), any(), false) } returns planJson() + coEvery { inferenceEngine.generateOnce(any(), any(), any(), any()) } returns planJson() coEvery { sessionRepository.savePlanDraft("session-1", any()) } returns reviewed coordinator.ingestUserMessage("conv", "Remove no chicken") @@ -1010,7 +1010,7 @@ class MealPlannerCoordinatorTest { proteinPreferences = null, ) } returns reviewed - coEvery { inferenceEngine.generateStructuredOnce(any(), any(), any(), false) } returns planJson() + coEvery { inferenceEngine.generateOnce(any(), any(), any(), any()) } returns planJson() coEvery { sessionRepository.savePlanDraft("session-1", any()) } returns reviewed coordinator.ingestUserMessage("conv", "Remove no chicken") @@ -1050,7 +1050,7 @@ class MealPlannerCoordinatorTest { assertTrue(reply.content.contains("dietary requirements", ignoreCase = true)) assertFalse(reply.content.contains("no dietary requirements", ignoreCase = true)) - coVerify(exactly = 0) { inferenceEngine.generateStructuredOnce(any(), any(), any(), false) } + coVerify(exactly = 0) { inferenceEngine.generateOnce(any(), any(), any(), any()) } } @Test @@ -1073,7 +1073,7 @@ class MealPlannerCoordinatorTest { assertTrue(reply.content.contains("Current plan details", ignoreCase = true)) assertFalse(reply.content.contains("saved favourites preferred", ignoreCase = true)) - coVerify(exactly = 0) { inferenceEngine.generateStructuredOnce(any(), any(), any(), any()) } + coVerify(exactly = 0) { inferenceEngine.generateOnce(any(), any(), any(), any()) } } @Test @@ -1097,7 +1097,7 @@ class MealPlannerCoordinatorTest { proteinPreferences = listOf("salmon"), ) } returns reviewed - coEvery { inferenceEngine.generateStructuredOnce(any(), any(), any(), false) } returns planJson() + coEvery { inferenceEngine.generateOnce(any(), any(), any(), any()) } returns planJson() coEvery { sessionRepository.savePlanDraft("session-1", any()) } returns reviewed coordinator.ingestUserMessage("conv", "remove beef and add salmon") @@ -1160,14 +1160,14 @@ class MealPlannerCoordinatorTest { proteinPreferences = null, ) } returns collecting - coEvery { inferenceEngine.generateStructuredOnce(any(), any(), any(), false) } returns planJson() + coEvery { inferenceEngine.generateOnce(any(), any(), any(), any()) } returns planJson() coEvery { sessionRepository.savePlanDraft("session-1", any()) } returns reviewed val reply = coordinator.ingestUserMessage("conv", "generate") assertTrue(reply.content.contains("Day 1: Lemon chicken", ignoreCase = true)) assertTrue(reply.content.contains("generate recipes", ignoreCase = true)) - coVerify(exactly = 1) { inferenceEngine.generateStructuredOnce(any(), any(), any(), false) } + coVerify(exactly = 1) { inferenceEngine.generateOnce(any(), any(), any(), any()) } } @Test fun `interrupted recipe generation requires explicit resume`() = runTest { @@ -1224,7 +1224,7 @@ class MealPlannerCoordinatorTest { ), ) coEvery { sessionRepository.getActiveSession("conv") } returns interrupted - coEvery { inferenceEngine.generateStructuredOnce(any(), any(), any(), false) } returns replacementJson(dayIndex = 1, title = "Pan-Seared Chicken") + coEvery { inferenceEngine.generateOnce(any(), any(), any(), any()) } returns replacementJson(dayIndex = 1, title = "Pan-Seared Chicken") coEvery { sessionRepository.replaceDayDraft("session-1", 0, "Pan-Seared Chicken", any(), any(), false) } returns replaced val reply = coordinator.ingestUserMessage("conv", "retry") @@ -1258,7 +1258,7 @@ class MealPlannerCoordinatorTest { ), ) coEvery { sessionRepository.getActiveSession("conv") } returns interrupted - coEvery { inferenceEngine.generateStructuredOnce(any(), any(), any(), false) } returnsMany listOf( + coEvery { inferenceEngine.generateOnce(any(), any(), any(), any()) } returnsMany listOf( replacementJson(dayIndex = 1, title = "Pan-Seared Chicken"), recipeJson("Pan-Seared Chicken"), ) @@ -1422,7 +1422,7 @@ class MealPlannerCoordinatorTest { assertTrue(reply.content.contains("Day 1: Chicken stir-fry", ignoreCase = true)) assertTrue(reply.content.contains("Day 2: Chicken curry", ignoreCase = true)) assertTrue(reply.content.contains("done meal planning", ignoreCase = true)) - coVerify(exactly = 0) { inferenceEngine.generateStructuredOnce(any(), any(), any(), any()) } + coVerify(exactly = 0) { inferenceEngine.generateOnce(any(), any(), any(), any()) } } @Test fun `interrupted regenerate day prompts for retry`() = runTest { @@ -1456,7 +1456,7 @@ class MealPlannerCoordinatorTest { ), ) coEvery { sessionRepository.getActiveSession("conv") } returns active - coEvery { inferenceEngine.generateStructuredOnce(any(), any(), any(), false) } returnsMany listOf( + coEvery { inferenceEngine.generateOnce(any(), any(), any(), any()) } returnsMany listOf( replacementJson(dayIndex = 2, title = "Turkey skillet"), recipeJson("Turkey skillet"), ) @@ -1483,7 +1483,7 @@ class MealPlannerCoordinatorTest { assertTrue(reply.content.contains("can only replace", ignoreCase = true)) assertTrue(reply.content.contains("6", ignoreCase = true)) assertFalse(reply.content.contains("Invalid day index", ignoreCase = true)) - coVerify(exactly = 0) { inferenceEngine.generateStructuredOnce(any(), any(), any(), any()) } + coVerify(exactly = 0) { inferenceEngine.generateOnce(any(), any(), any(), any()) } } @Test @@ -1496,7 +1496,7 @@ class MealPlannerCoordinatorTest { ), ) coEvery { sessionRepository.getActiveSession("conv") } returns active - coEvery { inferenceEngine.generateStructuredOnce(any(), any(), any(), false) } returns recipeJson("Chicken curry") + coEvery { inferenceEngine.generateOnce(any(), any(), any(), any()) } returns recipeJson("Chicken curry") coEvery { sessionRepository.persistRecipeDraft("session-1", 1, any(), any(), any()) } returns persisted val reply = coordinator.ingestUserMessage("conv", "regenerate day 2") @@ -1530,7 +1530,7 @@ class MealPlannerCoordinatorTest { ) } returns ready coEvery { sessionRepository.markGenerationFailure("session-1", null, "PLAN_NO_OUTPUT", "The model did not return a plan.") } returns ready - coEvery { inferenceEngine.generateStructuredOnce(any(), any(), any(), false) } coAnswers { + coEvery { inferenceEngine.generateOnce(any(), any(), any(), any()) } coAnswers { started.complete(Unit) release.await() "" @@ -1567,12 +1567,12 @@ class MealPlannerCoordinatorTest { coEvery { sessionRepository.getActiveSession("conv") } returns failed coEvery { sessionRepository.markGenerationFailure("session-1", 0, "RECIPE_NO_OUTPUT", "The model did not return a recipe.") } returns failed - coEvery { inferenceEngine.generateStructuredOnce(any(), any(), any(), false) } returns "" + coEvery { inferenceEngine.generateOnce(any(), any(), any(), any()) } returns "" val reply = coordinator.ingestUserMessage("conv", "regenerate day 1") assertTrue(reply.content.contains("didn't return a recipe", ignoreCase = true)) - coVerify(exactly = 1) { inferenceEngine.generateStructuredOnce(any(), any(), any(), false) } + coVerify(exactly = 1) { inferenceEngine.generateOnce(any(), any(), any(), any()) } } @Test @@ -1599,7 +1599,7 @@ class MealPlannerCoordinatorTest { } returns ready // After all retry attempts exhausted, markGenerationFailure is called coEvery { sessionRepository.markGenerationFailure("session-1", null, "PLAN_NO_OUTPUT", "The model did not return a plan.") } returns failedPlan - coEvery { inferenceEngine.generateStructuredOnce(any(), any(), any(), false) } returns "" + coEvery { inferenceEngine.generateOnce(any(), any(), any(), any()) } returns "" val first = coordinator.ingestUserMessage("conv", "low lactose, chicken") val second = coordinator.ingestUserMessage("conv", "help") @@ -1609,7 +1609,7 @@ class MealPlannerCoordinatorTest { assertTrue(second.content.contains("couldn't build the meal plan yet", ignoreCase = true)) assertTrue(third.content.contains("couldn't build the meal plan", ignoreCase = true)) coVerify(exactly = 0) { sessionRepository.completeSession(any()) } - coVerify(exactly = 5) { inferenceEngine.generateStructuredOnce(any(), any(), any(), false) } + coVerify(exactly = 5) { inferenceEngine.generateOnce(any(), any(), any(), any()) } } @Test @@ -1667,13 +1667,13 @@ class MealPlannerCoordinatorTest { proteinPreferences = listOf("chicken"), ) } returns ready - coEvery { inferenceEngine.generateStructuredOnce(any(), any(), any(), false) } returnsMany listOf("", planJson()) + coEvery { inferenceEngine.generateOnce(any(), any(), any(), any()) } returnsMany listOf("", planJson()) coEvery { sessionRepository.savePlanDraft("session-1", any()) } returns reviewed val reply = coordinator.ingestUserMessage("conv", "low lactose, chicken") assertTrue(reply.content.contains("Chicken", ignoreCase = true)) - coVerify(exactly = 2) { inferenceEngine.generateStructuredOnce(any(), any(), any(), false) } + coVerify(exactly = 2) { inferenceEngine.generateOnce(any(), any(), any(), any()) } coVerify(exactly = 1) { sessionRepository.savePlanDraft("session-1", any()) } coVerify(exactly = 0) { sessionRepository.markGenerationFailure(any(), any(), any(), any()) } } @@ -1696,13 +1696,13 @@ class MealPlannerCoordinatorTest { proteinPreferences = listOf("chicken"), ) } returns ready - coEvery { inferenceEngine.generateStructuredOnce(any(), any(), any(), false) } returnsMany listOf("not valid json at all", planJson()) + coEvery { inferenceEngine.generateOnce(any(), any(), any(), any()) } returnsMany listOf("not valid json at all", planJson()) coEvery { sessionRepository.savePlanDraft("session-1", any()) } returns reviewed val reply = coordinator.ingestUserMessage("conv", "low lactose, chicken") assertTrue(reply.content.contains("Chicken", ignoreCase = true)) - coVerify(exactly = 2) { inferenceEngine.generateStructuredOnce(any(), any(), any(), false) } + coVerify(exactly = 2) { inferenceEngine.generateOnce(any(), any(), any(), any()) } coVerify(exactly = 1) { sessionRepository.savePlanDraft("session-1", any()) } coVerify(exactly = 0) { sessionRepository.markGenerationFailure(any(), any(), any(), any()) } } @@ -1730,12 +1730,12 @@ class MealPlannerCoordinatorTest { ) } returns ready coEvery { sessionRepository.markGenerationFailure("session-1", null, "PLAN_NO_OUTPUT", "The model did not return a plan.") } returns failedPlan - coEvery { inferenceEngine.generateStructuredOnce(any(), any(), any(), false) } returns "" + coEvery { inferenceEngine.generateOnce(any(), any(), any(), any()) } returns "" val reply = coordinator.ingestUserMessage("conv", "low lactose, chicken") assertTrue(reply.content.contains("couldn't build the meal plan", ignoreCase = true)) - coVerify(exactly = 5) { inferenceEngine.generateStructuredOnce(any(), any(), any(), false) } + coVerify(exactly = 5) { inferenceEngine.generateOnce(any(), any(), any(), any()) } coVerify(exactly = 1) { sessionRepository.markGenerationFailure("session-1", null, "PLAN_NO_OUTPUT", "The model did not return a plan.") } coVerify(exactly = 0) { sessionRepository.savePlanDraft(any(), any()) } } @@ -1763,12 +1763,12 @@ class MealPlannerCoordinatorTest { ) } returns ready coEvery { sessionRepository.markGenerationFailure("session-1", null, "PLAN_JSON_INVALID", any()) } returns failedPlan - coEvery { inferenceEngine.generateStructuredOnce(any(), any(), any(), false) } returns "invalid json" + coEvery { inferenceEngine.generateOnce(any(), any(), any(), any()) } returns "invalid json" val reply = coordinator.ingestUserMessage("conv", "low lactose, chicken") assertTrue(reply.content.contains("couldn't build the meal plan", ignoreCase = true)) - coVerify(exactly = 5) { inferenceEngine.generateStructuredOnce(any(), any(), any(), false) } + coVerify(exactly = 5) { inferenceEngine.generateOnce(any(), any(), any(), any()) } coVerify(exactly = 1) { sessionRepository.markGenerationFailure("session-1", null, "PLAN_JSON_INVALID", any()) } coVerify(exactly = 0) { sessionRepository.savePlanDraft(any(), any()) } } @@ -1784,7 +1784,7 @@ class MealPlannerCoordinatorTest { // Plan JSON parses to 2 days, but both match recent history exactly. // Replacement attempts return planJson() (2 days, not 1), so parseSinglePlanDay rejects them. // This exhausts replacement retries and throws PLAN_VARIETY_REPAIR_FAILED. - coEvery { inferenceEngine.generateStructuredOnce(any(), any(), any(), false) } returns planJson() + coEvery { inferenceEngine.generateOnce(any(), any(), any(), any()) } returns planJson() coEvery { sessionRepository.markGenerationFailure("session-1", null, "PLAN_VARIETY_REPAIR_FAILED", any()) } returns emptyPlanReview val reply = coordinator.ingestUserMessage("conv", "generate recipes") @@ -1852,7 +1852,7 @@ class MealPlannerCoordinatorTest { ) } returns ready coEvery { sessionRepository.markGenerationFailure("session-1", null, "PLAN_NO_OUTPUT", "The model did not return a plan.") } returns failedPlan - coEvery { inferenceEngine.generateStructuredOnce(any(), any(), any(), false) } returns "" + coEvery { inferenceEngine.generateOnce(any(), any(), any(), any()) } returns "" // After failure, ingest another message to reach plan review handler coordinator.ingestUserMessage("conv", "low lactose, chicken") @@ -1891,13 +1891,13 @@ class MealPlannerCoordinatorTest { ) } returns updated coEvery { sessionRepository.markGenerationFailure("session-1", null, "PLAN_NO_OUTPUT", "The model did not return a plan.") } returns snapshotWithDays - coEvery { inferenceEngine.generateStructuredOnce(any(), any(), any(), false) } returns "" + coEvery { inferenceEngine.generateOnce(any(), any(), any(), any()) } returns "" val reply = coordinator.ingestUserMessage("conv", "low lactose, chicken") // When days is not empty, failure message mentions previous draft assertTrue(reply.content.contains("previous draft", ignoreCase = true)) - coVerify(exactly = 5) { inferenceEngine.generateStructuredOnce(any(), any(), any(), false) } + coVerify(exactly = 5) { inferenceEngine.generateOnce(any(), any(), any(), any()) } coVerify(exactly = 1) { sessionRepository.markGenerationFailure("session-1", null, "PLAN_NO_OUTPUT", "The model did not return a plan.") } } From 9ae75d21ec487f9a8401699e4ddfac39ea93244b Mon Sep 17 00:00:00 2001 From: Nick Monrad Date: Fri, 5 Jun 2026 14:59:05 +1000 Subject: [PATCH 12/12] chore(#1079): add debug logging to recipe and replacement generation paths --- .../ai/core/skills/mealplan/MealPlannerCoordinator.kt | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/core/skills/src/main/java/com/kernel/ai/core/skills/mealplan/MealPlannerCoordinator.kt b/core/skills/src/main/java/com/kernel/ai/core/skills/mealplan/MealPlannerCoordinator.kt index 738cbb660..8ec4d5951 100644 --- a/core/skills/src/main/java/com/kernel/ai/core/skills/mealplan/MealPlannerCoordinator.kt +++ b/core/skills/src/main/java/com/kernel/ai/core/skills/mealplan/MealPlannerCoordinator.kt @@ -515,6 +515,7 @@ class MealPlannerCoordinator @Inject constructor( ): MealPlanDraftDay { val enforceRecentPatternDiversity = shouldEnforceRecentPatternDiversity(snapshot) val favouriteRecipes = sessionRepository.getFavouriteRecipes(MAX_FAVOURITE_PROMPT_RECIPES) + Log.d(TAG, "Variety repair started: sessionId=${snapshot.sessionId}, dayIndex=$dayIndex, attemptLimit=$MAX_DAY_VARIETY_REPAIR_ATTEMPTS") repeat(MAX_DAY_VARIETY_REPAIR_ATTEMPTS) { val raw = inferenceEngine.generateOnce( prompt = buildReplacementDayUserPrompt(snapshot, currentDays, dayIndex, recentHistory, favouriteRecipes), @@ -958,6 +959,7 @@ class MealPlannerCoordinator @Inject constructor( if (markPendingGeneration) { sessionRepository.markPendingGeneration(snapshot.sessionId, PendingGenerationKind.RECIPE, dayIndex) } + Log.d(TAG, "Recipe generation started: sessionId=${snapshot.sessionId}, dayIndex=$dayIndex, dayTitle=${day.title ?: "unnamed"}") val rawRecipe = inferenceEngine.generateOnce( prompt = buildRecipeUserPrompt(snapshot, dayIndex), systemPrompt = buildRecipeSystemPrompt(), @@ -992,6 +994,7 @@ class MealPlannerCoordinator @Inject constructor( rawModelJson = rawRecipe, groceries = groceries, ) + Log.d(TAG, "Recipe generation success: sessionId=${snapshot.sessionId}, dayIndex=$dayIndex, title=${recipe.title}") return GeneratedRecipeResult(updated, recipe, day.title ?: recipe.title) } private suspend fun replaceDaysAndGenerateRecipes( @@ -1242,6 +1245,7 @@ class MealPlannerCoordinator @Inject constructor( val recentHistory = sessionRepository.getRecentMealHistory(RECENT_MEAL_HISTORY_LIMIT) val favouriteRecipes = sessionRepository.getFavouriteRecipes(MAX_FAVOURITE_PROMPT_RECIPES) val enforceRecentPatternDiversity = shouldEnforceRecentPatternDiversity(snapshot) + Log.d(TAG, "Replacement day generation started: sessionId=${snapshot.sessionId}, dayIndex=$dayIndex") val raw = inferenceEngine.generateOnce( prompt = buildReplacementDayUserPrompt(snapshot, dayIndex, recentHistory, favouriteRecipes), systemPrompt = buildReplacementDaySystemPrompt(dayIndex), @@ -1271,7 +1275,7 @@ class MealPlannerCoordinator @Inject constructor( ) { throw MealPlanValidationException("Replacement day still duplicated another planned meal too closely.") } - return sessionRepository.replaceDayDraft( + val updated = sessionRepository.replaceDayDraft( sessionId = snapshot.sessionId, dayIndex = dayIndex, title = replacement.title, @@ -1279,6 +1283,8 @@ class MealPlannerCoordinator @Inject constructor( proteinTags = replacement.proteinTags, recipeGenerationPending = !markPendingGeneration, ) + Log.d(TAG, "Replacement day generated: sessionId=${snapshot.sessionId}, dayIndex=$dayIndex, title=${replacement.title}") + return updated } catch (e: MealPlanValidationException) { sessionRepository.clearPendingGeneration(snapshot.sessionId) throw e