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/assets/docs/index.html b/plugins/AI-Core/src/main/assets/docs/index.html
index fc29b5b1..6b77c9af 100644
--- a/plugins/AI-Core/src/main/assets/docs/index.html
+++ b/plugins/AI-Core/src/main/assets/docs/index.html
@@ -103,6 +103,9 @@
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/ToolCallProgressGuard.kt b/plugins/AI-Core/src/main/kotlin/com/itsaky/androidide/plugins/aicore/tool/ToolCallProgressGuard.kt
index b8272aba..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
@@ -49,6 +50,7 @@ internal class ToolCallProgressGuard(
private var currentBatchWrites = emptySet()
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 +71,7 @@ internal class ToolCallProgressGuard(
currentBatchPaths = pathsNamedBy(calls)
currentBatchWrites = pathsNamedBy(calls.filter(changesPaths))
currentBatchChanges = calls.any(changesPaths)
+ currentBatchRereadsLive = calls.all { it.name in LIVE_READS }
val verdict = verdictFor(signature)
if (verdict == Verdict.PROCEED) {
previousSignature = signature
@@ -117,7 +120,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 +135,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 31d2270e..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
@@ -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,15 +23,28 @@ object BuiltInToolHandlers {
SearchProjectHandler(context),
OpenFileHandler(context),
ReadBuildOutputHandler(context),
+ ) + (if (hostHasLogApi()) LogToolHandlers.create(context) else emptyList()) + listOf(
// Write tools
CreateFileHandler(context),
UpdateFileHandler(context),
EditFileHandler(context),
AddDependencyHandler(context),
// Build tools
- RunAppHandler(context),
+ RunAppHandler(context, hasLogTools = hostHasLogApi()),
GradleSyncHandler(context),
// 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
new file mode 100644
index 00000000..b0c78352
--- /dev/null
+++ b/plugins/AI-Core/src/main/kotlin/com/itsaky/androidide/plugins/aicore/tool/handlers/LogWindowCalculator.kt
@@ -0,0 +1,110 @@
+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
+
+/**
+ * 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: 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 a later error when the crash fits. */
+ private val CRASH_MARKERS = listOf("FATAL EXCEPTION", "Fatal signal")
+
+ /**
+ * 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 {
+ 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)
+ }
+ return OutputWindow(text = text, anchoredOnError = anchor != null)
+ }
+
+ /** [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 (fits(entries, room)) return prefix + joined(entries)
+
+ 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): String {
+ val prefix = "$TRUNCATION_MARKER\n"
+ return prefix + tailBody(entries, MAX_OUTPUT_CHARS - prefix.length)
+ }
+
+ /** 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)
+ }
+ }
+
+ /** 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 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. */
+ 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)
+ }
+
+ /**
+ * 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.takeUnless { it == 0 && entries.last().level == LogLevel.ERROR }
+ }
+
+ 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
new file mode 100644
index 00000000..1d354511
--- /dev/null
+++ b/plugins/AI-Core/src/main/kotlin/com/itsaky/androidide/plugins/aicore/tool/handlers/ReadLogsHandler.kt
@@ -0,0 +1,150 @@
+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 newest 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 newest crash or error",
+ label = "App Logs",
+ 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",
+ 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/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..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
+ 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 6031c2fb..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
@@ -58,6 +58,25 @@ 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 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/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..6bae6238
--- /dev/null
+++ b/plugins/AI-Core/src/test/kotlin/com/itsaky/androidide/plugins/aicore/tool/handlers/LogWindowCalculatorTest.kt
@@ -0,0 +1,164 @@
+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.contains("\n$marker\n"))
+ assertTrue(text.endsWith("I MyApp: after2000"))
+ 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(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)
+
+ 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 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.substringBefore("\n$marker\n").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 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(2000) + 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(2000) + 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")
+ val lowercase = LogEntry(null, "fatal signal 11 (SIGSEGV), code 1")
+
+ val window = LogWindowCalculator.windowFor(listOf(earlier) + info(2000) + lowercase, false)
+
+ 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(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") }
+
+ 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
new file mode 100644
index 00000000..0750d39c
--- /dev/null
+++ b/plugins/AI-Core/src/test/kotlin/com/itsaky/androidide/plugins/aicore/tool/handlers/ReadLogsHandlerTest.kt
@@ -0,0 +1,230 @@
+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
+ * 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_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("log sender"))
+ assertFalse(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(2000) + 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("newest error"))
+ }
+
+ @Test
+ fun givenAnEarlierPlainErrorAndACrash_whenReading_thenTheCrashWins() = runTest {
+ val earlier = LogEntry(LogLevel.ERROR, "E Glide: load failed")
+ answerWith(listOf(earlier) + info(2000) + 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(2000) + 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_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.contains("\n...[truncated]...\n"))
+ assertTrue(data.endsWith("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..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,10 +79,23 @@ 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)
+ 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
+ fun givenNoLogTools_whenTheBuildSucceeds_thenItDoesNotNameReadAppLogs() = runTest {
+ answerWith(true to "Build successful")
+
+ val result = handler.execute(emptyMap())
+
assertEquals("Build successful", result.data)
}
@@ -119,7 +132,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