diff --git a/core/skills/src/main/java/com/kernel/ai/core/skills/LoadSkillSkill.kt b/core/skills/src/main/java/com/kernel/ai/core/skills/LoadSkillSkill.kt index 9ad0f3629..9ceba63e8 100644 --- a/core/skills/src/main/java/com/kernel/ai/core/skills/LoadSkillSkill.kt +++ b/core/skills/src/main/java/com/kernel/ai/core/skills/LoadSkillSkill.kt @@ -61,10 +61,23 @@ class LoadSkillSkill @Inject constructor( override suspend fun execute(call: SkillCall): SkillResult { val skillName = call.arguments["skill_name"]?.takeIf { it.isNotBlank() } ?: return SkillResult.Failure(name, "Missing required parameter: skill_name.") + // Calendar actions are handled by run_intent, not a standalone skill + if (skillName.equals("calendar", ignoreCase = true)) { + val runIntentSkill = skillRegistry.get().get("run_intent") + if (runIntentSkill != null) { + return SkillResult.Success( + "Calendar actions are handled through run_intent. " + + "Use runIntent(intentName=\"create_calendar_event\", parameters={...}).\n\n" + + runIntentSkill.fullInstructions + ) + } + } val skill = skillRegistry.get().get(skillName) ?: return SkillResult.Failure( name, - "Unknown skill: '$skillName'. Available: run_intent, get_weather, query_wikipedia, save_memory, search_memory, get_system_info, run_js", + "Unknown skill: '$skillName'. Available: run_intent, get_weather, " + + "query_wikipedia, save_memory, search_memory, get_system_info, run_js. " + + "Hint: calendar, alarm, SMS, and other device actions use run_intent." ) return SkillResult.Success(skill.fullInstructions) } diff --git a/core/skills/src/main/java/com/kernel/ai/core/skills/QuickIntentRouter.kt b/core/skills/src/main/java/com/kernel/ai/core/skills/QuickIntentRouter.kt index b35d3d4f6..f09cbb637 100644 --- a/core/skills/src/main/java/com/kernel/ai/core/skills/QuickIntentRouter.kt +++ b/core/skills/src/main/java/com/kernel/ai/core/skills/QuickIntentRouter.kt @@ -947,6 +947,17 @@ class QuickIntentRouter( paramExtractor = { _, _ -> emptyMap() }, ), + // "add something to the calendar for June 9th" / "put lunch in my calendar tomorrow" + IntentPattern( + intentName = "create_calendar_event", + regex = Regex( + """(?:add|create|schedule|put|book)\s+(?!(?:calendar\s+)?voice\s+memo\b)(?!note\b)(.{3,60}?)\s+(?:to|in|on|into)\s+(?:the\s+|my\s+)?calendar\b""", + RegexOption.IGNORE_CASE, + ), + paramExtractor = { _, raw -> extractCalendarHints(raw) }, + requiredSlots = slotContract("create_calendar_event"), + ), + // ── Calendar ── @@ -4334,11 +4345,11 @@ class QuickIntentRouter( // Fall back to the noun phrase immediately after the verb+article, stopping // before any temporal keyword (e.g. "schedule a dentist appointment Friday"). val titleFromFor = Regex( - """(?:^|\s)for\s+(?:a\s+|an\s+)?([a-zA-Z][a-zA-Z\s]{1,40}?)(?=\s+(?:at|from|on|next|this|tomorrow|today|monday|tuesday|wednesday|thursday|friday|saturday|sunday|\d)|$)""", + """(?:^|\s)for\s+(?:a\s+|an\s+)?([a-zA-Z][a-zA-Z\s]{1,40}?)(?=\s+(?:at|from|on|to|in|into|next|this|tomorrow|today|monday|tuesday|wednesday|thursday|friday|saturday|sunday|\d)|$)""", RegexOption.IGNORE_CASE, ).find(raw) val titleFromVerb = Regex( - """(?:add|create|schedule|put|book|set(?:\s+up)?)\s+(?:a\s+|an\s+)?([a-zA-Z][a-zA-Z\s]{1,40}?)(?=\s+(?:for|at|from|on|next|this|tomorrow|today|monday|tuesday|wednesday|thursday|friday|saturday|sunday|\d)|$)""", + """(?:add|create|schedule|put|book|set(?:\s+up)?)\s+(?:a\s+|an\s+)?([a-zA-Z][a-zA-Z\s]{1,40}?)(?=\s+(?:for|at|from|on|to|in|into|next|this|tomorrow|today|monday|tuesday|wednesday|thursday|friday|saturday|sunday|\d)|$)""", RegexOption.IGNORE_CASE, ).find(raw) val DATE_WORDS = setOf( @@ -4354,6 +4365,7 @@ class QuickIntentRouter( "booking", "invite", "entry", + "something", ) val rawTitle = run { val fromFor = titleFromFor?.groupValues?.get(1)?.trim() @@ -4385,6 +4397,32 @@ class QuickIntentRouter( ) dateRegex.find(lower)?.value?.trim()?.let { params["date"] = it } + // ── Date: ordinal and explicit dates ("9th of june", "June 9th") ── + if (!params.containsKey("date")) { + val ordinalDateRegex = Regex( + """\b(?:the\s+)?(\d{1,2})(?:st|nd|rd|th)?\s+(?:of\s+)?(jan(?:uary)?|feb(?:ruary)?|mar(?:ch)?|apr(?:il)?|may|jun(?:e)?|jul(?:y)?|aug(?:ust)?|sep(?:tember)?|oct(?:ober)?|nov(?:ember)?|dec(?:ember)?)\b""", + RegexOption.IGNORE_CASE, + ) + ordinalDateRegex.find(raw)?.let { match -> + val day = match.groupValues[1] + val month = match.groupValues[2].lowercase() + .replaceFirstChar { c -> c.uppercase() } + params["date"] = "$day $month" + } + } + if (!params.containsKey("date")) { + val monthFirstRegex = Regex( + """\b(jan(?:uary)?|feb(?:ruary)?|mar(?:ch)?|apr(?:il)?|may|jun(?:e)?|jul(?:y)?|aug(?:ust)?|sep(?:tember)?|oct(?:ober)?|nov(?:ember)?|dec(?:ember)?)\s+(\d{1,2})(?:st|nd|rd|th)?\b""", + RegexOption.IGNORE_CASE, + ) + monthFirstRegex.find(raw)?.let { match -> + val month = match.groupValues[1].lowercase() + .replaceFirstChar { c -> c.uppercase() } + val day = match.groupValues[2] + params["date"] = "$day $month" + } + } + // ── Time: "at 2pm", "for 2pm", "at 10:30am", "at 10:30 p.m.", "at noon/midnight", "at 10" ─ // Bare hours (no am/pm) are normalised to HH:00 so resolveTime() can parse. val timeRegex = Regex( diff --git a/core/skills/src/main/java/com/kernel/ai/core/skills/natives/NativeIntentHandler.kt b/core/skills/src/main/java/com/kernel/ai/core/skills/natives/NativeIntentHandler.kt index b94659d0d..b6a5711e0 100644 --- a/core/skills/src/main/java/com/kernel/ai/core/skills/natives/NativeIntentHandler.kt +++ b/core/skills/src/main/java/com/kernel/ai/core/skills/natives/NativeIntentHandler.kt @@ -662,10 +662,22 @@ class NativeIntentHandler @Inject constructor( // ── Calendar ────────────────────────────────────────────────────────────── private fun resolveCalendarSchedule(dateStr: String, explicitTimeStr: String?): Pair? { - val extractedDateTime = QuickIntentRouter.extractCalendarHints(dateStr) - val normalizedDateStr = extractedDateTime["date"]?.takeIf { it.isNotBlank() } ?: dateStr + // If the model passed a combined ISO datetime as the date param + // (e.g. "2026-06-06T09:00:00"), split it into date and time so + // the time component isn't silently discarded. + // Anchored on ISO date prefix to avoid false matches on natural + // language like "Sunday at 3:00 p.m." + val isoTimeMatch = Regex("""^\d{4}-\d{1,2}-\d{1,2}[T ](\d{1,2}:\d{2})""").find(dateStr) + val cleanedDateStr = if (isoTimeMatch != null) { + dateStr.replace(Regex("""[T ].*$"""), "") + } else { + dateStr + } + val extractedDateTime = QuickIntentRouter.extractCalendarHints(cleanedDateStr) + val normalizedDateStr = extractedDateTime["date"]?.takeIf { it.isNotBlank() } ?: cleanedDateStr val date = resolveDate(normalizedDateStr) ?: return null val timeStr = explicitTimeStr?.takeIf { it.isNotBlank() } + ?: isoTimeMatch?.groupValues?.get(1)?.takeIf { it.isNotBlank() } ?: extractedDateTime["time"]?.takeIf { it.isNotBlank() } return date to timeStr } @@ -2791,7 +2803,7 @@ class NativeIntentHandler @Inject constructor( * Returns null if the string cannot be resolved to a valid date. */ private fun resolveDate(dateStr: String): LocalDate? { - val input = dateStr.trim() + val input = dateStr.trim().replace(Regex("""\b(\d{1,2})(?:st|nd|rd|th)\b"""), "$1") val normalized = input.lowercase() val today = LocalDate.now() diff --git a/core/skills/src/main/java/com/kernel/ai/core/skills/natives/SearchMemorySkill.kt b/core/skills/src/main/java/com/kernel/ai/core/skills/natives/SearchMemorySkill.kt index a3c26dc7a..db68c2a2b 100644 --- a/core/skills/src/main/java/com/kernel/ai/core/skills/natives/SearchMemorySkill.kt +++ b/core/skills/src/main/java/com/kernel/ai/core/skills/natives/SearchMemorySkill.kt @@ -145,8 +145,8 @@ After calling searchMemory, incorporate its result into your reply naturally. ) if (filtered.memoryResults.isEmpty() && filtered.messageResults.isEmpty()) { - // Success: action result — LLM narration appropriate - return@withContext SkillResult.Success("No relevant memories found matching '$query'.") + // DirectReply: empty retrieval result is already the complete user-facing answer. + return@withContext SkillResult.DirectReply("No relevant memories found matching '$query'.") } val fmt = SimpleDateFormat("MMM d, yyyy", Locale.getDefault()) diff --git a/core/skills/src/test/java/com/kernel/ai/core/skills/QuickIntentRouterTest.kt b/core/skills/src/test/java/com/kernel/ai/core/skills/QuickIntentRouterTest.kt index f83122528..03860c1de 100644 --- a/core/skills/src/test/java/com/kernel/ai/core/skills/QuickIntentRouterTest.kt +++ b/core/skills/src/test/java/com/kernel/ai/core/skills/QuickIntentRouterTest.kt @@ -512,7 +512,7 @@ class QuickIntentRouterTest { input: String, expectedTitle: String, expectedDate: String, - expectedTime: String, + expectedTime: String?, ) { val result = regexOnlyRouter.route(input) assertRegexMatch(result, "create_calendar_event", input) @@ -2437,6 +2437,10 @@ class QuickIntentRouterTest { Arguments.of("set up a dentist appointment tomorrow at 5pm", "Dentist Appointment", "tomorrow", "5pm"), Arguments.of("schedule a team meeting on friday at 2pm", "Team Meeting", "friday", "2pm"), Arguments.of("set up a dentist appointment sunday at 3:00 p.m.", "Dentist Appointment", "sunday", "3:00 p.m."), + Arguments.of("add dentist to my calendar on Friday at 2pm", "Dentist", "friday", "2pm"), + Arguments.of("put lunch in my calendar tomorrow", "Lunch", "tomorrow", null), + Arguments.of("add lunch to my calendar on June 9th at 2pm", "Lunch", "9 June", "2pm"), + Arguments.of("add lunch to my calendar on 9th of June", "Lunch", "9 June", null), ) @JvmStatic @@ -2447,12 +2451,14 @@ class QuickIntentRouterTest { Arguments.of("set an appointment for 3:00 p.m. Sunday"), Arguments.of("Appointment for 3:00 p.m. Sunday"), Arguments.of("Set an appointment up for 3:00 p.m. On Monday"), + Arguments.of("add something to my calendar"), ) @JvmStatic fun calendarNeedsDatePhrases(): Stream = Stream.of( Arguments.of("set up a dentist appointment", "Dentist Appointment"), Arguments.of("schedule a budget meeting", "Budget Meeting"), + Arguments.of("add dentist appointment to my calendar", "Dentist Appointment"), ) // ── DND ─────────────────────────────────────────────────────────────── @@ -3664,7 +3670,6 @@ class QuickIntentRouterTest { @JvmStatic fun e4bFallthroughPhrases(): Stream = Stream.of( // Calendar (needs NLU for date/time/title extraction) - Arguments.of("add dentist appointment to my calendar"), // Wikipedia / knowledge Arguments.of("tell me about the history of New Zealand"), Arguments.of("who invented the internet"), diff --git a/core/skills/src/test/java/com/kernel/ai/core/skills/natives/NativeIntentHandlerTest.kt b/core/skills/src/test/java/com/kernel/ai/core/skills/natives/NativeIntentHandlerTest.kt index 3057ef964..7d627e353 100644 --- a/core/skills/src/test/java/com/kernel/ai/core/skills/natives/NativeIntentHandlerTest.kt +++ b/core/skills/src/test/java/com/kernel/ai/core/skills/natives/NativeIntentHandlerTest.kt @@ -2312,4 +2312,61 @@ class NativeIntentHandlerTest { return cursor } + + @Test + fun `resolveDate strips ordinal suffixes from date strings`() { + val method = NativeIntentHandler::class.java.getDeclaredMethod( + "resolveDate", + String::class.java, + ).also { it.isAccessible = true } + + val today = java.time.LocalDate.now() + + // "9th June" → resolveDate should strip "th" and parse "9 June" + val result = method.invoke(handler, "9th June") + assertNotNull(result, "Expected non-null for '9th June'") + val localDate = result as java.time.LocalDate + assertEquals(today.year, localDate.year, "Year defaults to current year") + assertEquals(6, localDate.monthValue, "Month should be June (6)") + assertEquals(9, localDate.dayOfMonth, "Day should be 9") + } + + @Test + fun `resolveCalendarSchedule splits ISO datetime into date and time`() { + val method = NativeIntentHandler::class.java.getDeclaredMethod( + "resolveCalendarSchedule", + String::class.java, + String::class.java, + ).apply { isAccessible = true } + + // E4B may pass "2026-06-06T09:00:00" as date with no separate time param. + // resolveCalendarSchedule should split it: date=2026-06-06, time="09:00". + val resolved = method.invoke(handler, "2026-06-06T09:00:00", null) as Pair<*, *> + val date = resolved.first as java.time.LocalDate + assertEquals(2026, date.year) + assertEquals(6, date.monthValue) + assertEquals(6, date.dayOfMonth) + assertEquals("09:00", resolved.second) + + // Space-separated variant: "2026-12-25 14:30" with no separate time + val resolved2 = method.invoke(handler, "2026-12-25 14:30", null) as Pair<*, *> + val date2 = resolved2.first as java.time.LocalDate + assertEquals(2026, date2.year) + assertEquals(12, date2.monthValue) + assertEquals(25, date2.dayOfMonth) + assertEquals("14:30", resolved2.second) + + // Explicit time param takes priority over extracted time + val resolved3 = method.invoke(handler, "2026-06-06T09:00:00", "11:00") as Pair<*, *> + assertEquals("11:00", resolved3.second) + + // Natural language like "Sunday at 3:00 p.m." must NOT be treated as ISO datetime + val resolved4 = method.invoke(handler, "Sunday at 3:00 p.m.", null) as Pair<*, *> + val date4 = resolved4.first as java.time.LocalDate + assertEquals( + java.time.LocalDate.now().with(java.time.temporal.TemporalAdjusters.next(java.time.DayOfWeek.SUNDAY)), + date4, + ) + assertEquals("3:00 p.m.", resolved4.second) + } } \ No newline at end of file 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 4e009fd0a..fbaf08ec0 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 @@ -739,7 +739,8 @@ class ChatViewModel @Inject constructor( append("1. Choose the most relevant native tool for the user's request.\n") append("2. Call that tool directly when its name and parameters are clear from the request.\n") append("3. If you are unsure about parameters or need gateway-specific rules, call load_skill first, then follow its instructions.\n") - append("4. Output ONLY the final user-facing result when successful.\n\n") + append("4. Treat load_skill results as internal instructions only. NEVER quote or paste them into the user-visible reply.\n") + append("5. Output ONLY the final user-facing result when successful.\n\n") append("CRITICAL: Execute all steps silently. Do NOT output intermediate reasoning, status updates, or tool call text.") } } @@ -1349,6 +1350,9 @@ class ChatViewModel @Inject constructor( // Actions-tab fallthroughs temporarily swap the system prompt to MINIMAL; mark the // next turn for a history replay so normal chat can restore the full prompt safely. var restoreFullPromptAfterTurn = false + // Suppressed system-only tool leaks also require a replay so the next turn does not + // inherit hidden retry/correction state that was never persisted to Room. + var forceHistoryReplayAfterTurn = false // Hoisted so the Complete handler can index the user message after knowing whether // any device-action tools were called during LLM inference. var savedUserMsgId = "" @@ -1599,19 +1603,36 @@ class ChatViewModel @Inject constructor( } } if (matchedIntent != null) { - // Calendar intent matched by classifier but params not extractable via regex — - // skip immediate execution and fall through to E4B with a structured hint. + // Calendar intent matched by classifier or regex but params not fully extractable — + // skip immediate execution. Only inject the structured hint when there is actionable + // date/time info (extractable or already in params). Pure capability queries + // ("do you know how to create calendar events") fall through naturally to E4B. if (matchedIntent.intentName == "create_calendar_event" && matchedIntent.params["title"].isNullOrBlank() ) { val rawQuery = matchedIntent.params["raw_query"] ?: text - val titleHint = matchedIntent.params["extracted_title"] - val titleClause = if (titleHint != null) "The event title is likely \"$titleHint\". " else "" - systemContext = "[System: User wants to create a calendar event. " + - "Their request: \"$rawQuery\". " + - "${titleClause}Extract the event title, date, and time, then call " + - "runIntent(intentName=\"create_calendar_event\", ...). " + - "Pass the date exactly as the user said it. Pass time as HH:MM 24h.]" + // Use extractCalendarHints on the raw query to detect actionable + // date/time info — classifier matches carry empty params, so we + // must inspect the query text itself, not matchedIntent.params. + // Note: gating on extractability means requests like "schedule + // something for next week" (where the regex doesn't match "next + // week") won't get the steer. That's intentional — the steer is + // a convenience for the model, not a necessity, and removing it + // for edge cases is the safer failure mode compared to injecting + // it into unrelated conversations. + val hints = QuickIntentRouter.extractCalendarHints(rawQuery) + val hasDateHint = hints["date"]?.isNotBlank() == true + val hasTimeHint = hints["time"]?.isNotBlank() == true + if (hasDateHint || hasTimeHint) { + systemContext = "[System: User wants to create a calendar event. " + + "Their request: \"$rawQuery\". " + + "Extract the event title, date, and time, then call " + + "runIntent(intentName=\"create_calendar_event\", parameters={...}). " + + "Pass the date in the 'date' field using a relative term (e.g. 'tomorrow', " + + "'next friday') or a plain date ('9 June'), and the clock time separately " + + "in the 'time' field as HH:MM. If you have YYYY-MM-DDTHH:MM, send " + + "date=YYYY-MM-DD and time=HH:MM.]" + } // fall through to E4B — do NOT execute now } else { // Router intent names (e.g. "toggle_flashlight_on") are sub-intent values @@ -1935,6 +1956,7 @@ class ChatViewModel @Inject constructor( kernelAIToolSet.resetTurnState() hallucinationRetryAttempted = false var rawToolCallRetryAttempted = false + var systemOnlyToolRetryAttempted = false var blankResponseRetryAttempted = false var preservedThinkingText: String? = null var currentPrompt = prompt @@ -2051,15 +2073,48 @@ class ChatViewModel @Inject constructor( } else null if (nativeToolCall != null || toolCallResult != null) { - val toolCall = nativeToolCall ?: toolCallResult!!.first + val rawToolCall = nativeToolCall ?: toolCallResult!!.first val nativeToolWasDirectReply = nativeToolCall != null && kernelAIToolSet.lastToolWasDirectReply() + val isSystemOnlyTool = rawToolCall.isSuccess && isSystemOnlyToolCall(rawToolCall.skillName) + val leakedSystemToolContent = isSystemOnlyTool && looksLikeRawToolCall(fullContent) + if (leakedSystemToolContent) { + forceHistoryReplayAfterTurn = true + val budgetOk = estimatedTokensUsed <= (activeContextWindowSize * 0.75).toInt() + if (!systemOnlyToolRetryAttempted && budgetOk) { + systemOnlyToolRetryAttempted = true + Log.w("KernelAI", "system_only_tool_retry_attempted") + needsHallucinationRetry = true + currentPrompt = SYSTEM_ONLY_TOOL_RETRY_CORRECTION + "\n\n" + prompt + accumulatedContent = StringBuilder() + accumulatedThinking = StringBuilder() + activeStreamingContent = accumulatedContent + activeStreamingThinking = accumulatedThinking + _messages.update { msgs -> + msgs.map { + if (it.id == assistantMsgId) it.copy(content = "", isStreaming = true) else it + } + } + return@collect + } + Log.w("KernelAI", "system_only_tool_leak_suppressed") + } + val toolCall = rawToolCall.toVisibleToolCallInfo() + if (systemOnlyToolRetryAttempted && !leakedSystemToolContent) { + Log.d("KernelAI", "system_only_tool_retry_succeeded") + } val resultContent = when { + leakedSystemToolContent -> + fallbackSystemOnlyToolReply(text) nativeToolWasDirectReply -> - toolCall.resultText - nativeToolCall != null && toolCall.presentation != null && toolCall.isSuccess -> - toolCall.resultText + rawToolCall.resultText + nativeToolCall != null && + !isSystemOnlyTool && + rawToolCall.presentation != null && + rawToolCall.isSuccess -> + rawToolCall.resultText nativeToolCall != null -> fullContent + isSystemOnlyTool -> fallbackSystemOnlyToolReply(text) else -> toolCallResult!!.second } @@ -2245,7 +2300,7 @@ class ChatViewModel @Inject constructor( activeStreamingContent = StringBuilder() activeStreamingThinking = StringBuilder() } finally { - if (restoreFullPromptAfterTurn) { + if (restoreFullPromptAfterTurn || forceHistoryReplayAfterTurn) { needsHistoryReplay = true } } @@ -2788,6 +2843,11 @@ private const val RAW_TOOL_CALL_RETRY_CORRECTION = "function text. Silently call the appropriate native tool function and then answer with " + "the final user-facing result only.]" +private const val SYSTEM_ONLY_TOOL_RETRY_CORRECTION = + "[System: You already loaded internal tool instructions. Do NOT quote, summarise, or paste " + + "those instructions into chat. Use them silently and answer the user's original request " + + "in natural language only.]" + /** * Returns true if a tool call result should be indexed in episodic RAG memory. * @@ -2806,6 +2866,43 @@ private fun shouldIndexToolCallResult(skillName: String): Boolean = when (skillN else -> true // run_js (wikipedia etc.) — knowledge worth recalling } + +private fun isSystemOnlyToolCall(skillName: String): Boolean = skillName == "load_skill" + +private fun ToolCallInfo.toVisibleToolCallInfo(): ToolCallInfo { + if (!isSystemOnlyToolCall(skillName)) return this + val loadedSkillName = runCatching { + org.json.JSONObject(requestJson).optString("skill_name").takeIf { it.isNotBlank() } + }.getOrNull() + val displayName = loadedSkillName?.replace('_', ' ') + val summary = if (displayName != null) { + "Loaded internal instructions for $displayName." + } else { + "Loaded internal instructions." + } + val title = if (displayName != null) { + "Loaded $displayName instructions" + } else { + "Loaded skill instructions" + } + return copy( + resultText = summary, + presentation = ToolPresentation.Status( + icon = "🧠", + title = title, + subtitle = "Internal step used to answer this question.", + ), + spokenSummary = null, + ) +} + +private fun fallbackSystemOnlyToolReply(userText: String): String = + if (userText.trim().endsWith("?")) { + "Yes — I can help with that. Tell me what you'd like to do." + } else { + "I can help with that. Tell me what you'd like to do." + } + private fun formatBytes(bytes: Long): String = when { bytes >= 1_073_741_824L -> "%.1f GB".format(bytes / 1_073_741_824.0) bytes >= 1_048_576L -> "%.0f MB".format(bytes / 1_048_576.0) diff --git a/feature/chat/src/test/java/com/kernel/ai/feature/chat/ChatViewModelInitTest.kt b/feature/chat/src/test/java/com/kernel/ai/feature/chat/ChatViewModelInitTest.kt index abde0a80d..3bca14d41 100644 --- a/feature/chat/src/test/java/com/kernel/ai/feature/chat/ChatViewModelInitTest.kt +++ b/feature/chat/src/test/java/com/kernel/ai/feature/chat/ChatViewModelInitTest.kt @@ -949,4 +949,94 @@ class ChatViewModelInitTest { method.isAccessible = true method.invoke(viewModel) } + + @Test + fun `load skill leak retries with clean reply and sanitised tool metadata`() = runTest(dispatcher) { + val prompts = mutableListOf() + val leakedInstructions = """ + run_intent: + Available intents: + - create_calendar_event + Parameters (pass as JSON): title, start_date, start_time + """.trimIndent() + val cleanReply = "Yes — I can help create calendar events. Tell me the title, date, and time." + every { inferenceEngine.isReady } returns MutableStateFlow(true) + every { inferenceEngine.generate(capture(prompts)) } returnsMany listOf( + flowOf(GenerationResult.Token(leakedInstructions), GenerationResult.Complete(durationMs = 1L)), + flowOf(GenerationResult.Token(cleanReply), GenerationResult.Complete(durationMs = 1L)), + ) + every { quickIntentRouter.route(any()) } returns QuickIntentRouter.RouteResult.FallThrough( + input = "Do you know how to create calendar events", + ) + every { kernelAIToolSet.wasToolCalled() } returns true + every { kernelAIToolSet.lastToolName() } returns "load_skill" + every { kernelAIToolSet.lastToolRequest() } returns """{"skill_name":"run_intent"}""" + every { kernelAIToolSet.lastToolResult() } returns leakedInstructions + every { kernelAIToolSet.lastToolPresentation() } returns null + every { kernelAIToolSet.lastToolSpokenSummary() } returns null + every { kernelAIToolSet.lastToolWasDirectReply() } returns false + coEvery { conversationRepository.addMessage(any(), any(), any(), any(), any()) } returnsMany + listOf("user-msg-id", "assistant-msg-id") + + val viewModel = createViewModel() + advanceUntilIdle() + + viewModel.onInputChanged("Do you know how to create calendar events") + viewModel.sendMessage() + advanceUntilIdle() + + assertEquals(2, prompts.size) + assertTrue(prompts[1].contains("You already loaded internal tool instructions")) + coVerify(atLeast = 1) { + conversationRepository.addMessage( + any(), + eq("assistant"), + eq(cleanReply), + any(), + match { + it.contains("Loaded internal instructions for run intent.") && + it.contains("Loaded run intent instructions") && + !it.contains("Available intents:") + }, + ) + } + } + + private fun createViewModel( + savedStateHandle: SavedStateHandle = SavedStateHandle(), + ): ChatViewModel = ChatViewModel( + savedStateHandle = savedStateHandle, + chatPreferences = chatPreferences, + authRepository = authRepository, + inferenceEngine = inferenceEngine, + downloadManager = downloadManager, + conversationRepository = conversationRepository, + ragRepository = ragRepository, + userProfileRepository = userProfileRepository, + memoryRepository = memoryRepository, + episodicDistillationUseCase = episodicDistillationUseCase, + modelSettingsRepository = modelSettingsRepository, + skillRegistry = skillRegistry, + skillExecutor = skillExecutor, + quickIntentRouter = quickIntentRouter, + slotFillerManager = slotFillerManager, + kernelAIToolSet = kernelAIToolSet, + toolProvider = toolProvider, + embeddingEngine = embeddingEngine, + voiceInputController = voiceInputController, + voiceOutputController = voiceOutputController, + voiceOutputPreferences = voiceOutputPreferences, + jandalPersona = jandalPersona, + nzTruthSeedingService = nzTruthSeedingService, + verboseLoggingPreferenceUseCase = verboseLoggingPreferenceUseCase, + startListeningCuePlayer = startListeningCuePlayer, + mealPlanSessionRepository = mealPlanSessionRepository, + mealPlannerCoordinator = mealPlannerCoordinator, + ) + + private fun invokeOnCleared(viewModel: ChatViewModel) { + val method = ChatViewModel::class.java.getDeclaredMethod("onCleared") + method.isAccessible = true + method.invoke(viewModel) + } }