From d85cedacf9810b896cd72d2dd7acc2f539c9798b Mon Sep 17 00:00:00 2001 From: John Trujillo Date: Mon, 28 Sep 2026 15:36:23 -0500 Subject: [PATCH 1/4] feat(ai-core): add read_app_logs and read_ide_logs agent tools (ADFA-6268) Read-only tools over IdeLogService, capped at 8000 chars and anchored on the first crash or error. run_app points the agent at read_app_logs; min_ide_version is now 26.40. --- plugins/AI-Core/build.gradle.kts | 4 +- plugins/AI-Core/src/main/AndroidManifest.xml | 2 +- .../AI-Core/src/main/assets/docs/index.html | 3 + .../plugins/aicore/tool/Executor.kt | 2 + .../tool/handlers/BuiltInToolHandlers.kt | 3 + .../tool/handlers/LogWindowCalculator.kt | 97 ++++++++ .../aicore/tool/handlers/ReadLogsHandler.kt | 149 ++++++++++++ .../aicore/tool/handlers/RunAppHandler.kt | 2 +- .../handlers/BuiltInHandlerApprovalTest.kt | 2 + .../tool/handlers/LogWindowCalculatorTest.kt | 103 ++++++++ .../tool/handlers/ReadLogsHandlerTest.kt | 229 ++++++++++++++++++ .../aicore/tool/handlers/RunAppHandlerTest.kt | 9 +- 12 files changed, 599 insertions(+), 6 deletions(-) create mode 100644 plugins/AI-Core/src/main/kotlin/com/itsaky/androidide/plugins/aicore/tool/handlers/LogWindowCalculator.kt create mode 100644 plugins/AI-Core/src/main/kotlin/com/itsaky/androidide/plugins/aicore/tool/handlers/ReadLogsHandler.kt create mode 100644 plugins/AI-Core/src/test/kotlin/com/itsaky/androidide/plugins/aicore/tool/handlers/LogWindowCalculatorTest.kt create mode 100644 plugins/AI-Core/src/test/kotlin/com/itsaky/androidide/plugins/aicore/tool/handlers/ReadLogsHandlerTest.kt diff --git a/plugins/AI-Core/build.gradle.kts b/plugins/AI-Core/build.gradle.kts index 1c27c327..642aebbe 100644 --- a/plugins/AI-Core/build.gradle.kts +++ b/plugins/AI-Core/build.gradle.kts @@ -15,8 +15,8 @@ android { applicationId = "com.itsaky.androidide.plugins.aicore" minSdk = 33 targetSdk = 36 - versionCode = 5 - versionName = "3.1.0" + versionCode = 6 + versionName = "3.2.0" } buildFeatures { diff --git a/plugins/AI-Core/src/main/AndroidManifest.xml b/plugins/AI-Core/src/main/AndroidManifest.xml index 34e9e190..20142820 100644 --- a/plugins/AI-Core/src/main/AndroidManifest.xml +++ b/plugins/AI-Core/src/main/AndroidManifest.xml @@ -38,7 +38,7 @@ + android:value="26.40" /> What the agent can do so Ctrl+Z undoes it and your unsaved work is preserved.
  • Trigger a Gradle sync (asks for approval — a sync starts a real build) and read build output.
  • +
  • Read App Logs and IDE Logs, so it can find the exception + behind a crash without you copying log lines into the chat. It never + asks first: reading a log changes nothing.
  • Add dependencies and generate code from templates, both after asking.
  • diff --git a/plugins/AI-Core/src/main/kotlin/com/itsaky/androidide/plugins/aicore/tool/Executor.kt b/plugins/AI-Core/src/main/kotlin/com/itsaky/androidide/plugins/aicore/tool/Executor.kt index 3d777444..a7ab34cc 100644 --- a/plugins/AI-Core/src/main/kotlin/com/itsaky/androidide/plugins/aicore/tool/Executor.kt +++ b/plugins/AI-Core/src/main/kotlin/com/itsaky/androidide/plugins/aicore/tool/Executor.kt @@ -27,6 +27,8 @@ class Executor( "read_file", "list_files", "search_project", + "read_app_logs", + "read_ide_logs", ) /** diff --git a/plugins/AI-Core/src/main/kotlin/com/itsaky/androidide/plugins/aicore/tool/handlers/BuiltInToolHandlers.kt b/plugins/AI-Core/src/main/kotlin/com/itsaky/androidide/plugins/aicore/tool/handlers/BuiltInToolHandlers.kt index 31d2270e..8b37d069 100644 --- a/plugins/AI-Core/src/main/kotlin/com/itsaky/androidide/plugins/aicore/tool/handlers/BuiltInToolHandlers.kt +++ b/plugins/AI-Core/src/main/kotlin/com/itsaky/androidide/plugins/aicore/tool/handlers/BuiltInToolHandlers.kt @@ -2,6 +2,7 @@ package com.itsaky.androidide.plugins.aicore.tool.handlers import com.itsaky.androidide.plugins.PluginContext import com.itsaky.androidide.plugins.aicore.tool.ToolHandler +import com.itsaky.androidide.plugins.services.LogSource /** * The agent's own tool catalogue, in one place rather than inline in the chat: what the agent can @@ -22,6 +23,8 @@ object BuiltInToolHandlers { SearchProjectHandler(context), OpenFileHandler(context), ReadBuildOutputHandler(context), + ReadLogsHandler(context, LogSource.APP), + ReadLogsHandler(context, LogSource.IDE), // Write tools CreateFileHandler(context), UpdateFileHandler(context), diff --git a/plugins/AI-Core/src/main/kotlin/com/itsaky/androidide/plugins/aicore/tool/handlers/LogWindowCalculator.kt b/plugins/AI-Core/src/main/kotlin/com/itsaky/androidide/plugins/aicore/tool/handlers/LogWindowCalculator.kt new file mode 100644 index 00000000..9e8eeda3 --- /dev/null +++ b/plugins/AI-Core/src/main/kotlin/com/itsaky/androidide/plugins/aicore/tool/handlers/LogWindowCalculator.kt @@ -0,0 +1,97 @@ +package com.itsaky.androidide.plugins.aicore.tool.handlers + +import com.itsaky.androidide.plugins.services.LogEntry +import com.itsaky.androidide.plugins.services.LogLevel + +/** + * Selects the slice of a log the model needs, in whole lines and within [MAX_OUTPUT_CHARS] + * including the truncation markers. Only the kept lines are ever joined into a string. + */ +internal object LogWindowCalculator { + /** Maximum characters of log handed to the model, the same budget as build output. */ + const val MAX_OUTPUT_CHARS = 8000 + + private const val TRUNCATION_MARKER = "...[truncated]..." + + /** Markers of an app crash; the anchor prefers these over any earlier plain error. */ + private val CRASH_MARKERS = listOf("FATAL EXCEPTION", "Fatal signal") + + /** + * A crash's exception and top frames sit where it starts, not in the tail of later chatter, + * so the window starts at the first crash line, else the first error line, and keeps the + * head of what follows. With neither, it keeps the newest lines. + */ + fun windowFor(entries: List, hostTruncated: Boolean): OutputWindow { + val anchor = entries.indexOfFirst { isCrashLine(it) }.takeIf { it >= 0 } + ?: entries.indexOfFirst { it.level == LogLevel.ERROR }.takeIf { it >= 0 } + val text = if (anchor != null) { + head(entries.subList(anchor, entries.size), droppedBefore = hostTruncated || anchor > 0) + } else { + tail(entries, droppedBefore = hostTruncated) + } + return OutputWindow(text = text, anchoredOnError = anchor != null) + } + + /** The oldest lines of [entries] that fit, marking what was dropped on either side. */ + private fun head(entries: List, droppedBefore: Boolean): String { + val prefix = if (droppedBefore) "$TRUNCATION_MARKER\n" else "" + val room = MAX_OUTPUT_CHARS - prefix.length + if (fitCount(entries, room) == entries.size) return prefix + joined(entries) + + val suffix = "\n$TRUNCATION_MARKER" + val kept = fitCount(entries, room - suffix.length) + val body = if (kept > 0) { + joined(entries.subList(0, kept)) + } else { + keepStart(entries.first().text, room - suffix.length) + } + return prefix + body + suffix + } + + /** The newest lines of [entries] that fit, marking what was dropped before them. */ + private fun tail(entries: List, droppedBefore: Boolean): String { + val prefix = "$TRUNCATION_MARKER\n" + val newestFirst = entries.asReversed() + if (!droppedBefore && fitCount(newestFirst, MAX_OUTPUT_CHARS) == entries.size) { + return joined(entries) + } + + val room = MAX_OUTPUT_CHARS - prefix.length + val kept = fitCount(newestFirst, room) + val body = if (kept > 0) { + joined(entries.subList(entries.size - kept, entries.size)) + } else { + keepEnd(entries.last().text, room) + } + return prefix + body + } + + /** How many of [entries], taken in order, fit in [room] characters once joined by newlines. */ + private fun fitCount(entries: List, room: Int): Int { + var used = -1 // the first line needs no separator + var count = 0 + for (entry in entries) { + used += entry.text.length + 1 + if (used > room) break + count++ + } + return count + } + + private fun joined(entries: List): String = entries.joinToString("\n") { it.text } + + /** The start of one oversized line, never splitting a surrogate pair. */ + private fun keepStart(text: String, room: Int): String { + val end = if (text[room - 1].isHighSurrogate()) room - 1 else room + return text.substring(0, end) + } + + /** The end of one oversized line, never splitting a surrogate pair. */ + private fun keepEnd(text: String, room: Int): String { + val start = text.length - room + return text.substring(if (text[start].isLowSurrogate()) start + 1 else start) + } + + private fun isCrashLine(entry: LogEntry): Boolean = + CRASH_MARKERS.any { entry.text.contains(it, ignoreCase = true) } +} diff --git a/plugins/AI-Core/src/main/kotlin/com/itsaky/androidide/plugins/aicore/tool/handlers/ReadLogsHandler.kt b/plugins/AI-Core/src/main/kotlin/com/itsaky/androidide/plugins/aicore/tool/handlers/ReadLogsHandler.kt new file mode 100644 index 00000000..42bd9750 --- /dev/null +++ b/plugins/AI-Core/src/main/kotlin/com/itsaky/androidide/plugins/aicore/tool/handlers/ReadLogsHandler.kt @@ -0,0 +1,149 @@ +package com.itsaky.androidide.plugins.aicore.tool.handlers + +import com.itsaky.androidide.plugins.PluginContext +import com.itsaky.androidide.plugins.aicore.logging.AgentTrace +import com.itsaky.androidide.plugins.aicore.models.ToolResult +import com.itsaky.androidide.plugins.aicore.tool.ToolHandler +import com.itsaky.androidide.plugins.aicore.tool.ToolSchema +import com.itsaky.androidide.plugins.services.IdeLogService +import com.itsaky.androidide.plugins.services.LogLevel +import com.itsaky.androidide.plugins.services.LogQuery +import com.itsaky.androidide.plugins.services.LogSource +import kotlinx.coroutines.CancellationException + +/** + * Handler for reading one of the IDE's log tabs: App Logs (`read_app_logs`, the user's running + * app) or IDE Logs (`read_ide_logs`, CoGo and its plugins). Read-only, so it never asks. + */ +class ReadLogsHandler( + private val pluginContext: PluginContext, + private val source: LogSource, +) : ToolHandler { + private val tab = tabFor(source) + + override val toolName = tab.toolName + override val description = tab.description + override val requiresApproval = false + override val parametersSchema = ToolSchema.objectOf( + "min_level" to ToolSchema.string( + "Lowest level to include: verbose, debug, info, warning or error. Defaults to all levels." + ), + "filter" to ToolSchema.string( + "Case-insensitive text a line must contain, matched against its tag and message." + ), + ) + + private val label = tab.label + + override suspend fun execute(args: Map): ToolResult { + val minLevel = args["min_level"]?.toString()?.trim().orEmpty() + val levels = if (minLevel.isEmpty()) { + emptySet() + } else { + levelsFrom(minLevel) ?: return ToolResult.failure( + "Unknown min_level '$minLevel'", + "Use one of: verbose, debug, info, warning, error." + ) + } + val filter = args["filter"]?.toString()?.trim().orEmpty() + + return try { + val logService = pluginContext.services.get(IdeLogService::class.java) + if (logService == null) { + // A clear answer rather than a failure: the agent can say so and carry on. + AgentTrace.refusal("LOGS", "$toolName rejected", "IdeLogService not available") + return ToolResult.success( + message = "$label not available", + data = "(This version of Code on the Go does not let plugins read $label.)" + ) + } + + val result = logService.readLogs( + source, + LogQuery(levels = levels, text = filter, maxLines = LogQuery.MAX_LINES), + ) + if (result.entries.isEmpty()) { + AgentTrace.detail("LOGS", "$toolName lines=0 levels=$levels filter=${filter.isNotEmpty()}") + ToolResult.success(message = "No $label", data = emptyMessage(filter, levels)) + } else { + val window = LogWindowCalculator.windowFor(result.entries, result.truncated) + AgentTrace.detail( + "LOGS", + "$toolName lines=${result.entries.size} chars=${window.text.length} " + + "anchoredOnError=${window.anchoredOnError} hostTruncated=${result.truncated}" + ) + ToolResult.success( + message = if (window.anchoredOnError) { + "$label from the first error (${window.text.length} characters)" + } else { + "$label (last ${window.text.length} characters)" + }, + data = window.text + ) + } + } catch (ce: CancellationException) { + // An Exception on the JVM, so the catch below would report Stop as a read failure. + throw ce + } catch (e: Exception) { + AgentTrace.refusal("LOGS", "$toolName failed", e.toString()) + pluginContext.logger.error("$toolName failed", e) + ToolResult.failure( + "Error reading $label", + "${e.message ?: "Unknown error"}\n\n${e.stackTraceToString()}" + ) + } + } + + private fun emptyMessage(filter: String, levels: Set): String = + if (filter.isEmpty() && levels.isEmpty()) { + tab.emptyLog + } else { + "(No $label lines match that level or filter. Try again without them.)" + } + + /** How the tool for one log tab is named and described, to the model and in messages. */ + private class LogTab( + val toolName: String, + val description: String, + val label: String, + val emptyLog: String, + ) + + companion object { + private val ORDERED_LEVELS = listOf( + LogLevel.VERBOSE, LogLevel.DEBUG, LogLevel.INFO, LogLevel.WARNING, LogLevel.ERROR, + ) + + /** + * The levels at or above [name], which the host takes as an exact set. + * @param name a level name, case-insensitive; `warn` and `e`-style initials are accepted. + * @return the levels, or null when [name] is not a level. + */ + internal fun levelsFrom(name: String): Set? { + val level = when (name.lowercase()) { + "verbose", "v" -> LogLevel.VERBOSE + "debug", "d" -> LogLevel.DEBUG + "info", "i" -> LogLevel.INFO + "warning", "warn", "w" -> LogLevel.WARNING + "error", "e" -> LogLevel.ERROR + else -> return null + } + return ORDERED_LEVELS.subList(ORDERED_LEVELS.indexOf(level), ORDERED_LEVELS.size).toSet() + } + + private fun tabFor(source: LogSource): LogTab = when (source) { + LogSource.APP -> LogTab( + toolName = "read_app_logs", + description = "Read the App Logs of the user's running app, from the first crash or error", + label = "App Logs", + emptyLog = "(App Logs are empty. Run the app with run_app first, then read them again.)", + ) + LogSource.IDE -> LogTab( + toolName = "read_ide_logs", + description = "Read the IDE Logs of Code on the Go and its plugins, from the first error", + label = "IDE Logs", + emptyLog = "(IDE Logs are empty.)", + ) + } + } +} diff --git a/plugins/AI-Core/src/main/kotlin/com/itsaky/androidide/plugins/aicore/tool/handlers/RunAppHandler.kt b/plugins/AI-Core/src/main/kotlin/com/itsaky/androidide/plugins/aicore/tool/handlers/RunAppHandler.kt index bb10dc8d..8e6dd7f5 100644 --- a/plugins/AI-Core/src/main/kotlin/com/itsaky/androidide/plugins/aicore/tool/handlers/RunAppHandler.kt +++ b/plugins/AI-Core/src/main/kotlin/com/itsaky/androidide/plugins/aicore/tool/handlers/RunAppHandler.kt @@ -96,7 +96,7 @@ class RunAppHandler( if (success) { ToolResult.success( message = "Build succeeded", - data = message + data = "$message\n\nIf the app crashes or misbehaves, call read_app_logs." ) } else { ToolResult.failure( diff --git a/plugins/AI-Core/src/test/kotlin/com/itsaky/androidide/plugins/aicore/tool/handlers/BuiltInHandlerApprovalTest.kt b/plugins/AI-Core/src/test/kotlin/com/itsaky/androidide/plugins/aicore/tool/handlers/BuiltInHandlerApprovalTest.kt index a3f327b7..63902f4d 100644 --- a/plugins/AI-Core/src/test/kotlin/com/itsaky/androidide/plugins/aicore/tool/handlers/BuiltInHandlerApprovalTest.kt +++ b/plugins/AI-Core/src/test/kotlin/com/itsaky/androidide/plugins/aicore/tool/handlers/BuiltInHandlerApprovalTest.kt @@ -39,6 +39,8 @@ class BuiltInHandlerApprovalTest { "search_project", "open_file", "read_build_output", + "read_app_logs", + "read_ide_logs", ) } diff --git a/plugins/AI-Core/src/test/kotlin/com/itsaky/androidide/plugins/aicore/tool/handlers/LogWindowCalculatorTest.kt b/plugins/AI-Core/src/test/kotlin/com/itsaky/androidide/plugins/aicore/tool/handlers/LogWindowCalculatorTest.kt new file mode 100644 index 00000000..dfd38306 --- /dev/null +++ b/plugins/AI-Core/src/test/kotlin/com/itsaky/androidide/plugins/aicore/tool/handlers/LogWindowCalculatorTest.kt @@ -0,0 +1,103 @@ +package com.itsaky.androidide.plugins.aicore.tool.handlers + +import com.itsaky.androidide.plugins.aicore.tool.handlers.LogWindowCalculator.MAX_OUTPUT_CHARS +import com.itsaky.androidide.plugins.services.LogEntry +import com.itsaky.androidide.plugins.services.LogLevel +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +/** + * Unit tests for [LogWindowCalculator]: the budget holds including markers, cuts fall between + * lines, and a crash is found whatever its case. + */ +class LogWindowCalculatorTest { + + private val marker = "...[truncated]..." + + private fun info(lines: Int, label: String = "step"): List = + (1..lines).map { LogEntry(LogLevel.INFO, "I MyApp: $label$it") } + + private val crash = LogEntry(LogLevel.ERROR, "E AndroidRuntime: FATAL EXCEPTION: main") + + /** Every line between the markers is one of [entries], whole. */ + private fun assertWholeLines(text: String, entries: List) { + val texts = entries.map { it.text }.toSet() + text.split("\n").filter { it != marker }.forEach { + assertTrue("cut line: '$it'", it in texts) + } + } + + @Test + fun givenAHostTruncatedCrashFollowedByALongLog_whenWindowed_thenBothMarkersFitTheBudget() { + val entries = info(10) + crash + info(2000, label = "after") + + val text = LogWindowCalculator.windowFor(entries, hostTruncated = true).text + + assertTrue("budget exceeded: ${text.length}", text.length <= MAX_OUTPUT_CHARS) + assertTrue(text.startsWith("$marker\n${crash.text}")) + assertTrue(text.endsWith("\n$marker")) + assertWholeLines(text, entries) + } + + @Test + fun givenALongLogWithNoError_whenWindowed_thenTheNewestWholeLinesFitTheBudget() { + val entries = info(2000) + + val text = LogWindowCalculator.windowFor(entries, hostTruncated = false).text + + assertTrue("budget exceeded: ${text.length}", text.length <= MAX_OUTPUT_CHARS) + assertTrue(text.startsWith("$marker\n")) + assertTrue(text.endsWith("I MyApp: step2000")) + assertWholeLines(text, entries) + } + + @Test + fun givenALogOfExactlyTheBudget_whenWindowed_thenItIsReturnedWhole() { + val entries = List(79) { LogEntry(LogLevel.INFO, "x".repeat(99)) } + + LogEntry(LogLevel.INFO, "x".repeat(100)) + val whole = entries.joinToString("\n") { it.text } + assertEquals(MAX_OUTPUT_CHARS, whole.length) + + val text = LogWindowCalculator.windowFor(entries, hostTruncated = false).text + + assertEquals(whole, text) + } + + @Test + fun givenOneOversizedCrashLine_whenWindowed_thenItsStartIsKeptWithoutSplittingAnEmoji() { + // The emoji's surrogate pair straddles the cut point. + val room = MAX_OUTPUT_CHARS - "\n$marker".length + val line = "E AndroidRuntime: FATAL EXCEPTION: " + "a".repeat(room - 36) + "😀" + "b".repeat(100) + + val text = LogWindowCalculator.windowFor(listOf(LogEntry(LogLevel.ERROR, line)), false).text + + assertTrue(text.length <= MAX_OUTPUT_CHARS) + assertTrue(text.startsWith("E AndroidRuntime: FATAL EXCEPTION")) + assertFalse("split surrogate", text.removeSuffix("\n$marker").last().isHighSurrogate()) + } + + @Test + fun givenOneOversizedPlainLine_whenWindowed_thenItsEndIsKeptWithoutSplittingAnEmoji() { + val room = MAX_OUTPUT_CHARS - "$marker\n".length + val line = "a".repeat(100) + "😀" + "b".repeat(room - 1) + + val text = LogWindowCalculator.windowFor(listOf(LogEntry(LogLevel.INFO, line)), false).text + + assertTrue(text.length <= MAX_OUTPUT_CHARS) + assertTrue(text.endsWith("b")) + assertFalse("split surrogate", text.removePrefix("$marker\n").first().isLowSurrogate()) + } + + @Test + fun givenALowercaseCrashHeader_whenWindowed_thenItStillAnchors() { + val earlier = LogEntry(LogLevel.ERROR, "E Glide: load failed") + val lowercase = LogEntry(null, "fatal signal 11 (SIGSEGV), code 1") + + val window = LogWindowCalculator.windowFor(listOf(earlier) + info(3) + lowercase, false) + + assertTrue(window.anchoredOnError) + assertTrue(window.text.startsWith("$marker\n${lowercase.text}")) + } +} diff --git a/plugins/AI-Core/src/test/kotlin/com/itsaky/androidide/plugins/aicore/tool/handlers/ReadLogsHandlerTest.kt b/plugins/AI-Core/src/test/kotlin/com/itsaky/androidide/plugins/aicore/tool/handlers/ReadLogsHandlerTest.kt new file mode 100644 index 00000000..f430f5fe --- /dev/null +++ b/plugins/AI-Core/src/test/kotlin/com/itsaky/androidide/plugins/aicore/tool/handlers/ReadLogsHandlerTest.kt @@ -0,0 +1,229 @@ +package com.itsaky.androidide.plugins.aicore.tool.handlers + +import com.itsaky.androidide.plugins.PluginContext +import com.itsaky.androidide.plugins.ServiceRegistry +import com.itsaky.androidide.plugins.services.IdeLogService +import com.itsaky.androidide.plugins.services.LogEntry +import com.itsaky.androidide.plugins.services.LogLevel +import com.itsaky.androidide.plugins.services.LogQuery +import com.itsaky.androidide.plugins.services.LogReadResult +import com.itsaky.androidide.plugins.services.LogSource +import io.mockk.every +import io.mockk.mockk +import io.mockk.slot +import kotlinx.coroutines.test.runTest +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test + +/** + * Unit tests for [ReadLogsHandler] — the `read_app_logs` and `read_ide_logs` tools. Covers the + * 8000-character budget, the crash-anchored window, and the empty and missing-service answers. + */ +class ReadLogsHandlerTest { + + private lateinit var context: PluginContext + private lateinit var services: ServiceRegistry + private lateinit var logService: IdeLogService + private lateinit var handler: ReadLogsHandler + + @Before + fun setup() { + logService = mockk() + services = mockk() + context = mockk() + every { context.services } returns services + // The handler logs failures through the host logger. + every { context.logger } returns mockk(relaxed = true) + every { services.get(IdeLogService::class.java) } returns logService + handler = ReadLogsHandler(context, LogSource.APP) + } + + private fun answerWith(entries: List, truncated: Boolean = false) { + every { logService.readLogs(any(), any()) } returns LogReadResult(entries, truncated) + } + + private fun info(lines: Int, label: String = "step"): List = + (1..lines).map { LogEntry(LogLevel.INFO, "I MyApp: $label$it") } + + private val crash = listOf( + LogEntry(LogLevel.ERROR, "E AndroidRuntime: FATAL EXCEPTION: main"), + LogEntry(LogLevel.ERROR, "E AndroidRuntime: Process: com.example.app, PID: 4242"), + LogEntry(LogLevel.ERROR, "E AndroidRuntime: java.lang.NullPointerException: boom"), + LogEntry(LogLevel.ERROR, "E AndroidRuntime: \tat com.example.app.MainActivity.onCreate(MainActivity.kt:12)"), + ) + + @Test + fun givenEachSource_whenNamed_thenTheToolNamesMatchTheTicket() { + assertEquals("read_app_logs", ReadLogsHandler(context, LogSource.APP).toolName) + assertEquals("read_ide_logs", ReadLogsHandler(context, LogSource.IDE).toolName) + } + + @Test + fun givenEitherSource_whenAskedForApproval_thenItNeedsNone() { + assertFalse(ReadLogsHandler(context, LogSource.APP).requiresApproval) + assertFalse(ReadLogsHandler(context, LogSource.IDE).requiresApproval) + } + + @Test + fun givenNoLogService_whenReading_thenItAnswersClearlyInsteadOfFailing() = runTest { + every { services.get(IdeLogService::class.java) } returns null + + val result = handler.execute(emptyMap()) + + assertTrue(result.success) + assertTrue(result.message.contains("not available")) + } + + @Test + fun givenAnEmptyLog_whenReading_thenItSaysSoAndPointsAtRunApp() = runTest { + every { logService.readLogs(any(), any()) } returns LogReadResult.EMPTY + + val result = handler.execute(emptyMap()) + + assertTrue(result.success) + assertTrue(result.message.contains("No App Logs")) + assertTrue(result.data.orEmpty().contains("run_app")) + } + + @Test + fun givenAFilterThatMatchesNothing_whenReading_thenItSuggestsDroppingTheFilter() = runTest { + every { logService.readLogs(any(), any()) } returns LogReadResult.EMPTY + + val result = handler.execute(mapOf("filter" to "nothing-matches")) + + assertTrue(result.success) + assertTrue(result.data.orEmpty().contains("without them")) + } + + @Test + fun givenTheIdeSource_whenReading_thenItAsksTheHostForIdeLogs() = runTest { + val sourceSlot = slot() + every { logService.readLogs(capture(sourceSlot), any()) } returns LogReadResult.EMPTY + + ReadLogsHandler(context, LogSource.IDE).execute(emptyMap()) + + assertEquals(LogSource.IDE, sourceSlot.captured) + } + + @Test + fun givenAMinLevelAndFilter_whenReading_thenTheQueryCarriesThem() = runTest { + val querySlot = slot() + every { logService.readLogs(any(), capture(querySlot)) } returns LogReadResult.EMPTY + + handler.execute(mapOf("min_level" to "Warning", "filter" to " MainActivity ")) + + assertEquals(setOf(LogLevel.WARNING, LogLevel.ERROR), querySlot.captured.levels) + assertEquals("MainActivity", querySlot.captured.text) + assertEquals(LogQuery.MAX_LINES, querySlot.captured.maxLines) + } + + @Test + fun givenAnUnknownMinLevel_whenReading_thenItFailsWithoutCallingTheHost() = runTest { + val result = handler.execute(mapOf("min_level" to "loud")) + + assertFalse(result.success) + assertTrue(result.error_details.orEmpty().contains("warning")) + } + + @Test + fun givenLevelNames_whenParsed_thenEachKeepsItselfAndEverythingAbove() { + assertEquals(LogLevel.values().toSet(), ReadLogsHandler.levelsFrom("verbose")) + assertEquals(setOf(LogLevel.ERROR), ReadLogsHandler.levelsFrom("E")) + assertNull(ReadLogsHandler.levelsFrom("fatal")) + } + + @Test + fun givenAShortCleanLog_whenReading_thenItIsReturnedUnchanged() = runTest { + answerWith(info(3)) + + val result = handler.execute(emptyMap()) + + assertTrue(result.success) + assertEquals("I MyApp: step1\nI MyApp: step2\nI MyApp: step3", result.data) + assertTrue(result.message.contains("last")) + } + + @Test + fun givenACrashAfterChatter_whenReading_thenTheWindowStartsAtTheCrash() = runTest { + answerWith(info(50) + crash + info(5, label = "after")) + + val result = handler.execute(emptyMap()) + + val data = result.data.orEmpty() + assertTrue(data.startsWith("...[truncated]...\nE AndroidRuntime: FATAL EXCEPTION: main")) + assertTrue(data.contains("NullPointerException")) + assertTrue(data.contains("MainActivity.kt:12")) + assertFalse("the head must be dropped", data.contains("I MyApp: step1\n")) + assertTrue(result.message.contains("first error")) + } + + @Test + fun givenAnEarlierPlainErrorAndACrash_whenReading_thenTheCrashWins() = runTest { + val earlier = LogEntry(LogLevel.ERROR, "E Glide: load failed") + answerWith(listOf(earlier) + info(5) + crash) + + val data = handler.execute(emptyMap()).data.orEmpty() + + assertFalse("a plain error must not anchor ahead of a crash", data.contains("Glide")) + assertTrue(data.contains("FATAL EXCEPTION")) + } + + @Test + fun givenAnErrorButNoCrash_whenReading_thenTheWindowStartsAtTheError() = runTest { + val error = LogEntry(LogLevel.ERROR, "E PluginManager: failed to load plugin") + answerWith(info(20) + error + info(3, label = "after")) + + val data = handler.execute(emptyMap()).data.orEmpty() + + assertTrue(data.startsWith("...[truncated]...\n" + error.text)) + } + + @Test + fun givenALongLogWithNoError_whenReading_thenItIsCappedToTheNewestLines() = runTest { + answerWith(info(2000)) + + val data = handler.execute(emptyMap()).data.orEmpty() + + assertTrue(data.startsWith("...[truncated]...")) + assertTrue(data.endsWith("I MyApp: step2000")) + assertTrue( + "budget exceeded: ${data.length}", + data.length <= LogWindowCalculator.MAX_OUTPUT_CHARS, + ) + } + + @Test + fun givenACrashFollowedByALongLog_whenReading_thenTheCrashHeadSurvivesTheCap() = runTest { + answerWith(crash + info(2000, label = "after")) + + val data = handler.execute(emptyMap()).data.orEmpty() + + assertTrue(data.startsWith("E AndroidRuntime: FATAL EXCEPTION: main")) + assertTrue(data.contains("MainActivity.kt:12")) + assertTrue(data.endsWith("...[truncated]...")) + assertFalse(data.contains("I MyApp: after2000")) + } + + @Test + fun givenTheHostDroppedOlderLines_whenReading_thenTheOutputSaysItIsTruncated() = runTest { + answerWith(info(3), truncated = true) + + val data = handler.execute(emptyMap()).data.orEmpty() + + assertTrue(data.startsWith("...[truncated]...\nI MyApp: step1")) + } + + @Test + fun givenTheServiceThrows_whenReading_thenItFailsWithoutPropagating() = runTest { + every { logService.readLogs(any(), any()) } throws IllegalStateException("boom") + + val result = handler.execute(emptyMap()) + + assertFalse(result.success) + assertTrue(result.error_details.orEmpty().contains("boom")) + } +} diff --git a/plugins/AI-Core/src/test/kotlin/com/itsaky/androidide/plugins/aicore/tool/handlers/RunAppHandlerTest.kt b/plugins/AI-Core/src/test/kotlin/com/itsaky/androidide/plugins/aicore/tool/handlers/RunAppHandlerTest.kt index 5195df78..e1237b45 100644 --- a/plugins/AI-Core/src/test/kotlin/com/itsaky/androidide/plugins/aicore/tool/handlers/RunAppHandlerTest.kt +++ b/plugins/AI-Core/src/test/kotlin/com/itsaky/androidide/plugins/aicore/tool/handlers/RunAppHandlerTest.kt @@ -83,7 +83,11 @@ class RunAppHandlerTest { assertTrue(result.success) assertEquals("Build succeeded", result.message) - assertEquals("Build successful", result.data) + assertTrue(result.data.orEmpty().startsWith("Build successful")) + assertTrue( + "the agent must be pointed at the app's log", + result.data.orEmpty().contains("read_app_logs"), + ) } @Test @@ -119,7 +123,8 @@ class RunAppHandlerTest { val result = handler.execute(emptyMap()) assertTrue(result.success) - assertEquals("first", result.data) + assertTrue(result.data.orEmpty().startsWith("first")) + assertFalse(result.data.orEmpty().contains("second")) } @Test From 54b818b57f9ccae11d51844c54b352c8fd6e1608 Mon Sep 17 00:00:00 2001 From: John Trujillo Date: Wed, 30 Sep 2026 11:41:34 -0500 Subject: [PATCH 2/4] fix(ai-core): address review on log tools (ADFA-6268) - Keep min_ide_version 26.39; register log tools only when the host has IdeLogService - Size the log window to AgentLoop's tool-output cap - Anchor on the newest crash / newest error run instead of the oldest - Don't treat a repeated log read as the run completing - Share OutputWindow and the truncation marker between build and log handlers --- plugins/AI-Core/src/main/AndroidManifest.xml | 2 +- .../aicore/tool/ToolCallProgressGuard.kt | 9 ++++++- .../tool/handlers/BuiltInToolHandlers.kt | 15 +++++++++-- .../tool/handlers/LogWindowCalculator.kt | 26 ++++++++++++------- .../aicore/tool/handlers/OutputWindow.kt | 10 +++++++ .../tool/handlers/ReadBuildOutputHandler.kt | 10 +------ .../aicore/tool/handlers/ReadLogsHandler.kt | 6 ++--- .../aicore/tool/ToolCallProgressGuardTest.kt | 9 +++++++ .../tool/handlers/LogWindowCalculatorTest.kt | 22 +++++++++++++++- .../tool/handlers/ReadLogsHandlerTest.kt | 4 +-- 10 files changed, 84 insertions(+), 29 deletions(-) create mode 100644 plugins/AI-Core/src/main/kotlin/com/itsaky/androidide/plugins/aicore/tool/handlers/OutputWindow.kt diff --git a/plugins/AI-Core/src/main/AndroidManifest.xml b/plugins/AI-Core/src/main/AndroidManifest.xml index 20142820..34e9e190 100644 --- a/plugins/AI-Core/src/main/AndroidManifest.xml +++ b/plugins/AI-Core/src/main/AndroidManifest.xml @@ -38,7 +38,7 @@ + android:value="26.39" /> () private var currentBatchChanges = false private var currentBatchIsNew = false + private var currentBatchRereadsLive = false // Null until a batch has run: "no tools yet" and "the tools failed" end a run differently. private var previousBatchSucceeded: Boolean? = null @@ -69,6 +70,7 @@ internal class ToolCallProgressGuard( currentBatchPaths = pathsNamedBy(calls) currentBatchWrites = pathsNamedBy(calls.filter(changesPaths)) currentBatchChanges = calls.any(changesPaths) + currentBatchRereadsLive = calls.any { it.name in LIVE_READS } val verdict = verdictFor(signature) if (verdict == Verdict.PROCEED) { previousSignature = signature @@ -117,7 +119,7 @@ internal class ToolCallProgressGuard( consecutiveRepeats = 0 return Verdict.PROCEED } - if (previousBatchSucceeded == true) return Verdict.ASSUME_COMPLETE + if (previousBatchSucceeded == true && !currentBatchRereadsLive) return Verdict.ASSUME_COMPLETE consecutiveRepeats++ return if (consecutiveRepeats >= maxConsecutiveRepeats) Verdict.REPEATED else Verdict.PROCEED } @@ -132,4 +134,9 @@ internal class ToolCallProgressGuard( calls.joinToString("|") { call -> call.name + "(" + call.args.toSortedMap().entries.joinToString(",") { "${it.key}=${it.value}" } + ")" } + + private companion object { + /** Reads whose answer changes between calls, so re-issuing one does not mean the work is done. */ + val LIVE_READS = setOf("read_app_logs", "read_ide_logs") + } } diff --git a/plugins/AI-Core/src/main/kotlin/com/itsaky/androidide/plugins/aicore/tool/handlers/BuiltInToolHandlers.kt b/plugins/AI-Core/src/main/kotlin/com/itsaky/androidide/plugins/aicore/tool/handlers/BuiltInToolHandlers.kt index 8b37d069..dff7c351 100644 --- a/plugins/AI-Core/src/main/kotlin/com/itsaky/androidide/plugins/aicore/tool/handlers/BuiltInToolHandlers.kt +++ b/plugins/AI-Core/src/main/kotlin/com/itsaky/androidide/plugins/aicore/tool/handlers/BuiltInToolHandlers.kt @@ -23,8 +23,7 @@ object BuiltInToolHandlers { SearchProjectHandler(context), OpenFileHandler(context), ReadBuildOutputHandler(context), - ReadLogsHandler(context, LogSource.APP), - ReadLogsHandler(context, LogSource.IDE), + ) + (if (hostHasLogApi()) LogToolHandlers.create(context) else emptyList()) + listOf( // Write tools CreateFileHandler(context), UpdateFileHandler(context), @@ -36,4 +35,16 @@ object BuiltInToolHandlers { // Template tool GenerateFromTemplateHandler(context), ) + + // A string, not a class literal: hosts before ADFA-6267 lack the class, and the literal would throw. + private fun hostHasLogApi(): Boolean = + runCatching { Class.forName("com.itsaky.androidide.plugins.services.IdeLogService") }.isSuccess +} + +/** The log tools, kept apart so [LogSource] is only touched on a host that has it. */ +private object LogToolHandlers { + fun create(context: PluginContext): List = listOf( + ReadLogsHandler(context, LogSource.APP), + ReadLogsHandler(context, LogSource.IDE), + ) } diff --git a/plugins/AI-Core/src/main/kotlin/com/itsaky/androidide/plugins/aicore/tool/handlers/LogWindowCalculator.kt b/plugins/AI-Core/src/main/kotlin/com/itsaky/androidide/plugins/aicore/tool/handlers/LogWindowCalculator.kt index 9e8eeda3..0187c895 100644 --- a/plugins/AI-Core/src/main/kotlin/com/itsaky/androidide/plugins/aicore/tool/handlers/LogWindowCalculator.kt +++ b/plugins/AI-Core/src/main/kotlin/com/itsaky/androidide/plugins/aicore/tool/handlers/LogWindowCalculator.kt @@ -1,5 +1,6 @@ package com.itsaky.androidide.plugins.aicore.tool.handlers +import com.itsaky.androidide.plugins.aicore.tool.AgentLoop import com.itsaky.androidide.plugins.services.LogEntry import com.itsaky.androidide.plugins.services.LogLevel @@ -8,22 +9,20 @@ import com.itsaky.androidide.plugins.services.LogLevel * including the truncation markers. Only the kept lines are ever joined into a string. */ internal object LogWindowCalculator { - /** Maximum characters of log handed to the model, the same budget as build output. */ - const val MAX_OUTPUT_CHARS = 8000 + /** Maximum characters of log handed to the model: AgentLoop's cap, less room for the message. */ + const val MAX_OUTPUT_CHARS = AgentLoop.DEFAULT_TOOL_OUTPUT_CHAR_LIMIT - 100 - private const val TRUNCATION_MARKER = "...[truncated]..." - - /** Markers of an app crash; the anchor prefers these over any earlier plain error. */ + /** Markers of an app crash; the anchor prefers these over any plain error. */ private val CRASH_MARKERS = listOf("FATAL EXCEPTION", "Fatal signal") /** - * A crash's exception and top frames sit where it starts, not in the tail of later chatter, - * so the window starts at the first crash line, else the first error line, and keeps the - * head of what follows. With neither, it keeps the newest lines. + * Logs span runs, so the window starts at the newest crash line, else the start of the newest + * run of error lines, and keeps the head of what follows. With neither, it keeps the newest + * lines. */ fun windowFor(entries: List, hostTruncated: Boolean): OutputWindow { - val anchor = entries.indexOfFirst { isCrashLine(it) }.takeIf { it >= 0 } - ?: entries.indexOfFirst { it.level == LogLevel.ERROR }.takeIf { it >= 0 } + val anchor = entries.indexOfLast { isCrashLine(it) }.takeIf { it >= 0 } + ?: newestErrorRunStart(entries) val text = if (anchor != null) { head(entries.subList(anchor, entries.size), droppedBefore = hostTruncated || anchor > 0) } else { @@ -92,6 +91,13 @@ internal object LogWindowCalculator { return text.substring(if (text[start].isLowSurrogate()) start + 1 else start) } + /** The first line of the newest contiguous run of ERROR lines, so its trace keeps its head. */ + private fun newestErrorRunStart(entries: List): Int? { + var start = entries.indexOfLast { it.level == LogLevel.ERROR }.takeIf { it >= 0 } ?: return null + while (start > 0 && entries[start - 1].level == LogLevel.ERROR) start-- + return start + } + private fun isCrashLine(entry: LogEntry): Boolean = CRASH_MARKERS.any { entry.text.contains(it, ignoreCase = true) } } diff --git a/plugins/AI-Core/src/main/kotlin/com/itsaky/androidide/plugins/aicore/tool/handlers/OutputWindow.kt b/plugins/AI-Core/src/main/kotlin/com/itsaky/androidide/plugins/aicore/tool/handlers/OutputWindow.kt new file mode 100644 index 00000000..759e1f1f --- /dev/null +++ b/plugins/AI-Core/src/main/kotlin/com/itsaky/androidide/plugins/aicore/tool/handlers/OutputWindow.kt @@ -0,0 +1,10 @@ +package com.itsaky.androidide.plugins.aicore.tool.handlers + +/** Marks where a window dropped text, shared so the build and log windows read the same. */ +internal const val TRUNCATION_MARKER = "...[truncated]..." + +/** The slice of a build or app/IDE log handed to the model, and whether it starts at an error. */ +internal data class OutputWindow( + val text: String, + val anchoredOnError: Boolean, +) diff --git a/plugins/AI-Core/src/main/kotlin/com/itsaky/androidide/plugins/aicore/tool/handlers/ReadBuildOutputHandler.kt b/plugins/AI-Core/src/main/kotlin/com/itsaky/androidide/plugins/aicore/tool/handlers/ReadBuildOutputHandler.kt index 331a0c9c..3ccd45b9 100644 --- a/plugins/AI-Core/src/main/kotlin/com/itsaky/androidide/plugins/aicore/tool/handlers/ReadBuildOutputHandler.kt +++ b/plugins/AI-Core/src/main/kotlin/com/itsaky/androidide/plugins/aicore/tool/handlers/ReadBuildOutputHandler.kt @@ -7,12 +7,6 @@ import com.itsaky.androidide.plugins.aicore.tool.ToolHandler import com.itsaky.androidide.plugins.services.IdeBuildService import kotlinx.coroutines.CancellationException -/** The slice of a build log handed to the model, and whether it starts at the first error. */ -internal data class OutputWindow( - val text: String, - val anchoredOnError: Boolean, -) - /** * Handler for reading the current build output. */ @@ -80,8 +74,6 @@ class ReadBuildOutputHandler( /** Maximum characters of build log handed to the model. */ internal const val MAX_OUTPUT_CHARS = 8000 - private const val TRUNCATION_MARKER = "...[truncated]...\n" - // The host strips line timing prefixes; tolerated here so the window is right either way. private val LINE_PREFIX = Regex("""^(?:\[\d{2}:\d{2}:\d{2}\.\d{3}] )?(?:Δ\d+ms\s+)?""") @@ -112,7 +104,7 @@ class ReadBuildOutputHandler( val text = if (overflows) body.takeLast(MAX_OUTPUT_CHARS) else body val dropped = overflows || (errorOffset ?: 0) > 0 return OutputWindow( - text = if (dropped) TRUNCATION_MARKER + text else text, + text = if (dropped) "$TRUNCATION_MARKER\n$text" else text, anchoredOnError = errorOffset != null && !overflows, ) } diff --git a/plugins/AI-Core/src/main/kotlin/com/itsaky/androidide/plugins/aicore/tool/handlers/ReadLogsHandler.kt b/plugins/AI-Core/src/main/kotlin/com/itsaky/androidide/plugins/aicore/tool/handlers/ReadLogsHandler.kt index 42bd9750..9455f84a 100644 --- a/plugins/AI-Core/src/main/kotlin/com/itsaky/androidide/plugins/aicore/tool/handlers/ReadLogsHandler.kt +++ b/plugins/AI-Core/src/main/kotlin/com/itsaky/androidide/plugins/aicore/tool/handlers/ReadLogsHandler.kt @@ -74,7 +74,7 @@ class ReadLogsHandler( ) ToolResult.success( message = if (window.anchoredOnError) { - "$label from the first error (${window.text.length} characters)" + "$label from the newest error (${window.text.length} characters)" } else { "$label (last ${window.text.length} characters)" }, @@ -134,13 +134,13 @@ class ReadLogsHandler( private fun tabFor(source: LogSource): LogTab = when (source) { LogSource.APP -> LogTab( toolName = "read_app_logs", - description = "Read the App Logs of the user's running app, from the first crash or error", + description = "Read the App Logs of the user's running app, from the newest crash or error", label = "App Logs", emptyLog = "(App Logs are empty. Run the app with run_app first, then read them again.)", ) LogSource.IDE -> LogTab( toolName = "read_ide_logs", - description = "Read the IDE Logs of Code on the Go and its plugins, from the first error", + description = "Read the IDE Logs of Code on the Go and its plugins, from the newest error", label = "IDE Logs", emptyLog = "(IDE Logs are empty.)", ) diff --git a/plugins/AI-Core/src/test/kotlin/com/itsaky/androidide/plugins/aicore/tool/ToolCallProgressGuardTest.kt b/plugins/AI-Core/src/test/kotlin/com/itsaky/androidide/plugins/aicore/tool/ToolCallProgressGuardTest.kt index 6031c2fb..4ce432e5 100644 --- a/plugins/AI-Core/src/test/kotlin/com/itsaky/androidide/plugins/aicore/tool/ToolCallProgressGuardTest.kt +++ b/plugins/AI-Core/src/test/kotlin/com/itsaky/androidide/plugins/aicore/tool/ToolCallProgressGuardTest.kt @@ -58,6 +58,15 @@ class ToolCallProgressGuardTest { assertEquals(ToolCallProgressGuard.Verdict.PROCEED, guard.inspect(call("read_file", "B.kt"))) } + @Test + fun givenALogReadThatJustSucceededReissued_whenInspected_thenItRunsAgain() { + val guard = guard() + guard.inspect(call("read_app_logs")) + guard.recordResults(ok) + + assertEquals(ToolCallProgressGuard.Verdict.PROCEED, guard.inspect(call("read_app_logs"))) + } + @Test fun givenArgumentsInAnotherOrder_whenInspected_thenTheBatchIsStillTheSameAction() { val guard = guard() diff --git a/plugins/AI-Core/src/test/kotlin/com/itsaky/androidide/plugins/aicore/tool/handlers/LogWindowCalculatorTest.kt b/plugins/AI-Core/src/test/kotlin/com/itsaky/androidide/plugins/aicore/tool/handlers/LogWindowCalculatorTest.kt index dfd38306..e034ad87 100644 --- a/plugins/AI-Core/src/test/kotlin/com/itsaky/androidide/plugins/aicore/tool/handlers/LogWindowCalculatorTest.kt +++ b/plugins/AI-Core/src/test/kotlin/com/itsaky/androidide/plugins/aicore/tool/handlers/LogWindowCalculatorTest.kt @@ -55,7 +55,7 @@ class LogWindowCalculatorTest { @Test fun givenALogOfExactlyTheBudget_whenWindowed_thenItIsReturnedWhole() { - val entries = List(79) { LogEntry(LogLevel.INFO, "x".repeat(99)) } + + val entries = List(38) { LogEntry(LogLevel.INFO, "x".repeat(99)) } + LogEntry(LogLevel.INFO, "x".repeat(100)) val whole = entries.joinToString("\n") { it.text } assertEquals(MAX_OUTPUT_CHARS, whole.length) @@ -90,6 +90,26 @@ class LogWindowCalculatorTest { assertFalse("split surrogate", text.removePrefix("$marker\n").first().isLowSurrogate()) } + @Test + fun givenAnOlderCrashAndANewerOne_whenWindowed_thenItAnchorsOnTheNewest() { + val older = LogEntry(LogLevel.ERROR, "E AndroidRuntime: FATAL EXCEPTION: old") + val newer = LogEntry(LogLevel.ERROR, "E AndroidRuntime: FATAL EXCEPTION: new") + + val text = LogWindowCalculator.windowFor(listOf(older) + info(3) + newer, false).text + + assertTrue(text.startsWith("$marker\n${newer.text}")) + } + + @Test + fun givenTwoErrorRuns_whenWindowed_thenItAnchorsOnTheStartOfTheNewest() { + val older = LogEntry(LogLevel.ERROR, "E Plugin: old") + val newest = listOf(LogEntry(LogLevel.ERROR, "E Plugin: new"), LogEntry(LogLevel.ERROR, "E Plugin: at frame")) + + val text = LogWindowCalculator.windowFor(listOf(older) + info(3) + newest, false).text + + assertEquals("$marker\nE Plugin: new\nE Plugin: at frame", text) + } + @Test fun givenALowercaseCrashHeader_whenWindowed_thenItStillAnchors() { val earlier = LogEntry(LogLevel.ERROR, "E Glide: load failed") diff --git a/plugins/AI-Core/src/test/kotlin/com/itsaky/androidide/plugins/aicore/tool/handlers/ReadLogsHandlerTest.kt b/plugins/AI-Core/src/test/kotlin/com/itsaky/androidide/plugins/aicore/tool/handlers/ReadLogsHandlerTest.kt index f430f5fe..688325b8 100644 --- a/plugins/AI-Core/src/test/kotlin/com/itsaky/androidide/plugins/aicore/tool/handlers/ReadLogsHandlerTest.kt +++ b/plugins/AI-Core/src/test/kotlin/com/itsaky/androidide/plugins/aicore/tool/handlers/ReadLogsHandlerTest.kt @@ -21,7 +21,7 @@ import org.junit.Test /** * Unit tests for [ReadLogsHandler] — the `read_app_logs` and `read_ide_logs` tools. Covers the - * 8000-character budget, the crash-anchored window, and the empty and missing-service answers. + * character budget, the crash-anchored window, and the empty and missing-service answers. */ class ReadLogsHandlerTest { @@ -158,7 +158,7 @@ class ReadLogsHandlerTest { assertTrue(data.contains("NullPointerException")) assertTrue(data.contains("MainActivity.kt:12")) assertFalse("the head must be dropped", data.contains("I MyApp: step1\n")) - assertTrue(result.message.contains("first error")) + assertTrue(result.message.contains("newest error")) } @Test From 61bb72ce068c400291dd1045c058f9e83044649d Mon Sep 17 00:00:00 2001 From: John Trujillo Date: Thu, 1 Oct 2026 09:04:57 -0500 Subject: [PATCH 3/4] fix(ai-core): address second review on log tools Anchor on the newer of crash/error run, tail all-ERROR reads, exempt only all-log-read batches, gate the run_app hint. Refs: ADFA-6268 --- .../aicore/tool/ToolCallProgressGuard.kt | 5 +- .../tool/handlers/BuiltInToolHandlers.kt | 2 +- .../tool/handlers/LogWindowCalculator.kt | 57 ++++++++++--------- .../aicore/tool/handlers/ReadLogsHandler.kt | 3 +- .../aicore/tool/handlers/RunAppHandler.kt | 9 ++- .../aicore/tool/ToolCallProgressGuardTest.kt | 10 ++++ .../tool/handlers/LogWindowCalculatorTest.kt | 29 ++++++++-- .../tool/handlers/ReadLogsHandlerTest.kt | 11 ++-- .../aicore/tool/handlers/RunAppHandlerTest.kt | 11 +++- 9 files changed, 95 insertions(+), 42 deletions(-) diff --git a/plugins/AI-Core/src/main/kotlin/com/itsaky/androidide/plugins/aicore/tool/ToolCallProgressGuard.kt b/plugins/AI-Core/src/main/kotlin/com/itsaky/androidide/plugins/aicore/tool/ToolCallProgressGuard.kt index 3fcdc2d3..fc6697b6 100644 --- a/plugins/AI-Core/src/main/kotlin/com/itsaky/androidide/plugins/aicore/tool/ToolCallProgressGuard.kt +++ b/plugins/AI-Core/src/main/kotlin/com/itsaky/androidide/plugins/aicore/tool/ToolCallProgressGuard.kt @@ -7,7 +7,8 @@ import com.itsaky.androidide.plugins.aicore.models.ToolResult * [AgentLoop] builds one per run and asks it about every batch before running it, so the loop * orchestrates turns and this decides what counts as progress. * - * @param maxConsecutiveRepeats identical unsuccessful batches tolerated back to back. + * @param maxConsecutiveRepeats identical unsuccessful batches, or successful log re-reads, tolerated + * back to back. * @param maxTurnsWithoutProgress turns tolerated introducing no batch the run has not already run. * @param pathsOf the project paths one call names, whether it reads them or rewrites them. * @param changesPaths whether a call rewrites what it names, so a run is not judged on novelty @@ -70,7 +71,7 @@ internal class ToolCallProgressGuard( currentBatchPaths = pathsNamedBy(calls) currentBatchWrites = pathsNamedBy(calls.filter(changesPaths)) currentBatchChanges = calls.any(changesPaths) - currentBatchRereadsLive = calls.any { it.name in LIVE_READS } + currentBatchRereadsLive = calls.all { it.name in LIVE_READS } val verdict = verdictFor(signature) if (verdict == Verdict.PROCEED) { previousSignature = signature diff --git a/plugins/AI-Core/src/main/kotlin/com/itsaky/androidide/plugins/aicore/tool/handlers/BuiltInToolHandlers.kt b/plugins/AI-Core/src/main/kotlin/com/itsaky/androidide/plugins/aicore/tool/handlers/BuiltInToolHandlers.kt index dff7c351..816529e5 100644 --- a/plugins/AI-Core/src/main/kotlin/com/itsaky/androidide/plugins/aicore/tool/handlers/BuiltInToolHandlers.kt +++ b/plugins/AI-Core/src/main/kotlin/com/itsaky/androidide/plugins/aicore/tool/handlers/BuiltInToolHandlers.kt @@ -30,7 +30,7 @@ object BuiltInToolHandlers { EditFileHandler(context), AddDependencyHandler(context), // Build tools - RunAppHandler(context), + RunAppHandler(context, hasLogTools = hostHasLogApi()), GradleSyncHandler(context), // Template tool GenerateFromTemplateHandler(context), diff --git a/plugins/AI-Core/src/main/kotlin/com/itsaky/androidide/plugins/aicore/tool/handlers/LogWindowCalculator.kt b/plugins/AI-Core/src/main/kotlin/com/itsaky/androidide/plugins/aicore/tool/handlers/LogWindowCalculator.kt index 0187c895..8f3933b2 100644 --- a/plugins/AI-Core/src/main/kotlin/com/itsaky/androidide/plugins/aicore/tool/handlers/LogWindowCalculator.kt +++ b/plugins/AI-Core/src/main/kotlin/com/itsaky/androidide/plugins/aicore/tool/handlers/LogWindowCalculator.kt @@ -16,53 +16,55 @@ internal object LogWindowCalculator { private val CRASH_MARKERS = listOf("FATAL EXCEPTION", "Fatal signal") /** - * Logs span runs, so the window starts at the newest crash line, else the start of the newest - * run of error lines, and keeps the head of what follows. With neither, it keeps the newest - * lines. + * Logs span runs, so the window starts at whichever is newer, the newest crash line or the start + * of the newest run of error lines. With neither, or a log of nothing but errors, it keeps the + * newest lines. */ fun windowFor(entries: List, hostTruncated: Boolean): OutputWindow { - val anchor = entries.indexOfLast { isCrashLine(it) }.takeIf { it >= 0 } - ?: newestErrorRunStart(entries) + val anchor = listOfNotNull( + entries.indexOfLast { isCrashLine(it) }.takeIf { it >= 0 }, + newestErrorRunStart(entries), + ).maxOrNull() val text = if (anchor != null) { - head(entries.subList(anchor, entries.size), droppedBefore = hostTruncated || anchor > 0) + fromAnchor(entries.subList(anchor, entries.size), droppedBefore = hostTruncated || anchor > 0) } else { tail(entries, droppedBefore = hostTruncated) } return OutputWindow(text = text, anchoredOnError = anchor != null) } - /** The oldest lines of [entries] that fit, marking what was dropped on either side. */ - private fun head(entries: List, droppedBefore: Boolean): String { + /** [entries] whole if they fit, else their head and their newest lines, half the room each. */ + private fun fromAnchor(entries: List, droppedBefore: Boolean): String { val prefix = if (droppedBefore) "$TRUNCATION_MARKER\n" else "" val room = MAX_OUTPUT_CHARS - prefix.length if (fitCount(entries, room) == entries.size) return prefix + joined(entries) - val suffix = "\n$TRUNCATION_MARKER" - val kept = fitCount(entries, room - suffix.length) - val body = if (kept > 0) { - joined(entries.subList(0, kept)) - } else { - keepStart(entries.first().text, room - suffix.length) - } - return prefix + body + suffix + val separator = "\n$TRUNCATION_MARKER\n" + val half = (room - separator.length) / 2 + return prefix + headBody(entries, half) + separator + tailBody(entries, half) } /** The newest lines of [entries] that fit, marking what was dropped before them. */ private fun tail(entries: List, droppedBefore: Boolean): String { + if (!droppedBefore && fitCount(entries, MAX_OUTPUT_CHARS) == entries.size) return joined(entries) val prefix = "$TRUNCATION_MARKER\n" - val newestFirst = entries.asReversed() - if (!droppedBefore && fitCount(newestFirst, MAX_OUTPUT_CHARS) == entries.size) { - return joined(entries) - } + return prefix + tailBody(entries, MAX_OUTPUT_CHARS - prefix.length) + } - val room = MAX_OUTPUT_CHARS - prefix.length - val kept = fitCount(newestFirst, room) - val body = if (kept > 0) { + /** The oldest whole lines of [entries] that fit in [room], else the start of the first. */ + private fun headBody(entries: List, room: Int): String { + val kept = fitCount(entries, room) + return if (kept > 0) joined(entries.subList(0, kept)) else keepStart(entries.first().text, room) + } + + /** The newest whole lines of [entries] that fit in [room], else the end of the last. */ + private fun tailBody(entries: List, room: Int): String { + val kept = fitCount(entries.asReversed(), room) + return if (kept > 0) { joined(entries.subList(entries.size - kept, entries.size)) } else { keepEnd(entries.last().text, room) } - return prefix + body } /** How many of [entries], taken in order, fit in [room] characters once joined by newlines. */ @@ -91,11 +93,14 @@ internal object LogWindowCalculator { return text.substring(if (text[start].isLowSurrogate()) start + 1 else start) } - /** The first line of the newest contiguous run of ERROR lines, so its trace keeps its head. */ + /** + * The first line of the newest contiguous run of ERROR lines, so its trace keeps its head; null + * when every line is an ERROR, where a head would keep the oldest errors. + */ private fun newestErrorRunStart(entries: List): Int? { var start = entries.indexOfLast { it.level == LogLevel.ERROR }.takeIf { it >= 0 } ?: return null while (start > 0 && entries[start - 1].level == LogLevel.ERROR) start-- - return start + return start.takeUnless { it == 0 && entries.last().level == LogLevel.ERROR } } private fun isCrashLine(entry: LogEntry): Boolean = diff --git a/plugins/AI-Core/src/main/kotlin/com/itsaky/androidide/plugins/aicore/tool/handlers/ReadLogsHandler.kt b/plugins/AI-Core/src/main/kotlin/com/itsaky/androidide/plugins/aicore/tool/handlers/ReadLogsHandler.kt index 9455f84a..1d354511 100644 --- a/plugins/AI-Core/src/main/kotlin/com/itsaky/androidide/plugins/aicore/tool/handlers/ReadLogsHandler.kt +++ b/plugins/AI-Core/src/main/kotlin/com/itsaky/androidide/plugins/aicore/tool/handlers/ReadLogsHandler.kt @@ -136,7 +136,8 @@ class ReadLogsHandler( toolName = "read_app_logs", description = "Read the App Logs of the user's running app, from the newest crash or error", label = "App Logs", - emptyLog = "(App Logs are empty. Run the app with run_app first, then read them again.)", + emptyLog = "(No App Logs have arrived: the app has not run since they were cleared, or the " + + "log sender is disabled in Developer Options.)", ) LogSource.IDE -> LogTab( toolName = "read_ide_logs", diff --git a/plugins/AI-Core/src/main/kotlin/com/itsaky/androidide/plugins/aicore/tool/handlers/RunAppHandler.kt b/plugins/AI-Core/src/main/kotlin/com/itsaky/androidide/plugins/aicore/tool/handlers/RunAppHandler.kt index 8e6dd7f5..8cc55461 100644 --- a/plugins/AI-Core/src/main/kotlin/com/itsaky/androidide/plugins/aicore/tool/handlers/RunAppHandler.kt +++ b/plugins/AI-Core/src/main/kotlin/com/itsaky/androidide/plugins/aicore/tool/handlers/RunAppHandler.kt @@ -28,7 +28,8 @@ internal const val BUILD_PROGRESS_LOG_INTERVAL_MS = 30 * 1000L * Handler for running/building the app. */ class RunAppHandler( - private val pluginContext: PluginContext + private val pluginContext: PluginContext, + private val hasLogTools: Boolean = false, ) : ToolHandler { override val toolName = "run_app" @@ -96,7 +97,11 @@ class RunAppHandler( if (success) { ToolResult.success( message = "Build succeeded", - data = "$message\n\nIf the app crashes or misbehaves, call read_app_logs." + data = if (hasLogTools) { + "$message\n\nIf the app crashes or misbehaves, call read_app_logs." + } else { + message + } ) } else { ToolResult.failure( diff --git a/plugins/AI-Core/src/test/kotlin/com/itsaky/androidide/plugins/aicore/tool/ToolCallProgressGuardTest.kt b/plugins/AI-Core/src/test/kotlin/com/itsaky/androidide/plugins/aicore/tool/ToolCallProgressGuardTest.kt index 4ce432e5..535bde8d 100644 --- a/plugins/AI-Core/src/test/kotlin/com/itsaky/androidide/plugins/aicore/tool/ToolCallProgressGuardTest.kt +++ b/plugins/AI-Core/src/test/kotlin/com/itsaky/androidide/plugins/aicore/tool/ToolCallProgressGuardTest.kt @@ -67,6 +67,16 @@ class ToolCallProgressGuardTest { assertEquals(ToolCallProgressGuard.Verdict.PROCEED, guard.inspect(call("read_app_logs"))) } + @Test + fun givenALogReadBatchedWithABuildReissued_whenInspected_thenItAssumesComplete() { + val guard = guard() + val batch = call("run_app") + call("read_app_logs") + guard.inspect(batch) + guard.recordResults(ok) + + assertEquals(ToolCallProgressGuard.Verdict.ASSUME_COMPLETE, guard.inspect(batch)) + } + @Test fun givenArgumentsInAnotherOrder_whenInspected_thenTheBatchIsStillTheSameAction() { val guard = guard() diff --git a/plugins/AI-Core/src/test/kotlin/com/itsaky/androidide/plugins/aicore/tool/handlers/LogWindowCalculatorTest.kt b/plugins/AI-Core/src/test/kotlin/com/itsaky/androidide/plugins/aicore/tool/handlers/LogWindowCalculatorTest.kt index e034ad87..a0b07b08 100644 --- a/plugins/AI-Core/src/test/kotlin/com/itsaky/androidide/plugins/aicore/tool/handlers/LogWindowCalculatorTest.kt +++ b/plugins/AI-Core/src/test/kotlin/com/itsaky/androidide/plugins/aicore/tool/handlers/LogWindowCalculatorTest.kt @@ -37,7 +37,8 @@ class LogWindowCalculatorTest { assertTrue("budget exceeded: ${text.length}", text.length <= MAX_OUTPUT_CHARS) assertTrue(text.startsWith("$marker\n${crash.text}")) - assertTrue(text.endsWith("\n$marker")) + assertTrue(text.contains("\n$marker\n")) + assertTrue(text.endsWith("I MyApp: after2000")) assertWholeLines(text, entries) } @@ -68,14 +69,14 @@ class LogWindowCalculatorTest { @Test fun givenOneOversizedCrashLine_whenWindowed_thenItsStartIsKeptWithoutSplittingAnEmoji() { // The emoji's surrogate pair straddles the cut point. - val room = MAX_OUTPUT_CHARS - "\n$marker".length - val line = "E AndroidRuntime: FATAL EXCEPTION: " + "a".repeat(room - 36) + "😀" + "b".repeat(100) + val half = (MAX_OUTPUT_CHARS - "\n$marker\n".length) / 2 + val line = "E AndroidRuntime: FATAL EXCEPTION: " + "a".repeat(half - 36) + "😀" + "b".repeat(MAX_OUTPUT_CHARS) val text = LogWindowCalculator.windowFor(listOf(LogEntry(LogLevel.ERROR, line)), false).text assertTrue(text.length <= MAX_OUTPUT_CHARS) assertTrue(text.startsWith("E AndroidRuntime: FATAL EXCEPTION")) - assertFalse("split surrogate", text.removeSuffix("\n$marker").last().isHighSurrogate()) + assertFalse("split surrogate", text.substringBefore("\n$marker\n").last().isHighSurrogate()) } @Test @@ -120,4 +121,24 @@ class LogWindowCalculatorTest { assertTrue(window.anchoredOnError) assertTrue(window.text.startsWith("$marker\n${lowercase.text}")) } + + @Test + fun givenAnOlderCrashAndANewerErrorRun_whenWindowed_thenItAnchorsOnTheErrorRun() { + val newer = LogEntry(LogLevel.ERROR, "E Plugin: new") + + val text = LogWindowCalculator.windowFor(listOf(crash) + info(3) + newer, false).text + + assertEquals("$marker\n${newer.text}", text) + } + + @Test + fun givenOnlyErrorLines_whenWindowed_thenTheNewestAreKept() { + val entries = (1..2000).map { LogEntry(LogLevel.ERROR, "E MyApp: error$it") } + + val text = LogWindowCalculator.windowFor(entries, hostTruncated = false).text + + assertTrue(text.startsWith("$marker\n")) + assertTrue(text.endsWith("E MyApp: error2000")) + assertWholeLines(text, entries) + } } diff --git a/plugins/AI-Core/src/test/kotlin/com/itsaky/androidide/plugins/aicore/tool/handlers/ReadLogsHandlerTest.kt b/plugins/AI-Core/src/test/kotlin/com/itsaky/androidide/plugins/aicore/tool/handlers/ReadLogsHandlerTest.kt index 688325b8..adb54d16 100644 --- a/plugins/AI-Core/src/test/kotlin/com/itsaky/androidide/plugins/aicore/tool/handlers/ReadLogsHandlerTest.kt +++ b/plugins/AI-Core/src/test/kotlin/com/itsaky/androidide/plugins/aicore/tool/handlers/ReadLogsHandlerTest.kt @@ -79,14 +79,15 @@ class ReadLogsHandlerTest { } @Test - fun givenAnEmptyLog_whenReading_thenItSaysSoAndPointsAtRunApp() = runTest { + fun givenAnEmptyLog_whenReading_thenItNamesTheLogSenderWithoutPrescribingRunApp() = runTest { every { logService.readLogs(any(), any()) } returns LogReadResult.EMPTY val result = handler.execute(emptyMap()) assertTrue(result.success) assertTrue(result.message.contains("No App Logs")) - assertTrue(result.data.orEmpty().contains("run_app")) + assertTrue(result.data.orEmpty().contains("log sender")) + assertFalse(result.data.orEmpty().contains("run_app")) } @Test @@ -197,15 +198,15 @@ class ReadLogsHandlerTest { } @Test - fun givenACrashFollowedByALongLog_whenReading_thenTheCrashHeadSurvivesTheCap() = runTest { + fun givenACrashFollowedByALongLog_whenReading_thenTheCrashHeadAndNewestLinesSurviveTheCap() = runTest { answerWith(crash + info(2000, label = "after")) val data = handler.execute(emptyMap()).data.orEmpty() assertTrue(data.startsWith("E AndroidRuntime: FATAL EXCEPTION: main")) assertTrue(data.contains("MainActivity.kt:12")) - assertTrue(data.endsWith("...[truncated]...")) - assertFalse(data.contains("I MyApp: after2000")) + assertTrue(data.contains("\n...[truncated]...\n")) + assertTrue(data.endsWith("I MyApp: after2000")) } @Test diff --git a/plugins/AI-Core/src/test/kotlin/com/itsaky/androidide/plugins/aicore/tool/handlers/RunAppHandlerTest.kt b/plugins/AI-Core/src/test/kotlin/com/itsaky/androidide/plugins/aicore/tool/handlers/RunAppHandlerTest.kt index e1237b45..6e50f471 100644 --- a/plugins/AI-Core/src/test/kotlin/com/itsaky/androidide/plugins/aicore/tool/handlers/RunAppHandlerTest.kt +++ b/plugins/AI-Core/src/test/kotlin/com/itsaky/androidide/plugins/aicore/tool/handlers/RunAppHandlerTest.kt @@ -79,7 +79,7 @@ class RunAppHandlerTest { fun givenTheCallbackReportsSuccess_whenRunning_thenItReturnsSuccessWithTheMessage() = runTest { answerWith(true to "Build successful") - val result = handler.execute(emptyMap()) + val result = RunAppHandler(context, hasLogTools = true).execute(emptyMap()) assertTrue(result.success) assertEquals("Build succeeded", result.message) @@ -90,6 +90,15 @@ class RunAppHandlerTest { ) } + @Test + fun givenNoLogTools_whenTheBuildSucceeds_thenItDoesNotNameReadAppLogs() = runTest { + answerWith(true to "Build successful") + + val result = handler.execute(emptyMap()) + + assertEquals("Build successful", result.data) + } + @Test fun givenTheCallbackReportsFailure_whenRunning_thenItReturnsFailureCarryingTheMessage() = runTest { answerWith(false to "Build failed: compilation error") From 78ecd26e4376979de98f931bd043dee01bae0109 Mon Sep 17 00:00:00 2001 From: John Trujillo Date: Thu, 1 Oct 2026 10:57:14 -0500 Subject: [PATCH 4/4] fix(ai-core): return small log reads whole, anchor on crash when it fits --- .../tool/handlers/LogWindowCalculator.kt | 26 +++++++++-------- .../tool/handlers/LogWindowCalculatorTest.kt | 28 ++++++++++++++++--- .../tool/handlers/ReadLogsHandlerTest.kt | 6 ++-- 3 files changed, 41 insertions(+), 19 deletions(-) diff --git a/plugins/AI-Core/src/main/kotlin/com/itsaky/androidide/plugins/aicore/tool/handlers/LogWindowCalculator.kt b/plugins/AI-Core/src/main/kotlin/com/itsaky/androidide/plugins/aicore/tool/handlers/LogWindowCalculator.kt index 8f3933b2..b0c78352 100644 --- a/plugins/AI-Core/src/main/kotlin/com/itsaky/androidide/plugins/aicore/tool/handlers/LogWindowCalculator.kt +++ b/plugins/AI-Core/src/main/kotlin/com/itsaky/androidide/plugins/aicore/tool/handlers/LogWindowCalculator.kt @@ -12,23 +12,24 @@ internal object LogWindowCalculator { /** Maximum characters of log handed to the model: AgentLoop's cap, less room for the message. */ const val MAX_OUTPUT_CHARS = AgentLoop.DEFAULT_TOOL_OUTPUT_CHAR_LIMIT - 100 - /** Markers of an app crash; the anchor prefers these over any plain error. */ + /** Markers of an app crash; the anchor prefers these over a later error when the crash fits. */ private val CRASH_MARKERS = listOf("FATAL EXCEPTION", "Fatal signal") /** - * Logs span runs, so the window starts at whichever is newer, the newest crash line or the start - * of the newest run of error lines. With neither, or a log of nothing but errors, it keeps the - * newest lines. + * A read that fits is returned whole. Logs span runs, so otherwise the window starts at the newest + * crash if it and all after it fit, else at whichever is newer, that crash or the start of the + * newest run of error lines. With neither, or a log of nothing but errors, it keeps the newest lines. */ fun windowFor(entries: List, hostTruncated: Boolean): OutputWindow { - val anchor = listOfNotNull( - entries.indexOfLast { isCrashLine(it) }.takeIf { it >= 0 }, - newestErrorRunStart(entries), - ).maxOrNull() + if (!hostTruncated && fits(entries, MAX_OUTPUT_CHARS)) return OutputWindow(joined(entries), false) + val crash = entries.indexOfLast { isCrashLine(it) }.takeIf { it >= 0 } + val markedRoom = MAX_OUTPUT_CHARS - TRUNCATION_MARKER.length - 1 + val anchor = crash?.takeIf { fits(entries.subList(it, entries.size), markedRoom) } + ?: listOfNotNull(crash, newestErrorRunStart(entries)).maxOrNull() val text = if (anchor != null) { fromAnchor(entries.subList(anchor, entries.size), droppedBefore = hostTruncated || anchor > 0) } else { - tail(entries, droppedBefore = hostTruncated) + tail(entries) } return OutputWindow(text = text, anchoredOnError = anchor != null) } @@ -37,7 +38,7 @@ internal object LogWindowCalculator { private fun fromAnchor(entries: List, droppedBefore: Boolean): String { val prefix = if (droppedBefore) "$TRUNCATION_MARKER\n" else "" val room = MAX_OUTPUT_CHARS - prefix.length - if (fitCount(entries, room) == entries.size) return prefix + joined(entries) + if (fits(entries, room)) return prefix + joined(entries) val separator = "\n$TRUNCATION_MARKER\n" val half = (room - separator.length) / 2 @@ -45,8 +46,7 @@ internal object LogWindowCalculator { } /** The newest lines of [entries] that fit, marking what was dropped before them. */ - private fun tail(entries: List, droppedBefore: Boolean): String { - if (!droppedBefore && fitCount(entries, MAX_OUTPUT_CHARS) == entries.size) return joined(entries) + private fun tail(entries: List): String { val prefix = "$TRUNCATION_MARKER\n" return prefix + tailBody(entries, MAX_OUTPUT_CHARS - prefix.length) } @@ -79,6 +79,8 @@ internal object LogWindowCalculator { return count } + private fun fits(entries: List, room: Int): Boolean = fitCount(entries, room) == entries.size + private fun joined(entries: List): String = entries.joinToString("\n") { it.text } /** The start of one oversized line, never splitting a surrogate pair. */ diff --git a/plugins/AI-Core/src/test/kotlin/com/itsaky/androidide/plugins/aicore/tool/handlers/LogWindowCalculatorTest.kt b/plugins/AI-Core/src/test/kotlin/com/itsaky/androidide/plugins/aicore/tool/handlers/LogWindowCalculatorTest.kt index a0b07b08..6bae6238 100644 --- a/plugins/AI-Core/src/test/kotlin/com/itsaky/androidide/plugins/aicore/tool/handlers/LogWindowCalculatorTest.kt +++ b/plugins/AI-Core/src/test/kotlin/com/itsaky/androidide/plugins/aicore/tool/handlers/LogWindowCalculatorTest.kt @@ -96,7 +96,7 @@ class LogWindowCalculatorTest { val older = LogEntry(LogLevel.ERROR, "E AndroidRuntime: FATAL EXCEPTION: old") val newer = LogEntry(LogLevel.ERROR, "E AndroidRuntime: FATAL EXCEPTION: new") - val text = LogWindowCalculator.windowFor(listOf(older) + info(3) + newer, false).text + val text = LogWindowCalculator.windowFor(listOf(older) + info(2000) + newer, false).text assertTrue(text.startsWith("$marker\n${newer.text}")) } @@ -106,7 +106,7 @@ class LogWindowCalculatorTest { val older = LogEntry(LogLevel.ERROR, "E Plugin: old") val newest = listOf(LogEntry(LogLevel.ERROR, "E Plugin: new"), LogEntry(LogLevel.ERROR, "E Plugin: at frame")) - val text = LogWindowCalculator.windowFor(listOf(older) + info(3) + newest, false).text + val text = LogWindowCalculator.windowFor(listOf(older) + info(2000) + newest, false).text assertEquals("$marker\nE Plugin: new\nE Plugin: at frame", text) } @@ -116,7 +116,7 @@ class LogWindowCalculatorTest { val earlier = LogEntry(LogLevel.ERROR, "E Glide: load failed") val lowercase = LogEntry(null, "fatal signal 11 (SIGSEGV), code 1") - val window = LogWindowCalculator.windowFor(listOf(earlier) + info(3) + lowercase, false) + val window = LogWindowCalculator.windowFor(listOf(earlier) + info(2000) + lowercase, false) assertTrue(window.anchoredOnError) assertTrue(window.text.startsWith("$marker\n${lowercase.text}")) @@ -126,11 +126,31 @@ class LogWindowCalculatorTest { fun givenAnOlderCrashAndANewerErrorRun_whenWindowed_thenItAnchorsOnTheErrorRun() { val newer = LogEntry(LogLevel.ERROR, "E Plugin: new") - val text = LogWindowCalculator.windowFor(listOf(crash) + info(3) + newer, false).text + val text = LogWindowCalculator.windowFor(listOf(crash) + info(2000) + newer, false).text assertEquals("$marker\n${newer.text}", text) } + @Test + fun givenACrashThenALaterErrorThatBothFit_whenWindowed_thenItAnchorsOnTheCrash() { + val later = LogEntry(LogLevel.ERROR, "E FrameEvents: updateAcquireFence") + + val text = LogWindowCalculator.windowFor(info(2000) + crash + info(3) + later, false).text + + assertTrue(text.startsWith("$marker\n${crash.text}")) + assertTrue(text.endsWith(later.text)) + } + + @Test + fun givenAReadThatFits_whenWindowed_thenItIsReturnedWholeAndUnanchored() { + val entries = info(3) + LogEntry(LogLevel.ERROR, "E MyApp: failed") + + val window = LogWindowCalculator.windowFor(entries, hostTruncated = false) + + assertEquals(entries.joinToString("\n") { it.text }, window.text) + assertFalse(window.anchoredOnError) + } + @Test fun givenOnlyErrorLines_whenWindowed_thenTheNewestAreKept() { val entries = (1..2000).map { LogEntry(LogLevel.ERROR, "E MyApp: error$it") } diff --git a/plugins/AI-Core/src/test/kotlin/com/itsaky/androidide/plugins/aicore/tool/handlers/ReadLogsHandlerTest.kt b/plugins/AI-Core/src/test/kotlin/com/itsaky/androidide/plugins/aicore/tool/handlers/ReadLogsHandlerTest.kt index adb54d16..0750d39c 100644 --- a/plugins/AI-Core/src/test/kotlin/com/itsaky/androidide/plugins/aicore/tool/handlers/ReadLogsHandlerTest.kt +++ b/plugins/AI-Core/src/test/kotlin/com/itsaky/androidide/plugins/aicore/tool/handlers/ReadLogsHandlerTest.kt @@ -150,7 +150,7 @@ class ReadLogsHandlerTest { @Test fun givenACrashAfterChatter_whenReading_thenTheWindowStartsAtTheCrash() = runTest { - answerWith(info(50) + crash + info(5, label = "after")) + answerWith(info(2000) + crash + info(5, label = "after")) val result = handler.execute(emptyMap()) @@ -165,7 +165,7 @@ class ReadLogsHandlerTest { @Test fun givenAnEarlierPlainErrorAndACrash_whenReading_thenTheCrashWins() = runTest { val earlier = LogEntry(LogLevel.ERROR, "E Glide: load failed") - answerWith(listOf(earlier) + info(5) + crash) + answerWith(listOf(earlier) + info(2000) + crash) val data = handler.execute(emptyMap()).data.orEmpty() @@ -176,7 +176,7 @@ class ReadLogsHandlerTest { @Test fun givenAnErrorButNoCrash_whenReading_thenTheWindowStartsAtTheError() = runTest { val error = LogEntry(LogLevel.ERROR, "E PluginManager: failed to load plugin") - answerWith(info(20) + error + info(3, label = "after")) + answerWith(info(2000) + error + info(3, label = "after")) val data = handler.execute(emptyMap()).data.orEmpty()