diff --git a/plugins/AI-Agent-OpenAI/src/main/kotlin/com/itsaky/androidide/plugins/aiagentopenai/backend/OpenAiBackend.kt b/plugins/AI-Agent-OpenAI/src/main/kotlin/com/itsaky/androidide/plugins/aiagentopenai/backend/OpenAiBackend.kt
index 83542422..847d7e22 100644
--- a/plugins/AI-Agent-OpenAI/src/main/kotlin/com/itsaky/androidide/plugins/aiagentopenai/backend/OpenAiBackend.kt
+++ b/plugins/AI-Agent-OpenAI/src/main/kotlin/com/itsaky/androidide/plugins/aiagentopenai/backend/OpenAiBackend.kt
@@ -44,6 +44,7 @@ import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch
import org.json.JSONArray
import org.json.JSONObject
+import java.util.concurrent.ConcurrentHashMap
/**
* Tool-protocol tracing, under the tag suffix `ai-core` uses for the other half of the same run:
@@ -54,6 +55,12 @@ import org.json.JSONObject
*/
private const val TAG = "$LOG_PREFIX.AgentTrace"
+/** How much of a refusal's body the trace keeps: enough for the field and the reason it names. */
+private const val REFUSAL_BODY_PREVIEW_CHARS = 500
+
+/** One model on one server, the unit a tool refusal is remembered for. */
+private data class ServerModel(val baseUrl: String, val model: String)
+
/**
* OpenAI-compatible backend: one transport for every server that speaks `chat/completions`.
*
@@ -111,11 +118,10 @@ class OpenAiBackend(
private var currentJob: Job? = null
/**
- * Base URL that answered a tool declaration with a refusal, so the next turn does not pay the
- * same round trip. Keyed by the URL itself, so pointing the setting elsewhere re-probes.
+ * Every server and model that answered a tool declaration with a refusal, so a later turn does
+ * not pay the same round trip. Keyed by both: one model refusing tools says nothing of the next.
*/
- @Volatile
- private var toolsRejectedBy: String? = null
+ private val toolsRejectedBy: MutableSet = ConcurrentHashMap.newKeySet()
/**
* Vector length this server actually returned, as (embedding model -> dimensions).
@@ -829,7 +835,8 @@ class OpenAiBackend(
* Run [attempt] with [tools] declared and, if this server refuses a tool declaration, run it
* once more with none.
*
- * The refusal is remembered per server so only the first turn pays for it. What it costs is
+ * The refusal is remembered per server and model so only the first turn pays for it, and
+ * picking another model tries tools again. What it costs is
* real: the system prompt for this run was built for native calling, so it teaches no envelope
* and the model has no other way to reach a tool — the turn answers in prose. Servers that
* take `tools` are the overwhelming majority, and this keeps the rest chatting rather than
@@ -842,8 +849,8 @@ class OpenAiBackend(
tools: List,
attempt: suspend (List) -> Unit
) {
- val baseUrl = getBaseUrl()
- if (tools.isEmpty() || toolsRejectedBy == baseUrl) {
+ val target = ServerModel(getBaseUrl(), getModelName())
+ if (tools.isEmpty() || target in toolsRejectedBy) {
attempt(emptyList())
return
}
@@ -853,16 +860,23 @@ class OpenAiBackend(
throw e
} catch (e: OpenAiHttpException) {
if (!UnsupportedTools.rejectedIn(e.statusCode, e.body)) throw e
- toolsRejectedBy = baseUrl
- Log.w(TAG, "REQUEST | server refused a tool declaration; retrying with none")
+ toolsRejectedBy.add(target)
+ // The body names what was refused; without it a refused model and a refused schema look alike.
+ Log.w(
+ TAG,
+ "REQUEST | ${target.model} refused a tool declaration; retrying with none | " +
+ e.body.orEmpty().take(REFUSAL_BODY_PREVIEW_CHARS)
+ )
+ // The toast sends the user here, so this line carries what the server named too.
context.logger.warn(
- "OpenAiBackend: $baseUrl does not accept tool declarations; " +
- "the agent cannot call tools on this server"
+ "OpenAiBackend: ${target.model} on ${target.baseUrl} refused the tool " +
+ "declarations; the agent cannot call tools with it | " +
+ e.body.orEmpty().take(REFUSAL_BODY_PREVIEW_CHARS)
)
// Said out loud, not only logged: from here the agent answers but never touches the
- // project, which reads as the tools being broken. Once per server, since the flag
- // above short-circuits every later turn.
- notifyToolsUnsupported(baseUrl)
+ // project, which reads as the tools being broken. Once per server and model, since
+ // the set above short-circuits every later turn.
+ notifyToolsUnsupported(target)
attempt(emptyList())
}
}
@@ -871,11 +885,11 @@ class OpenAiBackend(
* Tells the user this server cannot call tools, as a Toast: the run continues, so there is no
* error message to carry it, and the chat's own turn is an ordinary prose answer.
*
- * @param baseUrl the server that refused, named in the message.
+ * @param target the server and model that refused, both named in the message.
*/
- private fun notifyToolsUnsupported(baseUrl: String) {
+ private fun notifyToolsUnsupported(target: ServerModel) {
val appContext = context.androidContext.applicationContext
- val message = appContext.getString(R.string.openai_error_tools_unsupported, baseUrl)
+ val message = appContext.getString(R.string.openai_error_tools_unsupported, target.model, target.baseUrl)
Handler(Looper.getMainLooper()).post {
Toast.makeText(appContext, message, Toast.LENGTH_LONG).show()
}
diff --git a/plugins/AI-Agent-OpenAI/src/main/res/values/strings.xml b/plugins/AI-Agent-OpenAI/src/main/res/values/strings.xml
index b6169185..f9441531 100644
--- a/plugins/AI-Agent-OpenAI/src/main/res/values/strings.xml
+++ b/plugins/AI-Agent-OpenAI/src/main/res/values/strings.xml
@@ -33,7 +33,7 @@
The server answered but sent no reply text. Check the model is fully loaded in your server, then try again — see the IDE log for what the server sent.
The model spent its whole reply on internal reasoning and never answered. Raise the response length limit in your server, or choose a model without a thinking mode.
The reply was cut off before any text arrived — the response length limit is too low for this model. Raise it in your server settings.
- %1$s does not support tool calling, so the AI can answer but cannot change your project. Point the server setting at one that does.
+ %1$s on %2$s refused the agent\'s tools, so the AI can answer but cannot use them. The model may not support tool calling, or the server may reject one tool\'s definition; the IDE log says which.
The request to the AI server failed.
The request to the AI server failed. %1$s
diff --git a/plugins/AI-Core/ai-core.html b/plugins/AI-Core/ai-core.html
index afaccd6d..ed710fa9 100644
--- a/plugins/AI-Core/ai-core.html
+++ b/plugins/AI-Core/ai-core.html
@@ -62,8 +62,8 @@ Core functionality
- Agent chat — a conversational editor tab that can read and edit
project files, add dependencies, run a Gradle sync, list and run any Gradle
- task (tests, lint, clean, your own) and launch the app, with every file-changing action
- gated behind an approval dialog.
+ task (tests, lint, clean, your own), run a shell command in the Terminal and
+ launch the app, with every file-changing action gated behind an approval dialog.
- Agent settings — one screen to pick the backend and configure it;
each backend contributes its own portion of that screen.
- Web search — the agent always has
web_search (the
@@ -140,10 +140,11 @@ Technical architecture
AI Core declares filesystem.read, filesystem.write,
system.commands and project.structure — the agent reads and edits
files in the open project, triggers Gradle sync, Gradle tasks and run through
- the IDE's own build service, and inspects the project's module structure. It declares
- no network access and loads no native code: those belong to the backend
- plugin that serves a given request, so a device using only the local backend
- never grants a network-capable AI plugin. The same holds for tools another
+ the IDE's own build service, runs shell commands in the IDE's Terminal, and
+ inspects the project's module structure. It also declares
+ network.access, for fetch_url, which reads a page or file only
+ after you approve it. It loads no native code: that belongs to the backend
+ plugin that serves a given request. The same holds for tools another
plugin contributes — they run inside the contributing plugin, under the
permissions it declared, which is why the approval dialog names the
plugin a tool came from.
@@ -158,7 +159,7 @@ Usage
enter a Gemini API key. The same screen is reachable from the Agent tab.
- Open a project and switch to the Agent tab to start chatting. Any
action that changes a file asks for your approval first, as does starting a
- Gradle sync or task, or generating from a template. Only reads run unprompted.
+ Gradle sync or task, running a shell command, or generating from a template. Only reads run unprompted.
- Optional: install a tool provider such as AI Agent MCP to
give the agent tools beyond its own. Its tools appear in the agent's tool
list once configured, and each asks for approval naming the plugin it came
@@ -175,11 +176,10 @@
Key benefits
- One router, many plugins — a single inference service shared
across all AI features, so backend and model choice are configured once.
- Install only what you need — a device that only ever uses the
- cloud backend need not carry an 8 MB native library, and one that never
- goes online need not carry a network-capable plugin at all.
- - Least privilege — network access, filesystem access and native
- code are declared by the specific backend that needs them, so what a user
- grants matches what they installed.
+ cloud backend need not carry an 8 MB native library.
+ - Least privilege — native code and a provider's API access are
+ declared by the specific backend that needs them, so what a user grants
+ matches what they installed.
- Extensible in two directions — a new model provider is a new
backend plugin, and a new agent capability is a new tool source. Neither
requires a change to AI Core or to any consumer plugin.
diff --git a/plugins/AI-Core/build.gradle.kts b/plugins/AI-Core/build.gradle.kts
index 78c39b0a..e036cbca 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 = 7
- versionName = "3.3.0"
+ versionCode = 8
+ versionName = "3.4.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 afd6e6ff..d758d608 100644
--- a/plugins/AI-Core/src/main/assets/docs/index.html
+++ b/plugins/AI-Core/src/main/assets/docs/index.html
@@ -111,6 +111,14 @@ What the agent can do
- List the project's Gradle tasks, your own custom tasks included, with
each task's group and description as of the last sync, so it runs a task
that exists. A task you just added appears after the next Gradle sync.
+ - Run a shell command or script with bash. It asks first, every time, runs it in the
+ Terminal so you can see what ran, and reads back the exit code and
+ output. Commands reuse an idle Terminal session, but each runs in a fresh shell, so a
+
cd or export does not carry over to the next command. A command still running after
+ 30 seconds, such as a server, is left running, and the next command opens
+ another session beside it; the agent can check on it later without asking, and
+ asks before stopping it, touching only its own commands. A command starts in the project folder;
+ pressing Stop interrupts it.
- 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.
@@ -158,8 +166,8 @@ Tools from other plugins
the plugin that supplied one knows what it does:
- They ask every time. The approval dialog names the plugin the tool
- came from — From MCP servers, for example — and Always Allow
- does not apply to them, however you answer. A plugin cannot waive this for
+ came from — From MCP servers, for example — and does not offer
+ Always Allow. A plugin cannot waive this for
its own tools, and the title shows the name the agent registered rather than
the one the tool came with, so a remote tool cannot pass itself off as a
built-in one.
diff --git a/plugins/AI-Core/src/main/assets/prompts/tool_descriptions.yml b/plugins/AI-Core/src/main/assets/prompts/tool_descriptions.yml
index 1927d0e9..92dbc819 100644
--- a/plugins/AI-Core/src/main/assets/prompts/tool_descriptions.yml
+++ b/plugins/AI-Core/src/main/assets/prompts/tool_descriptions.yml
@@ -92,6 +92,39 @@ built_in_tools:
filter: >-
Text to search task paths, groups and descriptions for, e.g. ":app", "test" or "lint".
Omit to list the tasks that have a group or a description.
+ run_shell_command:
+ # Without the second sentence models hand the user a script to run themselves (ADFA-6339);
+ # without the third, Gemini ran "ls -la" when asked for "ls". The host runs each call in a
+ # fresh bash, so a lone "cd" or "export" is lost before the next call.
+ description: >-
+ Run a shell command or script with bash in the IDE's Terminal and get back its exit code and
+ output. Use it instead of asking the user to run a command. When the user gives the command,
+ run it exactly as written, adding no flags or options they did not ask for. Each call starts
+ a new shell, so cd and export do not carry over to the next call: use working_directory, or
+ chain in one call, e.g. "cd app && ls src". A non-zero exit code can be the answer, e.g.
+ grep exits 1 when nothing matches. A command still running after 30 seconds, such as a
+ server, is left running and its output so far comes back. For a Gradle task use
+ run_gradle_task, not ./gradlew
+ arguments:
+ command: >-
+ The command or script to run with bash, e.g. "ls -la app/src" or several lines of script.
+ working_directory: >-
+ Project-relative directory to run in; it must be inside the project. Omit to run at the
+ project root.
+ read_terminal_command:
+ description: >-
+ Check on a command run_shell_command left running, such as a server: whether it still runs,
+ its exit code once it stopped, and its latest output
+ arguments:
+ command_id: The command id the run_shell_command result reported.
+ stop_terminal_command:
+ # Its second sentence: Gemini stopped a ping it had just started, to finish the request.
+ description: >-
+ Stop a command run_shell_command left running, such as a server or ping, with Ctrl-C. Use it
+ only when the user asks to stop, kill or cancel that command, never to finish a request that
+ started it
+ arguments:
+ command_id: The command id the run_shell_command result reported.
generate_from_template:
description: Generate files from Pebble templates with variable substitution
arguments:
diff --git a/plugins/AI-Core/src/main/kotlin/com/itsaky/androidide/plugins/aicore/fragments/ApprovalDialogFragment.kt b/plugins/AI-Core/src/main/kotlin/com/itsaky/androidide/plugins/aicore/fragments/ApprovalDialogFragment.kt
index c647edbe..d4070152 100644
--- a/plugins/AI-Core/src/main/kotlin/com/itsaky/androidide/plugins/aicore/fragments/ApprovalDialogFragment.kt
+++ b/plugins/AI-Core/src/main/kotlin/com/itsaky/androidide/plugins/aicore/fragments/ApprovalDialogFragment.kt
@@ -1,6 +1,7 @@
package com.itsaky.androidide.plugins.aicore.fragments
import android.app.Dialog
+import android.content.res.Resources
import android.os.Bundle
import android.text.InputType
import android.view.View
@@ -11,16 +12,16 @@ import androidx.fragment.app.DialogFragment
import com.google.android.material.dialog.MaterialAlertDialogBuilder
import com.itsaky.androidide.plugins.aicore.plugin.AiCorePlugin
import com.itsaky.androidide.plugins.aicore.R
+import com.itsaky.androidide.plugins.aicore.tool.ApprovalPreview
import com.itsaky.androidide.plugins.aicore.tool.ApprovalRequest
import com.itsaky.androidide.plugins.aicore.tool.ApprovalResult
-import com.itsaky.androidide.plugins.aicore.tool.handlers.EditFileHandler
import com.itsaky.androidide.plugins.base.PluginFragmentHelper
import com.itsaky.androidide.plugins.services.IdeTooltipService
/**
- * Dialog for approving tool execution; for an edit, a real review step — a before/after block with
- * **Accept / Correct / Decline** and no blanket "Always Allow". Decisions go to [Host], resolved
- * from [getParentFragment] each time, since a captured callback dies on recreation.
+ * Dialog for approving tool execution; for an edit, a before/after block with **Accept / Correct /
+ * Decline**. "Always Allow" shows only when the request allows a session grant. Decisions go
+ * to [Host], resolved from [getParentFragment] each time, since a captured callback dies on recreation.
*/
class ApprovalDialogFragment : DialogFragment() {
@@ -57,15 +58,17 @@ class ApprovalDialogFragment : DialogFragment() {
private const val ARG_SOURCE = "source"
private const val ARG_DESCRIPTION = "description"
private const val ARG_ARGS = "args"
- private const val ARG_IS_EDIT = "is_edit"
+ private const val ARG_PREVIEW = "preview"
+ private const val ARG_ALLOWS_ALWAYS_ALLOW = "allows_always_allow"
/**
* Builds the dialog. Everything it needs is in [getArguments], so the framework can
* recreate it after a configuration change without losing the decision channel.
* @param request the pending approval to render.
+ * @param resources words the labels inside the formatted arguments.
*/
- fun newInstance(request: ApprovalRequest): ApprovalDialogFragment {
- val isEdit = request.toolName == EditFileHandler.TOOL_NAME
+ fun newInstance(request: ApprovalRequest, resources: Resources): ApprovalDialogFragment {
+ val preview = request.preview
return ApprovalDialogFragment().apply {
arguments = Bundle().apply {
// The registered name, not the provider's: it is the tool that will actually
@@ -77,12 +80,9 @@ class ApprovalDialogFragment : DialogFragment() {
)
putString(ARG_SOURCE, request.sourceLabel)
putString(ARG_DESCRIPTION, request.description)
- putBoolean(ARG_IS_EDIT, isEdit)
- putString(
- ARG_ARGS,
- if (isEdit) ApprovalTextFormatter.formatEdit(request.args)
- else ApprovalTextFormatter.formatArgs(request.args)
- )
+ putString(ARG_PREVIEW, preview.name)
+ putString(ARG_ARGS, ApprovalPresentation.format(preview, request.args, resources))
+ putBoolean(ARG_ALLOWS_ALWAYS_ALLOW, request.allowsSessionApproval)
}
}
}
@@ -94,7 +94,9 @@ class ApprovalDialogFragment : DialogFragment() {
val source = arguments?.getString(ARG_SOURCE)
val description = arguments?.getString(ARG_DESCRIPTION) ?: ""
val argsText = arguments?.getString(ARG_ARGS) ?: "{}"
- val isEdit = arguments?.getBoolean(ARG_IS_EDIT) == true
+ val preview = ApprovalPresentation.named(arguments?.getString(ARG_PREVIEW))
+ val isEdit = preview == ApprovalPreview.EDIT
+ val allowsAlwaysAllow = arguments?.getBoolean(ARG_ALLOWS_ALWAYS_ALLOW) ?: false
val message = buildString {
append(getString(R.string.approval_header))
@@ -114,7 +116,7 @@ class ApprovalDialogFragment : DialogFragment() {
}
append(description)
append("\n\n")
- append(getString(if (isEdit) R.string.approval_proposed_change else R.string.approval_args))
+ append(getString(ApprovalPresentation.argsLabel(preview)))
append("\n")
append(argsText)
}
@@ -142,9 +144,11 @@ class ApprovalDialogFragment : DialogFragment() {
decide(ApprovalResult.APPROVED_ONCE)
dismiss()
}
- builder.setNeutralButton(getString(R.string.approval_always_allow)) { _, _ ->
- decide(ApprovalResult.APPROVED_FOR_SESSION)
- dismiss()
+ if (allowsAlwaysAllow) {
+ builder.setNeutralButton(getString(R.string.approval_always_allow)) { _, _ ->
+ decide(ApprovalResult.APPROVED_FOR_SESSION)
+ dismiss()
+ }
}
}
@@ -160,8 +164,11 @@ class ApprovalDialogFragment : DialogFragment() {
// Long-press help on the consent gate: which button actually writes to the project.
wireTooltip(
dialog.getButton(Dialog.BUTTON_POSITIVE),
- if (isEdit) AiCorePlugin.TOOLTIP_TAG_APPROVAL_ACCEPT
- else AiCorePlugin.TOOLTIP_TAG_APPROVAL_RUN_NOW,
+ when (preview) {
+ ApprovalPreview.EDIT -> AiCorePlugin.TOOLTIP_TAG_APPROVAL_ACCEPT
+ ApprovalPreview.SHELL_COMMAND -> AiCorePlugin.TOOLTIP_TAG_APPROVAL_RUN_COMMAND
+ ApprovalPreview.ARGS -> AiCorePlugin.TOOLTIP_TAG_APPROVAL_RUN_NOW
+ },
)
wireTooltip(
dialog.getButton(Dialog.BUTTON_NEUTRAL),
diff --git a/plugins/AI-Core/src/main/kotlin/com/itsaky/androidide/plugins/aicore/fragments/ApprovalPresentation.kt b/plugins/AI-Core/src/main/kotlin/com/itsaky/androidide/plugins/aicore/fragments/ApprovalPresentation.kt
new file mode 100644
index 00000000..34e04743
--- /dev/null
+++ b/plugins/AI-Core/src/main/kotlin/com/itsaky/androidide/plugins/aicore/fragments/ApprovalPresentation.kt
@@ -0,0 +1,35 @@
+package com.itsaky.androidide.plugins.aicore.fragments
+
+import android.content.res.Resources
+import androidx.annotation.StringRes
+import com.itsaky.androidide.plugins.aicore.R
+import com.itsaky.androidide.plugins.aicore.tool.ApprovalPreview
+
+/**
+ * How [ApprovalDialogFragment] presents each [ApprovalPreview] a tool's handler declares. Exhaustive
+ * `when`s with no `else`, so a new preview fails to compile until it has a label and a formatter.
+ */
+internal object ApprovalPresentation {
+
+ /** The heading above the formatted arguments of a [preview] call. */
+ @StringRes
+ fun argsLabel(preview: ApprovalPreview): Int = when (preview) {
+ ApprovalPreview.EDIT -> R.string.approval_proposed_change
+ ApprovalPreview.SHELL_COMMAND -> R.string.approval_command
+ ApprovalPreview.ARGS -> R.string.approval_args
+ }
+
+ /** The call's arguments as the dialog shows them; [resources] words any label among them. */
+ fun format(preview: ApprovalPreview, args: Map, resources: Resources): String =
+ when (preview) {
+ ApprovalPreview.EDIT -> ApprovalTextFormatter.formatEdit(args)
+ ApprovalPreview.SHELL_COMMAND -> ApprovalTextFormatter.formatShellCommand(args) { directory ->
+ resources.getString(R.string.approval_working_directory, directory)
+ }
+ ApprovalPreview.ARGS -> ApprovalTextFormatter.formatArgs(args)
+ }
+
+ /** The preview saved as [name] in the dialog's arguments; [ApprovalPreview.ARGS] when it is missing or unknown. */
+ fun named(name: String?): ApprovalPreview =
+ ApprovalPreview.entries.firstOrNull { it.name == name } ?: ApprovalPreview.ARGS
+}
diff --git a/plugins/AI-Core/src/main/kotlin/com/itsaky/androidide/plugins/aicore/fragments/ApprovalTextFormatter.kt b/plugins/AI-Core/src/main/kotlin/com/itsaky/androidide/plugins/aicore/fragments/ApprovalTextFormatter.kt
index 32033324..c848bd66 100644
--- a/plugins/AI-Core/src/main/kotlin/com/itsaky/androidide/plugins/aicore/fragments/ApprovalTextFormatter.kt
+++ b/plugins/AI-Core/src/main/kotlin/com/itsaky/androidide/plugins/aicore/fragments/ApprovalTextFormatter.kt
@@ -1,6 +1,7 @@
package com.itsaky.androidide.plugins.aicore.fragments
import com.itsaky.androidide.plugins.aicore.tool.handlers.EditFileHandler
+import com.itsaky.androidide.plugins.aicore.tool.handlers.ShellInvocation
import com.itsaky.androidide.plugins.aicore.tool.parseToolBoolean
import org.json.JSONObject
@@ -17,6 +18,17 @@ object ApprovalTextFormatter {
/** Per-side cap in the edit preview; long enough for a real hunk, short enough to read. */
private const val MAX_SNIPPET_CHARS = 600
+ /** Unicode categories that draw nothing: bidi overrides and zero-width marks, line and paragraph breaks. */
+ private val HIDDEN_TYPES = setOf(
+ Character.FORMAT,
+ Character.LINE_SEPARATOR,
+ Character.PARAGRAPH_SEPARATOR,
+ )
+
+ private const val HIDDEN_CHARACTERS_WARNING =
+ "⚠ This command contains invisible or control characters, shown as . " +
+ "They will be sent to the shell as they are. Decline unless you know why they are there."
+
/**
* Renders the proposed edit as a diff-style before/after block.
* @param args the `edit_file` call arguments.
@@ -47,6 +59,41 @@ object ApprovalTextFormatter {
}
}
+ /**
+ * Renders a shell command uncut, since a hidden line would still run, with every invisible or
+ * control character spelled as `` so the text read is the text run. A working directory,
+ * when given, goes above the command as a label.
+ * @param args the `run_shell_command` call arguments.
+ * @param directoryLabel words the line naming the working directory.
+ * @return the command text.
+ */
+ fun formatShellCommand(args: Map, directoryLabel: (String) -> String): String {
+ val invocation = ShellInvocation.from(args) ?: return ""
+ val command = revealHidden(invocation.command)
+ val directory = invocation.workingDirectory?.let(::revealHidden)
+ val shown = directory?.let { "${directoryLabel(it)}\n\n$command" } ?: command
+ if (command == invocation.command && directory == invocation.workingDirectory) return shown
+ return "$shown\n\n$HIDDEN_CHARACTERS_WARNING"
+ }
+
+ /**
+ * Spells out each code point that draws nothing or moves the cursor: `\r`, ESC and other
+ * controls, bidi overrides, zero-width marks. Newlines and tabs stay, as they show as themselves.
+ * @param text the model-written text.
+ * @return [text], with each such code point replaced by ``.
+ */
+ internal fun revealHidden(text: String): String = buildString {
+ text.codePoints().forEach { codePoint ->
+ if (isHidden(codePoint)) append("".format(codePoint)) else appendCodePoint(codePoint)
+ }
+ }
+
+ private fun isHidden(codePoint: Int): Boolean = when {
+ codePoint == '\n'.code || codePoint == '\t'.code -> false
+ Character.isISOControl(codePoint) -> true
+ else -> Character.getType(codePoint).toByte() in HIDDEN_TYPES
+ }
+
/**
* Renders any other tool call's arguments as pretty-printed JSON.
* @param args the call arguments.
diff --git a/plugins/AI-Core/src/main/kotlin/com/itsaky/androidide/plugins/aicore/fragments/ChatFragment.kt b/plugins/AI-Core/src/main/kotlin/com/itsaky/androidide/plugins/aicore/fragments/ChatFragment.kt
index 4abea704..685e815b 100644
--- a/plugins/AI-Core/src/main/kotlin/com/itsaky/androidide/plugins/aicore/fragments/ChatFragment.kt
+++ b/plugins/AI-Core/src/main/kotlin/com/itsaky/androidide/plugins/aicore/fragments/ChatFragment.kt
@@ -941,7 +941,7 @@ class ChatFragment : Fragment(), ApprovalDialogFragment.Host {
// childFragmentManager makes this fragment the parent, which is how Host is resolved.
if (currentApprovalDialog() != null) return
AgentTrace.detail("UI", "approval dialog shown tool=${request.toolName}")
- ApprovalDialogFragment.newInstance(request).show(childFragmentManager, APPROVAL_DIALOG_TAG)
+ ApprovalDialogFragment.newInstance(request, resources).show(childFragmentManager, APPROVAL_DIALOG_TAG)
}
override fun onApprovalDecision(
diff --git a/plugins/AI-Core/src/main/kotlin/com/itsaky/androidide/plugins/aicore/plugin/AiCorePlugin.kt b/plugins/AI-Core/src/main/kotlin/com/itsaky/androidide/plugins/aicore/plugin/AiCorePlugin.kt
index ca836293..a6e95a1a 100644
--- a/plugins/AI-Core/src/main/kotlin/com/itsaky/androidide/plugins/aicore/plugin/AiCorePlugin.kt
+++ b/plugins/AI-Core/src/main/kotlin/com/itsaky/androidide/plugins/aicore/plugin/AiCorePlugin.kt
@@ -76,6 +76,8 @@ class AiCorePlugin : IPlugin, UIExtension, DocumentationExtension, SettingsExten
const val TOOLTIP_TAG_APPROVAL_DECLINE = "agent_approval_decline"
const val TOOLTIP_TAG_APPROVAL_CORRECTION_INPUT = "agent_approval_correction_input"
const val TOOLTIP_TAG_APPROVAL_RUN_NOW = "agent_approval_run_now"
+ /** Run Now under a shell command, which is shown uncut and is never granted for the session. */
+ const val TOOLTIP_TAG_APPROVAL_RUN_COMMAND = "agent_approval_run_command"
const val TOOLTIP_TAG_APPROVAL_ALWAYS_ALLOW = "agent_approval_always_allow"
// Tags for the controls rendered inside chat messages (see ChatAdapter).
@@ -613,7 +615,24 @@ class AiCorePlugin : IPlugin, UIExtension, DocumentationExtension, SettingsExten
shortened for readability, so a long path or snippet may be cut —
the full value is what actually runs.
You'll be asked again the next time this tool is used, unless
- you choose Always Allow.
+ you choose Always Allow where the dialog offers it.
+ """.trimIndent(),
+ buttons = listOf(
+ PluginTooltipButton(description = "AI Core Agent guide", uri = "index.html", order = 0)
+ )
+ ),
+ PluginTooltipEntry(
+ tag = TOOLTIP_TAG_APPROVAL_RUN_COMMAND,
+ summary = "Run this shell command once, exactly as shown above.",
+ detail = """
+ Runs the command in a Terminal session, in the project folder
+ unless a working directory is named above it. The command is shown
+ in full, never shortened: what you read is what runs. Invisible or
+ control characters are shown as <U+XXXX> codes so
+ they cannot hide part of it.
+ A shell command can change or delete any file in your project,
+ so read it before you tap. You'll be asked again for every command;
+ there is no Always Allow for the shell.
""".trimIndent(),
buttons = listOf(
PluginTooltipButton(description = "AI Core Agent guide", uri = "index.html", order = 0)
diff --git a/plugins/AI-Core/src/main/kotlin/com/itsaky/androidide/plugins/aicore/prompt/ToolResultsPrompt.kt b/plugins/AI-Core/src/main/kotlin/com/itsaky/androidide/plugins/aicore/prompt/ToolResultsPrompt.kt
index 9bf458b7..1102743a 100644
--- a/plugins/AI-Core/src/main/kotlin/com/itsaky/androidide/plugins/aicore/prompt/ToolResultsPrompt.kt
+++ b/plugins/AI-Core/src/main/kotlin/com/itsaky/androidide/plugins/aicore/prompt/ToolResultsPrompt.kt
@@ -6,6 +6,10 @@ import com.itsaky.androidide.plugins.aicore.models.ToolResult
import com.itsaky.androidide.plugins.aicore.prompt.config.AgentPromptConfig
import com.itsaky.androidide.plugins.aicore.tool.ToolCall
import com.itsaky.androidide.plugins.aicore.tool.ToolResultsFormatter
+import com.itsaky.androidide.plugins.aicore.tool.handlers.ReadTerminalCommandHandler
+import com.itsaky.androidide.plugins.aicore.tool.handlers.RunShellCommandHandler
+import com.itsaky.androidide.plugins.aicore.tool.handlers.StopTerminalCommandHandler
+import com.itsaky.androidide.plugins.aicore.tool.handlers.TerminalOutput
import com.itsaky.androidide.plugins.aicore.tool.web.WebAccess
/**
@@ -42,12 +46,27 @@ class ToolResultsPrompt(
*/
const val WEB_SEARCH_CHAR_LIMIT = 12000
+ /**
+ * A shell result's cap: the output tail [TerminalOutput] keeps, plus room for the message and
+ * the still-running note. [DEFAULT_CHAR_LIMIT] keeps the start, so it would cut the error away.
+ */
+ const val SHELL_CHAR_LIMIT = TerminalOutput.MAX_CHARS + 1000
+
+ /** The tools whose result needs more than [DEFAULT_CHAR_LIMIT], with the least each needs. */
+ private val MIN_CHAR_LIMITS = mapOf(
+ WebAccess.WEB_SEARCH_TOOL to WEB_SEARCH_CHAR_LIMIT,
+ WebAccess.FETCH_URL_TOOL to WEB_SEARCH_CHAR_LIMIT,
+ RunShellCommandHandler.TOOL_NAME to SHELL_CHAR_LIMIT,
+ ReadTerminalCommandHandler.TOOL_NAME to SHELL_CHAR_LIMIT,
+ StopTerminalCommandHandler.TOOL_NAME to SHELL_CHAR_LIMIT,
+ )
+
/**
* Renders one batch's turn. Pure and thread-safe.
*
* @param config the loaded prompt config.
* @param terminalTool the name of the tool that ends a run by answering the user.
- * @param charLimit each result's cap; a web search's or fetch's is at least [WEB_SEARCH_CHAR_LIMIT].
+ * @param charLimit each result's cap; a tool in [MIN_CHAR_LIMITS] gets at least its own.
* @param calls the tool calls that ran.
* @param results their results, positionally aligned with [calls].
* @return the turn to add to the transcript.
@@ -62,8 +81,7 @@ class ToolResultsPrompt(
val responses = buildString {
results.forEachIndexed { index, result ->
val name = calls.getOrNull(index)?.name ?: "tool"
- val limit =
- if (name == WebAccess.WEB_SEARCH_TOOL || name == WebAccess.FETCH_URL_TOOL) maxOf(charLimit, WEB_SEARCH_CHAR_LIMIT) else charLimit
+ val limit = maxOf(charLimit, MIN_CHAR_LIMITS[name] ?: 0)
val body = truncate(config, terminalTool, body(config, terminalTool, result), limit)
append("\n[").append(name).append("] ").append(body)
append("\n\n\n")
diff --git a/plugins/AI-Core/src/main/kotlin/com/itsaky/androidide/plugins/aicore/tool/ApprovalPreview.kt b/plugins/AI-Core/src/main/kotlin/com/itsaky/androidide/plugins/aicore/tool/ApprovalPreview.kt
new file mode 100644
index 00000000..c1b12edb
--- /dev/null
+++ b/plugins/AI-Core/src/main/kotlin/com/itsaky/androidide/plugins/aicore/tool/ApprovalPreview.kt
@@ -0,0 +1,16 @@
+package com.itsaky.androidide.plugins.aicore.tool
+
+/**
+ * How the approval dialog shows a pending call's arguments, declared by the tool's handler so the
+ * dialog never matches tool names.
+ */
+enum class ApprovalPreview {
+ /** The arguments as JSON, each value cut short. */
+ ARGS,
+
+ /** A diff-style before/after of one edit. */
+ EDIT,
+
+ /** The whole shell command, uncut, under the directory it runs in. */
+ SHELL_COMMAND,
+}
diff --git a/plugins/AI-Core/src/main/kotlin/com/itsaky/androidide/plugins/aicore/tool/ToolApprovalManager.kt b/plugins/AI-Core/src/main/kotlin/com/itsaky/androidide/plugins/aicore/tool/ToolApprovalManager.kt
index 51c2ca64..de68ef81 100644
--- a/plugins/AI-Core/src/main/kotlin/com/itsaky/androidide/plugins/aicore/tool/ToolApprovalManager.kt
+++ b/plugins/AI-Core/src/main/kotlin/com/itsaky/androidide/plugins/aicore/tool/ToolApprovalManager.kt
@@ -103,7 +103,9 @@ class ToolApprovalManager(
displayName = handler.displayName,
sourceLabel = handler.sourceLabel,
args = args,
- description = describe(handler)
+ description = describe(handler),
+ preview = handler.approvalPreview,
+ allowsSessionApproval = !isNeverSessionApproved(toolName, handler),
)
val deferred = CompletableDeferred()
@@ -234,7 +236,9 @@ data class ApprovalRequest(
val args: Map,
val description: String,
val displayName: String = toolName,
- val sourceLabel: String? = null
+ val sourceLabel: String? = null,
+ val preview: ApprovalPreview = ApprovalPreview.ARGS,
+ val allowsSessionApproval: Boolean = true,
)
/**
diff --git a/plugins/AI-Core/src/main/kotlin/com/itsaky/androidide/plugins/aicore/tool/ToolArgs.kt b/plugins/AI-Core/src/main/kotlin/com/itsaky/androidide/plugins/aicore/tool/ToolArgs.kt
index 8fa03dc0..5aa9a374 100644
--- a/plugins/AI-Core/src/main/kotlin/com/itsaky/androidide/plugins/aicore/tool/ToolArgs.kt
+++ b/plugins/AI-Core/src/main/kotlin/com/itsaky/androidide/plugins/aicore/tool/ToolArgs.kt
@@ -40,14 +40,14 @@ fun normalizeToolArgs(handler: ToolHandler, args: Map): Map): Set {
val normalized = normalizeToolArgs(this, args)
- return pathArgs.mapNotNullTo(mutableSetOf()) { key ->
+ return trackedPathArgs.mapNotNullTo(mutableSetOf()) { key ->
(normalized[key]?.toString() ?: pathDefaults[key])?.trim()?.takeIf { it.isNotEmpty() }
}
}
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 fc6697b6..8aea96ff 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,7 @@ 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, or successful log re-reads, tolerated
+ * @param maxConsecutiveRepeats identical unsuccessful batches, or live re-reads answering alike, 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.
@@ -47,10 +47,15 @@ internal class ToolCallProgressGuard(
// file back and forth still runs out of novelty.
private val changingSignatures = mutableSetOf()
private var currentBatchPaths = emptySet()
- private var currentBatchWrites = emptySet()
+ // Null when a changing call names no path, so nothing narrows which reads it invalidates.
+ private var currentBatchWrites: Set? = emptySet()
private var currentBatchChanges = false
private var currentBatchIsNew = false
private var currentBatchRereadsLive = false
+ private var currentSignature = ""
+
+ // What each live re-read last answered, so a poll whose answer moved counts as progress.
+ private val liveAnswers = mutableMapOf>()
// Null until a batch has run: "no tools yet" and "the tools failed" end a run differently.
private var previousBatchSucceeded: Boolean? = null
@@ -68,8 +73,10 @@ internal class ToolCallProgressGuard(
*/
fun inspect(calls: List): Verdict {
val signature = signatureOf(calls)
+ currentSignature = signature
currentBatchPaths = pathsNamedBy(calls)
- currentBatchWrites = pathsNamedBy(calls.filter(changesPaths))
+ val writes = calls.filter(changesPaths).map { pathsNamedBy(listOf(it)) }
+ currentBatchWrites = writes.takeIf { it.none(Set::isEmpty) }?.flatten()?.toSet()
currentBatchChanges = calls.any(changesPaths)
currentBatchRereadsLive = calls.all { it.name in LIVE_READS }
val verdict = verdictFor(signature)
@@ -86,14 +93,22 @@ internal class ToolCallProgressGuard(
*/
fun recordResults(results: List) {
previousBatchSucceeded = results.isNotEmpty() && results.all { it.success }
+ // Polling a build that is still printing is watching it move, not going round in circles;
+ // an answer that stopped changing still counts toward both limits.
+ if (previousBatchSucceeded == true && currentBatchRereadsLive &&
+ liveAnswers.put(currentSignature, results) != results
+ ) {
+ consecutiveRepeats = 0
+ turnsWithoutNewSignature = 0
+ }
// A change the run had not made before makes an earlier look at the paths it rewrote a new
// action again, so a run that edits and then verifies is not judged as going in circles.
- // A change whose handler names no path invalidates every read: there is no way to tell
- // which ones it stood for.
+ // A change whose handler names no path, such as a shell command, invalidates every read: there
+ // is no way to tell which ones it stood for, even beside an edit that names one.
if (previousBatchSucceeded != true || !currentBatchIsNew || !currentBatchChanges) return
val invalidated = seenSignatures.filter { (seen, paths) ->
seen !in changingSignatures &&
- (currentBatchWrites.isEmpty() || paths.any { it in currentBatchWrites })
+ (currentBatchWrites?.let { writes -> paths.any { it in writes } } ?: true)
}
seenSignatures.keys.removeAll(invalidated.keys)
}
@@ -138,6 +153,6 @@ internal class ToolCallProgressGuard(
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")
+ val LIVE_READS = setOf("read_app_logs", "read_ide_logs", "read_terminal_command")
}
}
diff --git a/plugins/AI-Core/src/main/kotlin/com/itsaky/androidide/plugins/aicore/tool/ToolHandler.kt b/plugins/AI-Core/src/main/kotlin/com/itsaky/androidide/plugins/aicore/tool/ToolHandler.kt
index 8c68c870..bfa719a1 100644
--- a/plugins/AI-Core/src/main/kotlin/com/itsaky/androidide/plugins/aicore/tool/ToolHandler.kt
+++ b/plugins/AI-Core/src/main/kotlin/com/itsaky/androidide/plugins/aicore/tool/ToolHandler.kt
@@ -70,6 +70,10 @@ interface ToolHandler {
val allowsSessionApproval: Boolean
get() = true
+ /** How the approval dialog shows this tool's arguments. */
+ val approvalPreview: ApprovalPreview
+ get() = ApprovalPreview.ARGS
+
/**
* Arg keys whose values are filesystem paths. The Executor verifies each of
* these resolves within the project root before the tool runs, so no handler
@@ -81,6 +85,13 @@ interface ToolHandler {
val resolvesPathsInternally: Boolean
get() = false
+ /**
+ * The [pathArgs] keys naming what a call reads or writes, for the progress guard. All of them by
+ * default; a shell command's working directory is where it runs, not what it touches.
+ */
+ val trackedPathArgs: List
+ get() = pathArgs
+
/**
* Values a [pathArgs] key falls back to when the call omits it, keyed the same way. Declared
* here so the progress guard sees the file a defaulted write actually touches.
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 dd8fcdd5..d13595c6 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
@@ -38,7 +38,13 @@ object BuiltInToolHandlers {
RunAppHandler(context, hasLogTools = hostHasLogApi()),
GradleSyncHandler(context),
) + (if (hostHasGradleTaskApi()) listOf(RunGradleTaskHandler(context)) else emptyList()) +
- (if (hostHasTaskListApi()) listOf(ListGradleTasksHandler(context)) else emptyList()) + listOf(
+ (if (hostHasTaskListApi()) listOf(ListGradleTasksHandler(context)) else emptyList()) +
+ (if (hostHasTerminalService(context)) listOf(
+ RunShellCommandHandler(context),
+ ReadTerminalCommandHandler(context),
+ StopTerminalCommandHandler(context),
+ ) else emptyList()) +
+ listOf(
// Template tool
GenerateFromTemplateHandler(context),
// Web tools
@@ -57,6 +63,12 @@ object BuiltInToolHandlers {
// Same reason: a host from before IdeBuildService.getTasks lacks the class, and the call would throw.
private fun hostHasTaskListApi(): Boolean =
runCatching { Class.forName("com.itsaky.androidide.plugins.services.GradleTaskInfo") }.isSuccess
+
+ // Same reason for the string; a host can also ship the class (ADFA-6385) without registering it.
+ private fun hostHasTerminalService(context: PluginContext): Boolean =
+ runCatching {
+ context.services.get(Class.forName("com.itsaky.androidide.plugins.services.IdeTerminalService")) != null
+ }.getOrDefault(false)
}
/** The log tools, kept apart so [LogSource] is only touched on a host that has it. */
diff --git a/plugins/AI-Core/src/main/kotlin/com/itsaky/androidide/plugins/aicore/tool/handlers/EditFileHandler.kt b/plugins/AI-Core/src/main/kotlin/com/itsaky/androidide/plugins/aicore/tool/handlers/EditFileHandler.kt
index 55fde688..f5537013 100644
--- a/plugins/AI-Core/src/main/kotlin/com/itsaky/androidide/plugins/aicore/tool/handlers/EditFileHandler.kt
+++ b/plugins/AI-Core/src/main/kotlin/com/itsaky/androidide/plugins/aicore/tool/handlers/EditFileHandler.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.models.ToolResult
+import com.itsaky.androidide.plugins.aicore.tool.ApprovalPreview
import com.itsaky.androidide.plugins.aicore.tool.ToolHandler
import com.itsaky.androidide.plugins.aicore.tool.ToolSchema
import com.itsaky.androidide.plugins.aicore.tool.Validation
@@ -45,6 +46,7 @@ class EditFileHandler(
required = listOf(ARG_PATH, ARG_OLD, ARG_NEW),
)
override val requiresApproval = true
+ override val approvalPreview = ApprovalPreview.EDIT
override val mutatesProject = true
override val pathArgs = listOf(ARG_PATH)
override val argAliases = mapOf(
diff --git a/plugins/AI-Core/src/main/kotlin/com/itsaky/androidide/plugins/aicore/tool/handlers/HostServiceCall.kt b/plugins/AI-Core/src/main/kotlin/com/itsaky/androidide/plugins/aicore/tool/handlers/HostServiceCall.kt
new file mode 100644
index 00000000..1b8ee01b
--- /dev/null
+++ b/plugins/AI-Core/src/main/kotlin/com/itsaky/androidide/plugins/aicore/tool/handlers/HostServiceCall.kt
@@ -0,0 +1,61 @@
+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 kotlinx.coroutines.CancellationException
+
+/**
+ * What every handler backed by one host service shares around the call: finding the service, and
+ * turning a host error into a result the model reads. Stop still cancels the call.
+ *
+ * @param service the host service the tool calls.
+ * @param traceStage the [AgentTrace] stage the tool logs under.
+ * @param serviceLabel the service as the model reads it, e.g. "Build" or "Terminal".
+ * @param failureMessage the result message for an unexpected host error.
+ */
+internal class HostServiceCall(
+ private val pluginContext: PluginContext,
+ private val toolName: String,
+ private val service: Class,
+ private val traceStage: String,
+ private val serviceLabel: String,
+ private val failureMessage: (Exception) -> String = { "Error: ${it.javaClass.simpleName}" },
+) {
+ /** Runs [block] with the service, or fails when the host has none. */
+ suspend fun run(block: suspend (S) -> ToolResult): ToolResult = try {
+ pluginContext.services.get(service)
+ ?.let { host -> block(host) }
+ ?: unavailable()
+ } catch (ce: CancellationException) {
+ // An Exception on the JVM, so the catches below would report Stop as a tool failure.
+ throw ce
+ } catch (e: SecurityException) {
+ // A path outside the project, or the permission missing: the model can fix the first.
+ AgentTrace.refusal(traceStage, "$toolName refused", e.toString())
+ ToolResult.failure("Refused by the IDE: ${e.message ?: "not allowed"}")
+ } catch (e: Exception) {
+ AgentTrace.refusal(traceStage, "$toolName failed", e.toString())
+ pluginContext.logger.error("$toolName failed", e)
+ ToolResult.failure(
+ failureMessage(e),
+ "${e.message ?: "Unknown error"}\n\n${e.stackTraceToString()}"
+ )
+ }
+
+ private fun unavailable(): ToolResult {
+ AgentTrace.refusal(traceStage, "$toolName rejected", "${service.simpleName} not available")
+ return ToolResult.failure(
+ "$serviceLabel service not available",
+ "The IDE ${serviceLabel.lowercase()} service is not available."
+ )
+ }
+
+ companion object {
+ /** The [AgentTrace] stage the shell tools log under. */
+ const val SHELL_STAGE = "SHELL"
+
+ /** The [AgentTrace] stage the build tools log under. */
+ const val BUILD_STAGE = "BUILD"
+ }
+}
diff --git a/plugins/AI-Core/src/main/kotlin/com/itsaky/androidide/plugins/aicore/tool/handlers/ListGradleTasksHandler.kt b/plugins/AI-Core/src/main/kotlin/com/itsaky/androidide/plugins/aicore/tool/handlers/ListGradleTasksHandler.kt
index 2645ba83..bc11b003 100644
--- a/plugins/AI-Core/src/main/kotlin/com/itsaky/androidide/plugins/aicore/tool/handlers/ListGradleTasksHandler.kt
+++ b/plugins/AI-Core/src/main/kotlin/com/itsaky/androidide/plugins/aicore/tool/handlers/ListGradleTasksHandler.kt
@@ -5,20 +5,25 @@ 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.aicore.tool.handlers.HostServiceCall.Companion.BUILD_STAGE
import com.itsaky.androidide.plugins.services.GradleTaskInfo
import com.itsaky.androidide.plugins.services.IdeBuildService
-import kotlinx.coroutines.CancellationException
/**
* Handler for listing the project's Gradle tasks with their group and description, from the IDE's
* last sync, so the agent picks a task that exists before it calls run_gradle_task.
*/
class ListGradleTasksHandler(
- private val pluginContext: PluginContext,
+ pluginContext: PluginContext,
) : ToolHandler {
override val toolName = "list_gradle_tasks"
override val requiresApproval = false
+ private val buildCall = HostServiceCall(
+ pluginContext, toolName, IdeBuildService::class.java, BUILD_STAGE, "Build",
+ failureMessage = { "Error listing Gradle tasks" },
+ )
+
override val parametersSchema = ToolSchema.objectOf(
"filter" to ToolSchema.string(),
)
@@ -32,28 +37,10 @@ class ListGradleTasksHandler(
override suspend fun execute(args: Map): ToolResult {
val filter = (args["filter"] as? String)?.trim().orEmpty()
- return try {
- val buildService = pluginContext.services.get(IdeBuildService::class.java)
- if (buildService == null) {
- AgentTrace.refusal("BUILD", "$toolName rejected", "IdeBuildService not available")
- return ToolResult.failure(
- "Build service not available",
- "The IDE build service is not available."
- )
- }
+ return buildCall.run { buildService ->
val tasks = buildService.getTasks()
- AgentTrace.detail("BUILD", "$toolName filter='$filter' hostTasks=${tasks.size}")
+ AgentTrace.detail(BUILD_STAGE, "$toolName filter='$filter' hostTasks=${tasks.size}")
resultFor(tasks, filter)
- } catch (ce: CancellationException) {
- // An Exception on the JVM, so the catch below would report Stop as a listing failure.
- throw ce
- } catch (e: Exception) {
- AgentTrace.refusal("BUILD", "$toolName failed", e.toString())
- pluginContext.logger.error("$toolName failed", e)
- ToolResult.failure(
- "Error listing Gradle tasks",
- "${e.message ?: "Unknown error"}\n\n${e.stackTraceToString()}"
- )
}
}
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 054f971a..d98f3651 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
@@ -4,36 +4,28 @@ 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.handlers.HostServiceCall.Companion.BUILD_STAGE
import com.itsaky.androidide.plugins.services.IdeBuildService
-import kotlinx.coroutines.CancellationException
/**
* Handler for reading the current build output.
*/
class ReadBuildOutputHandler(
- private val pluginContext: PluginContext
+ pluginContext: PluginContext
) : ToolHandler {
override val toolName = "read_build_output"
override val requiresApproval = false
- override suspend fun execute(args: Map): ToolResult {
- return try {
- val buildService = pluginContext.services.get(IdeBuildService::class.java)
- if (buildService == null) {
- AgentTrace.refusal(
- "BUILD",
- "read_build_output rejected",
- "IdeBuildService not available"
- )
- return ToolResult.failure(
- "Build service not available",
- "The IDE build service is not available."
- )
- }
+ private val buildCall = HostServiceCall(
+ pluginContext, toolName, IdeBuildService::class.java, BUILD_STAGE, "Build",
+ failureMessage = { "Error reading build output" },
+ )
+ override suspend fun execute(args: Map): ToolResult {
+ return buildCall.run { buildService ->
val output = buildService.getBuildOutput()
if (output.isNullOrBlank()) {
- AgentTrace.detail("BUILD", "read_build_output chars=0 (host returned nothing)")
+ AgentTrace.detail(BUILD_STAGE, "$toolName chars=0 (host returned nothing)")
ToolResult.success(
message = "No build output available",
data = "(No recent build output)"
@@ -41,8 +33,8 @@ class ReadBuildOutputHandler(
} else {
val window = windowFor(output)
AgentTrace.detail(
- "BUILD",
- "read_build_output chars=${window.text.length} " +
+ BUILD_STAGE,
+ "$toolName chars=${window.text.length} " +
"anchoredOnError=${window.anchoredOnError} hostChars=${output.length}"
)
ToolResult.success(
@@ -54,18 +46,6 @@ class ReadBuildOutputHandler(
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) {
- // The trace stream keeps the run readable; the host log keeps the stack trace, which
- // AgentTrace previews only in a debug build.
- AgentTrace.refusal("BUILD", "read_build_output failed", e.toString())
- pluginContext.logger.error("read_build_output failed", e)
- ToolResult.failure(
- "Error reading build output",
- "${e.message ?: "Unknown error"}\n\n${e.stackTraceToString()}"
- )
}
}
diff --git a/plugins/AI-Core/src/main/kotlin/com/itsaky/androidide/plugins/aicore/tool/handlers/ReadTerminalCommandHandler.kt b/plugins/AI-Core/src/main/kotlin/com/itsaky/androidide/plugins/aicore/tool/handlers/ReadTerminalCommandHandler.kt
new file mode 100644
index 00000000..88dabae4
--- /dev/null
+++ b/plugins/AI-Core/src/main/kotlin/com/itsaky/androidide/plugins/aicore/tool/handlers/ReadTerminalCommandHandler.kt
@@ -0,0 +1,37 @@
+package com.itsaky.androidide.plugins.aicore.tool.handlers
+
+import com.itsaky.androidide.plugins.PluginContext
+import com.itsaky.androidide.plugins.aicore.models.ToolResult
+import com.itsaky.androidide.plugins.services.IdeTerminalService
+import com.itsaky.androidide.plugins.services.TerminalCommandResult
+
+/**
+ * Handler for checking on a command [RunShellCommandHandler] left running, such as a dev server:
+ * whether it still runs, its exit code once it stopped, and its latest output. The host only reads
+ * the agent's own commands, never the user's.
+ */
+class ReadTerminalCommandHandler(
+ pluginContext: PluginContext,
+) : TerminalCommandHandler(pluginContext, TOOL_NAME) {
+
+ // Reads output of a command the user already approved; changes nothing.
+ override val requiresApproval = false
+
+ override suspend fun callHost(terminal: IdeTerminalService, commandId: String) =
+ terminal.readCommand(commandId)
+
+ override fun running(commandId: String, running: TerminalCommandResult.Running) = ToolResult.success(
+ message = "Command \"$commandId\" is still running in Terminal session \"${running.sessionName}\"",
+ data = TerminalOutput.tailOf(running.output)
+ )
+
+ // Any exit code succeeds, as in RunShellCommandHandler: the model reads what the code means.
+ override fun completed(commandId: String, completed: TerminalCommandResult.Completed) = ToolResult.success(
+ message = "Command \"$commandId\" ${TerminalOutput.exitOf(completed.exitCode)}",
+ data = TerminalOutput.tailOf(completed.output)
+ )
+
+ companion object {
+ const val TOOL_NAME = "read_terminal_command"
+ }
+}
diff --git a/plugins/AI-Core/src/main/kotlin/com/itsaky/androidide/plugins/aicore/tool/handlers/RunGradleTaskHandler.kt b/plugins/AI-Core/src/main/kotlin/com/itsaky/androidide/plugins/aicore/tool/handlers/RunGradleTaskHandler.kt
index 42e8ae8c..f0ee8515 100644
--- a/plugins/AI-Core/src/main/kotlin/com/itsaky/androidide/plugins/aicore/tool/handlers/RunGradleTaskHandler.kt
+++ b/plugins/AI-Core/src/main/kotlin/com/itsaky/androidide/plugins/aicore/tool/handlers/RunGradleTaskHandler.kt
@@ -6,6 +6,7 @@ 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.aicore.tool.Validation
+import com.itsaky.androidide.plugins.aicore.tool.handlers.HostServiceCall.Companion.BUILD_STAGE
import com.itsaky.androidide.plugins.services.GradleTaskResult
import com.itsaky.androidide.plugins.services.IdeBuildService
import kotlinx.coroutines.CancellationException
@@ -25,10 +26,14 @@ import kotlin.coroutines.resumeWithException
* the slice worth reading comes back with the result, so a test run reports its failures at once.
*/
class RunGradleTaskHandler(
- private val pluginContext: PluginContext,
+ pluginContext: PluginContext,
) : ToolHandler {
override val toolName = "run_gradle_task"
+ private val buildCall = HostServiceCall(
+ pluginContext, toolName, IdeBuildService::class.java, BUILD_STAGE, "Build",
+ )
+
// Starts a real build, like run_app and gradle_sync, and the arguments are the model's choice.
override val requiresApproval = true
@@ -58,17 +63,8 @@ class RunGradleTaskHandler(
if (invocation.tasks.isEmpty()) return NO_TASKS
val label = invocation.tasks.joinToString(" ")
- return try {
- val buildService = pluginContext.services.get(IdeBuildService::class.java)
- if (buildService == null) {
- AgentTrace.refusal("BUILD", "$toolName rejected", "IdeBuildService not available")
- return ToolResult.failure(
- "Build service not available",
- "The IDE build service is not available."
- )
- }
-
- AgentTrace.stage("BUILD", "$toolName tasks=$label args=${invocation.arguments}")
+ return buildCall.run { buildService ->
+ AgentTrace.stage(BUILD_STAGE, "$toolName tasks=$label args=${invocation.arguments}")
val startMs = System.currentTimeMillis()
val future = buildService.executeTasks(invocation.tasks, invocation.arguments)
val outcome = try {
@@ -85,7 +81,7 @@ class RunGradleTaskHandler(
} catch (ce: CancellationException) {
// Stop pressed while the build runs: the agent started it, so the agent ends it.
if (!future.isDone) {
- AgentTrace.stage("BUILD", "$toolName stopped; cancelling the build")
+ AgentTrace.stage(BUILD_STAGE, "$toolName stopped; cancelling the build")
buildService.cancelBuild()
}
throw ce
@@ -94,29 +90,19 @@ class RunGradleTaskHandler(
if (outcome == null) {
AgentTrace.refusal(
- "BUILD",
+ BUILD_STAGE,
"$toolName timed out waitedMs=$waitedMs",
"no result within ${BUILD_TIMEOUT_MS / 1000}s; the build may still be running"
)
- return ToolResult.failure(
+ return@run ToolResult.failure(
"Gradle task still running",
"$label did not finish within 10 minutes and may still be running. " +
"Call read_build_output to see how far it got."
)
}
- AgentTrace.stage("BUILD", "$toolName outcome=$outcome waitedMs=$waitedMs")
+ AgentTrace.stage(BUILD_STAGE, "$toolName outcome=$outcome waitedMs=$waitedMs")
resultFor(label, outcome, buildService.getBuildOutput())
- } catch (ce: CancellationException) {
- // An Exception on the JVM, so the catch below would report Stop as a task failure.
- throw ce
- } catch (e: Exception) {
- AgentTrace.refusal("BUILD", "$toolName failed", e.toString())
- pluginContext.logger.error("$toolName failed", e)
- ToolResult.failure(
- "Error: ${e.javaClass.simpleName}",
- "${e.message ?: "Unknown error"}\n\n${e.stackTraceToString()}"
- )
}
}
@@ -125,7 +111,7 @@ class RunGradleTaskHandler(
while (true) {
delay(BUILD_PROGRESS_LOG_INTERVAL_MS)
val seconds = (System.currentTimeMillis() - startMs) / 1000
- AgentTrace.detail("BUILD", "$toolName still waiting elapsed=${seconds}s")
+ AgentTrace.detail(BUILD_STAGE, "$toolName still waiting elapsed=${seconds}s")
}
}
diff --git a/plugins/AI-Core/src/main/kotlin/com/itsaky/androidide/plugins/aicore/tool/handlers/RunShellCommandHandler.kt b/plugins/AI-Core/src/main/kotlin/com/itsaky/androidide/plugins/aicore/tool/handlers/RunShellCommandHandler.kt
new file mode 100644
index 00000000..fb8cacfe
--- /dev/null
+++ b/plugins/AI-Core/src/main/kotlin/com/itsaky/androidide/plugins/aicore/tool/handlers/RunShellCommandHandler.kt
@@ -0,0 +1,122 @@
+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.ApprovalPreview
+import com.itsaky.androidide.plugins.aicore.tool.ToolHandler
+import com.itsaky.androidide.plugins.aicore.tool.ToolSchema
+import com.itsaky.androidide.plugins.aicore.tool.Validation
+import com.itsaky.androidide.plugins.aicore.tool.handlers.HostServiceCall.Companion.SHELL_STAGE
+import com.itsaky.androidide.plugins.services.IdeTerminalService
+import com.itsaky.androidide.plugins.services.TerminalCommandResult
+import kotlin.time.measureTimedValue
+
+/**
+ * Handler for running a shell command or script with bash in the IDE's visible Terminal, so the
+ * user sees what ran. The host reuses an idle session and opens another only while one is busy,
+ * e.g. with a dev server. Stop interrupts the command; Android's sandbox bounds what it can reach.
+ */
+class RunShellCommandHandler(
+ pluginContext: PluginContext,
+) : ToolHandler {
+ override val toolName = TOOL_NAME
+
+ // Runs whatever the model wrote, so the user reads the command before it runs.
+ override val requiresApproval = true
+ override val approvalPreview = ApprovalPreview.SHELL_COMMAND
+
+ // Approval is keyed by tool name, so a session grant would cover every later command.
+ override val allowsSessionApproval = false
+
+ // sed -i, rm or a script can rewrite any project file, so a run with one is no longer read-only.
+ override val mutatesProject = true
+
+ // Checked against the project root before the user is asked, so a doomed call costs no dialog.
+ override val pathArgs = listOf(ARG_WORKING_DIRECTORY)
+
+ // Where the command runs is not what it writes, so the progress guard treats every file as touched.
+ override val trackedPathArgs = emptyList()
+
+ override val parametersSchema = ToolSchema.objectOf(
+ ARG_COMMAND to ToolSchema.string(),
+ ARG_WORKING_DIRECTORY to ToolSchema.string(),
+ required = listOf(ARG_COMMAND),
+ )
+
+ override val argAliases = mapOf(
+ "cmd" to ARG_COMMAND,
+ "script" to ARG_COMMAND,
+ "shell_command" to ARG_COMMAND,
+ "cwd" to ARG_WORKING_DIRECTORY,
+ "directory" to ARG_WORKING_DIRECTORY,
+ "working_dir" to ARG_WORKING_DIRECTORY,
+ )
+
+ private val terminalCall = HostServiceCall(
+ pluginContext, toolName, IdeTerminalService::class.java, SHELL_STAGE, "Terminal",
+ )
+
+ override suspend fun validate(args: Map): Validation =
+ ShellInvocation.from(args)?.let { Validation.Accepted(args) } ?: Validation.Rejected(NO_COMMAND)
+
+ override suspend fun execute(args: Map): ToolResult {
+ val invocation = ShellInvocation.from(args) ?: return NO_COMMAND
+
+ return terminalCall.run { terminal ->
+ AgentTrace.stage(SHELL_STAGE, "$toolName cwd=${invocation.workingDirectory ?: ""}")
+ // No timeout here: the host returns Running once its wait ends. Stop sends Ctrl-C.
+ val (outcome, waited) = measureTimedValue {
+ terminal.runInTerminal(invocation.command, invocation.workingDirectory)
+ }
+ AgentTrace.stage(
+ SHELL_STAGE,
+ "$toolName outcome=${outcome::class.simpleName} waitedMs=${waited.inWholeMilliseconds}"
+ )
+ resultFor(outcome)
+ }
+ }
+
+ companion object {
+ const val TOOL_NAME = "run_shell_command"
+ const val ARG_COMMAND = "command"
+ const val ARG_WORKING_DIRECTORY = "working_directory"
+
+ // Naming the stop tool as a next step made Gemini stop a ping the user had just started.
+ private const val STILL_RUNNING_NOTE =
+ "The command has not exited and keeps running, e.g. a server, a watch task or a ping; " +
+ "the output below is what it printed so far. That is expected and the command " +
+ "succeeded: report this output. Do not run it again, and leave it running unless " +
+ "the user asks you to stop it. Its command id is what " +
+ "${ReadTerminalCommandHandler.TOOL_NAME} and ${StopTerminalCommandHandler.TOOL_NAME} " +
+ "take. Another command runs in a separate Terminal session meanwhile."
+
+ private val NO_COMMAND = ToolResult.failure(
+ "command is required",
+ "Give the shell command or script to run, e.g. command=\"ls -la\"."
+ )
+
+ /**
+ * The tool result for [outcome]: a command that exited succeeds whatever its code, as grep
+ * finding nothing exits 1; one still running (a server) succeeds with its output so far.
+ */
+ // The else is for a result a newer host adds; without it that result throws at runtime.
+ @Suppress("REDUNDANT_ELSE_IN_WHEN")
+ internal fun resultFor(outcome: TerminalCommandResult): ToolResult = when (outcome) {
+ is TerminalCommandResult.Completed -> ToolResult.success(
+ message = "Command ${TerminalOutput.exitOf(outcome.exitCode)}",
+ data = TerminalOutput.tailOf(outcome.output)
+ )
+ is TerminalCommandResult.Running -> ToolResult.success(
+ message = "Command is still running in Terminal session \"${outcome.sessionName}\" " +
+ "with command id \"${outcome.commandId}\"",
+ data = "$STILL_RUNNING_NOTE\n\n${TerminalOutput.tailOf(outcome.output)}"
+ )
+ is TerminalCommandResult.NotStarted -> ToolResult.failure(
+ "Command did not start: ${outcome.reason}",
+ "Nothing ran. Tell the user why; retrying the same command will not help."
+ )
+ else -> ToolResult.failure("Command ended with an unknown result: $outcome")
+ }
+ }
+}
diff --git a/plugins/AI-Core/src/main/kotlin/com/itsaky/androidide/plugins/aicore/tool/handlers/ShellInvocation.kt b/plugins/AI-Core/src/main/kotlin/com/itsaky/androidide/plugins/aicore/tool/handlers/ShellInvocation.kt
new file mode 100644
index 00000000..41d679c1
--- /dev/null
+++ b/plugins/AI-Core/src/main/kotlin/com/itsaky/androidide/plugins/aicore/tool/handlers/ShellInvocation.kt
@@ -0,0 +1,26 @@
+package com.itsaky.androidide.plugins.aicore.tool.handlers
+
+import com.itsaky.androidide.plugins.aicore.tool.handlers.RunShellCommandHandler.Companion.ARG_COMMAND
+import com.itsaky.androidide.plugins.aicore.tool.handlers.RunShellCommandHandler.Companion.ARG_WORKING_DIRECTORY
+
+/**
+ * A `run_shell_command` call as the host takes it: the command exactly as the model wrote it, and
+ * the working directory, null for the project root.
+ */
+internal data class ShellInvocation(
+ val command: String,
+ val workingDirectory: String?,
+) {
+ companion object {
+ /** The call in [args], or null when it names no command. */
+ fun from(args: Map): ShellInvocation? =
+ args[ARG_COMMAND]?.toString()
+ ?.takeIf(String::isNotBlank)
+ ?.let { command ->
+ ShellInvocation(
+ command = command,
+ workingDirectory = args[ARG_WORKING_DIRECTORY]?.toString()?.trim()?.takeIf(String::isNotEmpty),
+ )
+ }
+ }
+}
diff --git a/plugins/AI-Core/src/main/kotlin/com/itsaky/androidide/plugins/aicore/tool/handlers/StopTerminalCommandHandler.kt b/plugins/AI-Core/src/main/kotlin/com/itsaky/androidide/plugins/aicore/tool/handlers/StopTerminalCommandHandler.kt
new file mode 100644
index 00000000..386f537e
--- /dev/null
+++ b/plugins/AI-Core/src/main/kotlin/com/itsaky/androidide/plugins/aicore/tool/handlers/StopTerminalCommandHandler.kt
@@ -0,0 +1,40 @@
+package com.itsaky.androidide.plugins.aicore.tool.handlers
+
+import com.itsaky.androidide.plugins.PluginContext
+import com.itsaky.androidide.plugins.aicore.models.ToolResult
+import com.itsaky.androidide.plugins.services.IdeTerminalService
+import com.itsaky.androidide.plugins.services.TerminalCommandResult
+
+/**
+ * Handler for stopping a command [RunShellCommandHandler] left running, such as `ping` or a dev
+ * server, with Ctrl-C, as the user would. The host only stops the agent's own commands.
+ */
+class StopTerminalCommandHandler(
+ pluginContext: PluginContext,
+) : TerminalCommandHandler(pluginContext, TOOL_NAME) {
+
+ // Asked every time: unasked, Gemini stopped a ping the user had just started.
+ override val requiresApproval = true
+
+ // A session grant would let every later stop through unasked, which is what the dialog prevents.
+ override val allowsSessionApproval = false
+
+ override suspend fun callHost(terminal: IdeTerminalService, commandId: String) =
+ terminal.stopCommand(commandId)
+
+ override fun running(commandId: String, running: TerminalCommandResult.Running) = ToolResult.failure(
+ "Command \"$commandId\" is still running; it did not stop on Ctrl-C",
+ "Tell the user to stop it in the Terminal session \"${running.sessionName}\". Its latest output:\n" +
+ TerminalOutput.tailOf(running.output)
+ )
+
+ // Any exit code succeeds, since Ctrl-C itself makes most commands exit non-zero.
+ override fun completed(commandId: String, completed: TerminalCommandResult.Completed) = ToolResult.success(
+ message = "Command \"$commandId\" has stopped: it ${TerminalOutput.exitOf(completed.exitCode)}",
+ data = TerminalOutput.tailOf(completed.output)
+ )
+
+ companion object {
+ const val TOOL_NAME = "stop_terminal_command"
+ }
+}
diff --git a/plugins/AI-Core/src/main/kotlin/com/itsaky/androidide/plugins/aicore/tool/handlers/TerminalCommandHandler.kt b/plugins/AI-Core/src/main/kotlin/com/itsaky/androidide/plugins/aicore/tool/handlers/TerminalCommandHandler.kt
new file mode 100644
index 00000000..551b34ff
--- /dev/null
+++ b/plugins/AI-Core/src/main/kotlin/com/itsaky/androidide/plugins/aicore/tool/handlers/TerminalCommandHandler.kt
@@ -0,0 +1,84 @@
+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.aicore.tool.Validation
+import com.itsaky.androidide.plugins.aicore.tool.handlers.HostServiceCall.Companion.SHELL_STAGE
+import com.itsaky.androidide.plugins.services.IdeTerminalService
+import com.itsaky.androidide.plugins.services.TerminalCommandResult
+
+/**
+ * A tool acting on one command [RunShellCommandHandler] left running, by the id it reported: the
+ * argument, the host call, tracing and the unknown-id result. Subclasses say what to call and report.
+ * Keyed by command, not session: the host reuses an idle session for the next command.
+ */
+abstract class TerminalCommandHandler(
+ pluginContext: PluginContext,
+ final override val toolName: String,
+) : ToolHandler {
+
+ final override val parametersSchema = ToolSchema.objectOf(
+ ARG_COMMAND_ID to ToolSchema.string(),
+ required = listOf(ARG_COMMAND_ID),
+ )
+
+ final override val argAliases = mapOf(
+ "commandId" to ARG_COMMAND_ID,
+ "id" to ARG_COMMAND_ID,
+ )
+
+ private val terminalCall = HostServiceCall(
+ pluginContext, toolName, IdeTerminalService::class.java, SHELL_STAGE, "Terminal",
+ )
+
+ /** Calls the host on [commandId]; null when the host has no such command of the agent's. */
+ internal abstract suspend fun callHost(terminal: IdeTerminalService, commandId: String): TerminalCommandResult?
+
+ /** The result for [commandId] when the call leaves it [running]. */
+ internal abstract fun running(commandId: String, running: TerminalCommandResult.Running): ToolResult
+
+ /** The result for [commandId] when the call finds it [completed]. */
+ internal abstract fun completed(commandId: String, completed: TerminalCommandResult.Completed): ToolResult
+
+ final override suspend fun validate(args: Map): Validation =
+ commandIdOf(args)?.let { Validation.Accepted(args) } ?: Validation.Rejected(NO_COMMAND_ID)
+
+ final override suspend fun execute(args: Map): ToolResult {
+ val commandId = commandIdOf(args) ?: return NO_COMMAND_ID
+
+ return terminalCall.run { terminal ->
+ val state = callHost(terminal, commandId)
+ AgentTrace.stage(SHELL_STAGE, "$toolName command=$commandId state=${state?.let { it::class.simpleName }}")
+ resultFor(commandId, state)
+ }
+ }
+
+ /** The tool result for the [state] the call left command [commandId] in. */
+ // The else is for a result a newer host adds; without it that result throws at runtime.
+ @Suppress("REDUNDANT_ELSE_IN_WHEN")
+ internal fun resultFor(commandId: String, state: TerminalCommandResult?): ToolResult = when (state) {
+ null -> ToolResult.failure(
+ "No command with id \"$commandId\"",
+ "The id is wrong, or the command ended long enough ago that the Terminal no longer keeps " +
+ "it. Use the command id a ${RunShellCommandHandler.TOOL_NAME} result reported."
+ )
+ is TerminalCommandResult.Running -> running(commandId, state)
+ is TerminalCommandResult.Completed -> completed(commandId, state)
+ else -> ToolResult.failure("Command \"$commandId\" is in an unknown state: $state")
+ }
+
+ companion object {
+ const val ARG_COMMAND_ID = "command_id"
+
+ private val NO_COMMAND_ID = ToolResult.failure(
+ "command_id is required",
+ "Give the command id a ${RunShellCommandHandler.TOOL_NAME} result reported."
+ )
+
+ private fun commandIdOf(args: Map): String? =
+ args[ARG_COMMAND_ID]?.toString()?.trim()?.takeIf(String::isNotEmpty)
+ }
+}
diff --git a/plugins/AI-Core/src/main/kotlin/com/itsaky/androidide/plugins/aicore/tool/handlers/TerminalOutput.kt b/plugins/AI-Core/src/main/kotlin/com/itsaky/androidide/plugins/aicore/tool/handlers/TerminalOutput.kt
new file mode 100644
index 00000000..a8b2f938
--- /dev/null
+++ b/plugins/AI-Core/src/main/kotlin/com/itsaky/androidide/plugins/aicore/tool/handlers/TerminalOutput.kt
@@ -0,0 +1,26 @@
+package com.itsaky.androidide.plugins.aicore.tool.handlers
+
+/**
+ * The slice of a Terminal command's output handed to the model: its end, where an error and the
+ * final state show up. Shared by the shell tools, so a run and a later read look the same.
+ */
+internal object TerminalOutput {
+ /** Maximum characters of output handed to the model; the end is kept. */
+ const val MAX_CHARS = 8000
+
+ private const val NO_OUTPUT = "(No output)"
+
+ /** The last [MAX_CHARS] of [output], marked with [TRUNCATION_MARKER] when earlier output was cut. */
+ fun tailOf(output: String): String = when {
+ output.isBlank() -> NO_OUTPUT
+ output.length <= MAX_CHARS -> output
+ else -> "$TRUNCATION_MARKER\n${output.takeLast(MAX_CHARS)}"
+ }
+
+ /** How a command with [exitCode] ended, as a predicate; the host reports -1 when it could not tell. */
+ fun exitOf(exitCode: Int): String =
+ if (exitCode == UNKNOWN_EXIT_CODE) "exited, but the Terminal could not tell its exit code"
+ else "exited with code $exitCode"
+
+ private const val UNKNOWN_EXIT_CODE = -1
+}
diff --git a/plugins/AI-Core/src/main/kotlin/com/itsaky/androidide/plugins/aicore/viewmodel/AgentReplyRenderer.kt b/plugins/AI-Core/src/main/kotlin/com/itsaky/androidide/plugins/aicore/viewmodel/AgentReplyRenderer.kt
index 24748ec8..017b2811 100644
--- a/plugins/AI-Core/src/main/kotlin/com/itsaky/androidide/plugins/aicore/viewmodel/AgentReplyRenderer.kt
+++ b/plugins/AI-Core/src/main/kotlin/com/itsaky/androidide/plugins/aicore/viewmodel/AgentReplyRenderer.kt
@@ -12,6 +12,8 @@ import com.itsaky.androidide.plugins.aicore.tool.respondMessageOf
*/
object AgentReplyRenderer {
+ private val THINKING = Regex("(?s).*?(|$)")
+
/**
* Whether this turn leaves no bubble behind.
*
@@ -20,16 +22,39 @@ object AgentReplyRenderer {
* a failure still gets its own message. Before that line existed every such turn appended a
* badge bubble and every result another, which is what buried the answer.
*
- * A turn carrying the terminal call always keeps its bubble: that is the only place the answer
- * is rendered.
+ * A turn carrying the terminal call keeps its bubble, as the only place the answer is rendered,
+ * unless it is the one-line recap the finish request asks for after a prose answer already on
+ * screen; a longer reply is the real answer, and a failure's warning still shows.
*
* @param toolCalls the calls parsed out of this turn.
* @param terminalTool the name of the answer-carrying pseudo-tool (`respond`).
+ * @param recapsShownAnswer whether the run was asked to finish after a prose turn [showsAnswer] kept.
+ * @param lastToolFailed whether this run's most recent tool call failed.
* @return true when the turn should not reach the transcript.
*/
- fun isSilentTurn(toolCalls: List, terminalTool: String): Boolean {
+ fun isSilentTurn(
+ toolCalls: List,
+ terminalTool: String,
+ recapsShownAnswer: Boolean = false,
+ lastToolFailed: Boolean = false,
+ ): Boolean {
if (toolCalls.isEmpty()) return false
- return toolCalls.none { isTerminalToolName(it.name, terminalTool) }
+ val answers = toolCalls.filter { isTerminalToolName(it.name, terminalTool) }
+ if (answers.isEmpty()) return true
+ if (!recapsShownAnswer || lastToolFailed || answers.size != toolCalls.size) return false
+ return answers.all { respondMessageOf(it.args).orEmpty().trim().lines().size <= 1 }
+ }
+
+ /**
+ * Whether a prose turn put an answer on screen, so a recap of it would show it twice: not only
+ * thinking, and not a fallback such as [noResponseText] standing in for nothing.
+ * @param displayText what [render] returned for the turn.
+ * @param noResponseText the fallback [render] was given.
+ * @return true when the bubble holds text the model wrote for the user.
+ */
+ fun showsAnswer(displayText: String, noResponseText: String): Boolean {
+ val visible = displayText.replace(THINKING, "").trim()
+ return visible.isNotEmpty() && visible != noResponseText
}
/**
diff --git a/plugins/AI-Core/src/main/kotlin/com/itsaky/androidide/plugins/aicore/viewmodel/AgentRunReporter.kt b/plugins/AI-Core/src/main/kotlin/com/itsaky/androidide/plugins/aicore/viewmodel/AgentRunReporter.kt
index a68c44af..78a2e8a5 100644
--- a/plugins/AI-Core/src/main/kotlin/com/itsaky/androidide/plugins/aicore/viewmodel/AgentRunReporter.kt
+++ b/plugins/AI-Core/src/main/kotlin/com/itsaky/androidide/plugins/aicore/viewmodel/AgentRunReporter.kt
@@ -12,8 +12,12 @@ import com.itsaky.androidide.plugins.aicore.tool.ToolCallExtractor
* thing to read here, and the wording of a stop stays with the screen that shows it.
*
* @param notices where a user-visible stop is announced.
+ * @param onAskedToFinish called when a prose answer is followed by a request to finish the run.
*/
-internal class AgentRunReporter(private val notices: Notices) : AgentLoop.Events {
+internal class AgentRunReporter(
+ private val notices: Notices,
+ private val onAskedToFinish: () -> Unit = {},
+) : AgentLoop.Events {
/**
* The stops worth telling the user about, and nothing else the chat can do. The reporter
@@ -79,6 +83,7 @@ internal class AgentRunReporter(private val notices: Notices) : AgentLoop.Events
override suspend fun onUnfinishedReply(turn: Int) {
AgentTrace.stage("LOOP", "turn=$turn asked-to-finish=no-terminal-tool")
+ onAskedToFinish()
}
// Reaching this means the backend did not force the call; see VerificationPolicy.
diff --git a/plugins/AI-Core/src/main/kotlin/com/itsaky/androidide/plugins/aicore/viewmodel/ChatViewModel.kt b/plugins/AI-Core/src/main/kotlin/com/itsaky/androidide/plugins/aicore/viewmodel/ChatViewModel.kt
index 2f0664ef..409b5455 100644
--- a/plugins/AI-Core/src/main/kotlin/com/itsaky/androidide/plugins/aicore/viewmodel/ChatViewModel.kt
+++ b/plugins/AI-Core/src/main/kotlin/com/itsaky/androidide/plugins/aicore/viewmodel/ChatViewModel.kt
@@ -317,6 +317,14 @@ class ChatViewModel(
@Volatile
private var lastToolFailedThisRun = false
+ /** Whether the next turn answers a request to finish after a prose answer; read once. */
+ @Volatile
+ private var askedToFinishAfterProse = false
+
+ /** Whether the latest prose turn left answer text on screen, not only thinking or a fallback. */
+ @Volatile
+ private var proseTurnShowedAnswer = false
+
/**
* The transcript row this run rewrites in place as each tool starts, closed as a one-line
* summary when the run ends; null before the run's first tool. Main thread only.
@@ -960,6 +968,8 @@ class ChatViewModel(
AgentTrace.beginRun(currentBackendId, userMessage, runFiles.size)
// Reset per-run tool tracking.
lastToolFailedThisRun = false
+ askedToFinishAfterProse = false
+ proseTurnShowedAnswer = false
activityMessageId = null
runToolNames.clear()
runToolLog.clear()
@@ -1067,7 +1077,8 @@ class ChatViewModel(
tools.router.getHandler(call.name)?.mutatesProject == true
},
requiredTool = requiredTool,
- events = AgentRunReporter(runNotices),
+ // Only a prose turn that showed an answer has one a recap could repeat.
+ events = AgentRunReporter(runNotices) { askedToFinishAfterProse = proseTurnShowedAnswer },
)
if (loopResult.completed && generationEpoch.get() == epoch) {
runCodeReply?.let { draft -> reviewAnswer(llmService, userMessage, draft, history, epoch) }
@@ -1531,8 +1542,10 @@ class ChatViewModel(
}
// Per-run flag (set by executeToolCalls), not a session-wide scan.
val lastToolFailed = lastToolFailedThisRun
+ val recapsShownAnswer = askedToFinishAfterProse.also { askedToFinishAfterProse = false }
- if (AgentReplyRenderer.isSilentTurn(toolCalls, RESPOND_TOOL)) {
+ // A recap of the prose answer already on screen would show the answer twice.
+ if (AgentReplyRenderer.isSilentTurn(toolCalls, RESPOND_TOOL, recapsShownAnswer, lastToolFailed)) {
viewModelScope.launch(Dispatchers.Main) {
removeMessageFromSession(agentMessageId)
}
@@ -1549,6 +1562,8 @@ class ChatViewModel(
noResponseText = str(R.string.agent_no_response),
unparsedReplyText = { str(unparsedReplyMessage(it)) },
)
+ proseTurnShowedAnswer = toolCalls.isEmpty() &&
+ AgentReplyRenderer.showsAnswer(displayText, str(R.string.agent_no_response))
if (AnswerReview.holdsCode(displayText)) {
runCodeReply = CodeReply(agentMessageId, displayText, reply.historyText)
}
diff --git a/plugins/AI-Core/src/main/res/values/strings.xml b/plugins/AI-Core/src/main/res/values/strings.xml
index 9f52bded..a1d54d30 100644
--- a/plugins/AI-Core/src/main/res/values/strings.xml
+++ b/plugins/AI-Core/src/main/res/values/strings.xml
@@ -133,6 +133,8 @@
Approve %s?
The AI wants to execute:
Arguments:
+ Command:
+ Runs in: %1$s
Approve Once
Approve for Session
Deny
diff --git a/plugins/AI-Core/src/test/kotlin/com/itsaky/androidide/plugins/aicore/fragments/ApprovalPresentationTest.kt b/plugins/AI-Core/src/test/kotlin/com/itsaky/androidide/plugins/aicore/fragments/ApprovalPresentationTest.kt
new file mode 100644
index 00000000..6da6619d
--- /dev/null
+++ b/plugins/AI-Core/src/test/kotlin/com/itsaky/androidide/plugins/aicore/fragments/ApprovalPresentationTest.kt
@@ -0,0 +1,33 @@
+package com.itsaky.androidide.plugins.aicore.fragments
+
+import com.itsaky.androidide.plugins.aicore.R
+import com.itsaky.androidide.plugins.aicore.tool.ApprovalPreview
+import org.junit.Assert.assertEquals
+import org.junit.Test
+
+/**
+ * Unit tests for [ApprovalPresentation]: the dialog picks its presentation from the preview the
+ * handler declares, never from the tool's name.
+ */
+class ApprovalPresentationTest {
+
+ @Test
+ fun givenEachPreview_whenItsLabelIsPicked_thenTheMatchingHeadingComesBack() {
+ assertEquals(R.string.approval_proposed_change, ApprovalPresentation.argsLabel(ApprovalPreview.EDIT))
+ assertEquals(R.string.approval_command, ApprovalPresentation.argsLabel(ApprovalPreview.SHELL_COMMAND))
+ assertEquals(R.string.approval_args, ApprovalPresentation.argsLabel(ApprovalPreview.ARGS))
+ }
+
+ @Test
+ fun givenEverySavedPreviewName_whenRestored_thenTheSamePreviewComesBack() {
+ ApprovalPreview.entries.forEach { preview ->
+ assertEquals(preview, ApprovalPresentation.named(preview.name))
+ }
+ }
+
+ @Test
+ fun givenAMissingOrUnknownName_whenRestored_thenItFallsBackToArgs() {
+ assertEquals(ApprovalPreview.ARGS, ApprovalPresentation.named(null))
+ assertEquals(ApprovalPreview.ARGS, ApprovalPresentation.named("is_edit"))
+ }
+}
diff --git a/plugins/AI-Core/src/test/kotlin/com/itsaky/androidide/plugins/aicore/fragments/ApprovalTextFormatterTest.kt b/plugins/AI-Core/src/test/kotlin/com/itsaky/androidide/plugins/aicore/fragments/ApprovalTextFormatterTest.kt
index 6d91e06a..7dbc09d9 100644
--- a/plugins/AI-Core/src/test/kotlin/com/itsaky/androidide/plugins/aicore/fragments/ApprovalTextFormatterTest.kt
+++ b/plugins/AI-Core/src/test/kotlin/com/itsaky/androidide/plugins/aicore/fragments/ApprovalTextFormatterTest.kt
@@ -1,6 +1,7 @@
package com.itsaky.androidide.plugins.aicore.fragments
import com.itsaky.androidide.plugins.aicore.tool.handlers.EditFileHandler
+import com.itsaky.androidide.plugins.aicore.tool.handlers.RunShellCommandHandler
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
@@ -141,4 +142,68 @@ class ApprovalTextFormatterTest {
assertTrue(text.contains("directory"))
}
+
+ @Test
+ fun givenALongMultiLineScript_whenFormatted_thenEveryLineIsShownAsWritten() {
+ val script = (1..50).joinToString("\n") { "echo \"line $it\"" }
+
+ val text = ApprovalTextFormatter.formatShellCommand(
+ mapOf(RunShellCommandHandler.ARG_COMMAND to script),
+ directoryLabel = { "in $it" },
+ )
+
+ assertEquals(script, text)
+ }
+
+ @Test
+ fun givenAWorkingDirectory_whenFormatted_thenItIsLabelledAboveTheCommandAsWritten() {
+ val text = ApprovalTextFormatter.formatShellCommand(
+ mapOf(
+ RunShellCommandHandler.ARG_COMMAND to "ls",
+ RunShellCommandHandler.ARG_WORKING_DIRECTORY to " -L dir ",
+ ),
+ directoryLabel = { "Runs in: $it" },
+ )
+
+ assertEquals("Runs in: -L dir\n\nls", text)
+ assertFalse(text.contains("cd "))
+ }
+
+ @Test
+ fun givenACarriageReturnHidingAPrefix_whenFormatted_thenItIsSpelledOutAndWarnedAbout() {
+ val text = ApprovalTextFormatter.formatShellCommand(
+ mapOf(RunShellCommandHandler.ARG_COMMAND to "rm -rf app\rls"),
+ directoryLabel = { "in $it" },
+ )
+
+ assertTrue(text.startsWith("rm -rf appls"))
+ assertTrue(text.contains("invisible or control characters"))
+ }
+
+ @Test
+ fun givenBidiOverridesZeroWidthAndEscapes_whenFormatted_thenEachIsSpelledOut() {
+ val command = "echo \u202Egnp.exe\u202C \u200B\u2066x\u2069 \u001B[2K"
+
+ val text = ApprovalTextFormatter.revealHidden(command)
+
+ assertEquals("echo gnp.exe x [2K", text)
+ }
+
+ @Test
+ fun givenAHiddenCharacterInTheWorkingDirectory_whenFormatted_thenItIsSpelledOutToo() {
+ val text = ApprovalTextFormatter.formatShellCommand(
+ mapOf(
+ RunShellCommandHandler.ARG_COMMAND to "ls",
+ RunShellCommandHandler.ARG_WORKING_DIRECTORY to "app\u202Etset",
+ ),
+ directoryLabel = { "Runs in: $it" },
+ )
+
+ assertTrue(text.startsWith("Runs in: apptset\n\nls"))
+ }
+
+ @Test
+ fun givenNewlinesAndTabs_whenRevealed_thenTheyStayAsWritten() {
+ assertEquals("a\n\tb", ApprovalTextFormatter.revealHidden("a\n\tb"))
+ }
}
diff --git a/plugins/AI-Core/src/test/kotlin/com/itsaky/androidide/plugins/aicore/prompt/ToolResultsPromptTest.kt b/plugins/AI-Core/src/test/kotlin/com/itsaky/androidide/plugins/aicore/prompt/ToolResultsPromptTest.kt
index 755c8404..de163f05 100644
--- a/plugins/AI-Core/src/test/kotlin/com/itsaky/androidide/plugins/aicore/prompt/ToolResultsPromptTest.kt
+++ b/plugins/AI-Core/src/test/kotlin/com/itsaky/androidide/plugins/aicore/prompt/ToolResultsPromptTest.kt
@@ -5,6 +5,11 @@ import com.itsaky.androidide.plugins.aicore.prompt.config.AgentPromptConfig
import com.itsaky.androidide.plugins.aicore.prompt.config.DirectoryPromptConfigSource.Companion.shippedConfig
import com.itsaky.androidide.plugins.aicore.prompt.config.DirectoryPromptConfigSource.Companion.shippedWith
import com.itsaky.androidide.plugins.aicore.tool.ToolCall
+import com.itsaky.androidide.plugins.aicore.tool.handlers.ReadTerminalCommandHandler
+import com.itsaky.androidide.plugins.aicore.tool.handlers.RunShellCommandHandler
+import com.itsaky.androidide.plugins.aicore.tool.handlers.TerminalOutput
+import com.itsaky.androidide.plugins.services.TerminalCommandResult
+import io.mockk.mockk
import kotlinx.coroutines.runBlocking
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
@@ -170,4 +175,35 @@ class ToolResultsPromptTest {
assertTrue(turn.contains("[truncated"))
}
+
+ @Test
+ fun givenAStillRunningCommandWithFullOutput_whenRendering_thenItsLatestOutputIsNotCut() {
+ val output = "x".repeat(TerminalOutput.MAX_CHARS * 2) + "listening on 3000"
+ val result = RunShellCommandHandler.resultFor(TerminalCommandResult.Running("cmd-1", "AI Core 1", output))
+
+ val turn = render(listOf(ToolCall(RunShellCommandHandler.TOOL_NAME, emptyMap())), listOf(result))
+
+ assertTrue(turn.contains("listening on 3000\n"))
+ }
+
+ @Test
+ fun givenANonZeroExitWithFullOutput_whenRendering_thenTheErrorAtTheEndIsNotCut() {
+ val output = "x".repeat(TerminalOutput.MAX_CHARS * 2) + "error: no such file"
+ val result = RunShellCommandHandler.resultFor(TerminalCommandResult.Completed(1, output))
+
+ val turn = render(listOf(ToolCall(RunShellCommandHandler.TOOL_NAME, emptyMap())), listOf(result))
+
+ assertTrue(turn.contains("error: no such file"))
+ assertFalse(turn.contains("…[truncated"))
+ }
+
+ @Test
+ fun givenACommandReadWithFullOutput_whenRendering_thenItsLatestOutputIsNotCut() {
+ val output = "x".repeat(TerminalOutput.MAX_CHARS * 2) + "GET / 200"
+ val result = ReadTerminalCommandHandler(mockk(relaxed = true)).resultFor("cmd-1", TerminalCommandResult.Running("cmd-1", "AI Core 1", output))
+
+ val turn = render(listOf(ToolCall(ReadTerminalCommandHandler.TOOL_NAME, emptyMap())), listOf(result))
+
+ assertTrue(turn.contains("GET / 200\n"))
+ }
}
diff --git a/plugins/AI-Core/src/test/kotlin/com/itsaky/androidide/plugins/aicore/tool/ExecutorTest.kt b/plugins/AI-Core/src/test/kotlin/com/itsaky/androidide/plugins/aicore/tool/ExecutorTest.kt
index ef8f48c4..774c9775 100644
--- a/plugins/AI-Core/src/test/kotlin/com/itsaky/androidide/plugins/aicore/tool/ExecutorTest.kt
+++ b/plugins/AI-Core/src/test/kotlin/com/itsaky/androidide/plugins/aicore/tool/ExecutorTest.kt
@@ -2,7 +2,11 @@ package com.itsaky.androidide.plugins.aicore.tool
import com.itsaky.androidide.plugins.aicore.models.ToolResult
import com.itsaky.androidide.plugins.aicore.prompt.config.DirectoryPromptConfigSource.Companion.shippedConfig
+import com.itsaky.androidide.plugins.PluginContext
import com.itsaky.androidide.plugins.aicore.tool.handlers.PathGuard
+import com.itsaky.androidide.plugins.aicore.tool.handlers.RunShellCommandHandler
+import io.mockk.mockk
+import io.mockk.verify
import kotlinx.coroutines.delay
import kotlinx.coroutines.runBlocking
import org.junit.After
@@ -454,4 +458,19 @@ class ExecutorTest {
assertTrue("in-project path should be allowed through", handler.dispatched)
assertTrue(results.single().success)
}
+
+ @Test
+ fun givenAShellCommandOutsideTheProject_whenExecuting_thenItIsRejectedBeforeApprovalOrTheTerminal() = runBlocking {
+ val context = mockk(relaxed = true)
+ val executor = executorFor(RunShellCommandHandler(context))
+
+ val results = executor.execute(
+ listOf(ToolCall(RunShellCommandHandler.TOOL_NAME, mapOf("command" to "ls", "cwd" to "../outside")))
+ )
+
+ assertFalse(results.single().success)
+ // The containment guard's message: it runs before validate() and the approval dialog.
+ assertTrue(results.single().message.contains("outside the project directory"))
+ verify(exactly = 0) { context.services }
+ }
}
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 535bde8d..261a2085 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,30 @@ class ToolCallProgressGuardTest {
assertEquals(ToolCallProgressGuard.Verdict.PROCEED, guard.inspect(call("read_app_logs")))
}
+ @Test
+ fun givenATerminalPollWhoseOutputKeepsChanging_whenReissued_thenItNeverStops() {
+ val guard = guard()
+ val poll = listOf(ToolCall("read_terminal_command", mapOf("command_id" to "cmd-1")))
+
+ repeat(6) { i ->
+ assertEquals(ToolCallProgressGuard.Verdict.PROCEED, guard.inspect(poll))
+ guard.recordResults(listOf(ToolResult.success("running", "line $i")))
+ }
+ }
+
+ @Test
+ fun givenATerminalPollWhoseOutputStopsChanging_whenReissued_thenItStopsAtTheRepeatLimit() {
+ val guard = guard(repeats = 2)
+ val poll = listOf(ToolCall("read_terminal_command", mapOf("command_id" to "cmd-1")))
+ val same = listOf(ToolResult.success("running", "waiting"))
+
+ assertEquals(ToolCallProgressGuard.Verdict.PROCEED, guard.inspect(poll))
+ guard.recordResults(same)
+ assertEquals(ToolCallProgressGuard.Verdict.PROCEED, guard.inspect(poll))
+ guard.recordResults(same)
+ assertEquals(ToolCallProgressGuard.Verdict.REPEATED, guard.inspect(poll))
+ }
+
@Test
fun givenALogReadBatchedWithABuildReissued_whenInspected_thenItAssumesComplete() {
val guard = guard()
@@ -231,4 +255,39 @@ class ToolCallProgressGuardTest {
assertEquals(ToolCallProgressGuard.Verdict.PROCEED, guard.inspect(read))
}
+
+ @Test
+ fun givenATerminalCommandReadReissued_whenInspected_thenItPollsAgain() {
+ val guard = realGuard()
+ val read = listOf(ToolCall("read_terminal_command", mapOf("command_id" to "cmd-1")))
+ guard.inspect(read)
+ guard.recordResults(ok)
+
+ assertEquals(ToolCallProgressGuard.Verdict.PROCEED, guard.inspect(read))
+ }
+
+ @Test
+ fun givenAShellCommandInADirectory_whenInspected_thenItInvalidatesEveryRead() {
+ val guard = realGuard()
+ val read = listOf(ToolCall("read_file", mapOf("file_path" to "app/src/A.kt")))
+ guard.inspect(read)
+ guard.recordResults(ok)
+ guard.inspect(listOf(ToolCall("run_shell_command", mapOf("command" to "sed -i s/a/b/ src/A.kt", "working_directory" to "app"))))
+ guard.recordResults(ok)
+
+ assertEquals(ToolCallProgressGuard.Verdict.PROCEED, guard.inspect(read))
+ }
+
+ @Test
+ fun givenAShellCommandBatchedWithAnEdit_whenInspected_thenItStillInvalidatesEveryRead() {
+ val guard = realGuard()
+ val read = listOf(ToolCall("read_file", mapOf("file_path" to "B.kt")))
+ guard.inspect(read)
+ guard.recordResults(ok)
+ val edit = ToolCall("edit_file", mapOf("file_path" to "A.kt", "old_string" to "a", "new_string" to "b"))
+ guard.inspect(listOf(edit, ToolCall("run_shell_command", mapOf("command" to "rm B.kt"))))
+ guard.recordResults(ok + ok)
+
+ assertEquals(ToolCallProgressGuard.Verdict.PROCEED, guard.inspect(read))
+ }
}
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 955fa0bd..36a52d80 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
@@ -15,7 +15,7 @@ import org.junit.Test
class BuiltInHandlerApprovalTest {
private companion object {
- /** Built-ins that change the project, install an app, or start a build. */
+ /** Built-ins that change the project, install an app, start a build, or run or stop a command. */
val MUTATING_TOOLS = setOf(
"create_file",
"update_file",
@@ -24,11 +24,13 @@ class BuiltInHandlerApprovalTest {
"run_app",
"gradle_sync",
"run_gradle_task",
+ "run_shell_command",
+ "stop_terminal_command",
"generate_from_template",
)
/** The subset that rewrites a project file, which is what the progress guard counts. */
- val WRITING_TOOLS = setOf("create_file", "update_file", "edit_file", "add_dependency")
+ val WRITING_TOOLS = setOf("create_file", "update_file", "edit_file", "add_dependency", "run_shell_command")
/**
* Built-ins that only read. `open_file` belongs here despite opening an editor tab: it
@@ -45,6 +47,8 @@ class BuiltInHandlerApprovalTest {
"web_search",
"read_app_logs",
"read_ide_logs",
+ // Output of a command the user already approved; the host reads only the agent's sessions.
+ "read_terminal_command",
)
/** Built-ins that reach a host the model chose, so the user sees where before it happens. */
diff --git a/plugins/AI-Core/src/test/kotlin/com/itsaky/androidide/plugins/aicore/tool/handlers/ReadTerminalCommandHandlerTest.kt b/plugins/AI-Core/src/test/kotlin/com/itsaky/androidide/plugins/aicore/tool/handlers/ReadTerminalCommandHandlerTest.kt
new file mode 100644
index 00000000..2d0dfae6
--- /dev/null
+++ b/plugins/AI-Core/src/test/kotlin/com/itsaky/androidide/plugins/aicore/tool/handlers/ReadTerminalCommandHandlerTest.kt
@@ -0,0 +1,120 @@
+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.aicore.tool.Validation
+import com.itsaky.androidide.plugins.services.IdeTerminalService
+import com.itsaky.androidide.plugins.services.TerminalCommandResult
+import io.mockk.coEvery
+import io.mockk.coVerify
+import io.mockk.every
+import io.mockk.mockk
+import kotlinx.coroutines.test.runTest
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertFalse
+import org.junit.Assert.assertTrue
+import org.junit.Before
+import org.junit.Test
+
+/**
+ * Unit tests for [ReadTerminalCommandHandler] — the tool that checks on a command
+ * run_shell_command left running, such as a dev server (ADFA-6339).
+ */
+class ReadTerminalCommandHandlerTest {
+
+ private lateinit var context: PluginContext
+ private lateinit var services: ServiceRegistry
+ private lateinit var terminal: IdeTerminalService
+ private lateinit var handler: ReadTerminalCommandHandler
+
+ @Before
+ fun setup() {
+ terminal = mockk()
+ services = mockk()
+ context = mockk()
+ every { context.services } returns services
+ every { context.logger } returns mockk(relaxed = true)
+ every { services.get(IdeTerminalService::class.java) } returns terminal
+ handler = ReadTerminalCommandHandler(context)
+ }
+
+ @Test
+ fun givenNoTerminalService_whenReading_thenItFails() = runTest {
+ every { services.get(IdeTerminalService::class.java) } returns null
+
+ val result = handler.execute(mapOf("command_id" to "cmd-1"))
+
+ assertFalse(result.success)
+ assertTrue(result.message.contains("not available"))
+ }
+
+ @Test
+ fun givenNoCommandId_whenValidating_thenItIsRejected() = runTest {
+ assertTrue(handler.validate(mapOf("command_id" to " ")) is Validation.Rejected)
+ }
+
+ @Test
+ fun givenACommandId_whenReading_thenTheHostGetsItTrimmed() = runTest {
+ coEvery { terminal.readCommand(any()) } returns null
+
+ handler.execute(mapOf("command_id" to " cmd-1 "))
+
+ coVerify { terminal.readCommand("cmd-1") }
+ }
+
+ @Test
+ fun givenARunningCommand_whenReading_thenItSaysSoWithTheSessionAndOutput() = runTest {
+ coEvery { terminal.readCommand(any()) } returns
+ TerminalCommandResult.Running("cmd-1", "AI Core 2", "$ npm start\nlistening on 3000")
+
+ val result = handler.execute(mapOf("command_id" to "cmd-1"))
+
+ assertTrue(result.success)
+ assertTrue(result.message.contains("still running in Terminal session \"AI Core 2\""))
+ assertEquals("$ npm start\nlistening on 3000", result.data)
+ }
+
+ @Test
+ fun givenACommandThatExitedWithAnError_whenReading_thenItSucceedsWithTheCodeAndOutput() = runTest {
+ coEvery { terminal.readCommand(any()) } returns
+ TerminalCommandResult.Completed(1, "$ npm start\nEADDRINUSE")
+
+ val result = handler.execute(mapOf("command_id" to "cmd-1"))
+
+ assertTrue(result.success)
+ assertTrue(result.message.contains("exited with code 1"))
+ assertTrue(result.data!!.contains("EADDRINUSE"))
+ }
+
+ @Test
+ fun givenACommandThatExitedCleanly_whenReading_thenItSucceedsWithTheOutput() = runTest {
+ coEvery { terminal.readCommand(any()) } returns TerminalCommandResult.Completed(0, "done")
+
+ val result = handler.execute(mapOf("command_id" to "cmd-1"))
+
+ assertTrue(result.success)
+ assertTrue(result.message.contains("exited with code 0"))
+ assertEquals("done", result.data)
+ }
+
+ @Test
+ fun givenAnExitCodeTheTerminalCouldNotTell_whenReading_thenItDoesNotClaimMinusOne() = runTest {
+ coEvery { terminal.readCommand(any()) } returns TerminalCommandResult.Completed(-1, "done")
+
+ val result = handler.execute(mapOf("command_id" to "server"))
+
+ assertTrue(result.success)
+ assertTrue(result.message.contains("could not tell its exit code"))
+ assertFalse(result.message.contains("-1"))
+ }
+
+ @Test
+ fun givenAnUnknownCommandId_whenReading_thenItFailsNamingIt() = runTest {
+ coEvery { terminal.readCommand(any()) } returns null
+
+ val result = handler.execute(mapOf("command_id" to "cmd-9"))
+
+ assertFalse(result.success)
+ assertTrue(result.message.contains("No command with id \"cmd-9\""))
+ }
+}
diff --git a/plugins/AI-Core/src/test/kotlin/com/itsaky/androidide/plugins/aicore/tool/handlers/RunShellCommandHandlerTest.kt b/plugins/AI-Core/src/test/kotlin/com/itsaky/androidide/plugins/aicore/tool/handlers/RunShellCommandHandlerTest.kt
new file mode 100644
index 00000000..059bb936
--- /dev/null
+++ b/plugins/AI-Core/src/test/kotlin/com/itsaky/androidide/plugins/aicore/tool/handlers/RunShellCommandHandlerTest.kt
@@ -0,0 +1,186 @@
+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.aicore.tool.ApprovalPreview
+import com.itsaky.androidide.plugins.aicore.tool.Validation
+import com.itsaky.androidide.plugins.aicore.tool.pathsIn
+import com.itsaky.androidide.plugins.services.IdeTerminalService
+import com.itsaky.androidide.plugins.services.TerminalCommandResult
+import io.mockk.coEvery
+import io.mockk.coVerify
+import io.mockk.every
+import io.mockk.mockk
+import kotlinx.coroutines.awaitCancellation
+import kotlinx.coroutines.cancelAndJoin
+import kotlinx.coroutines.launch
+import kotlinx.coroutines.test.runCurrent
+import kotlinx.coroutines.test.runTest
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertFalse
+import org.junit.Assert.assertTrue
+import org.junit.Before
+import org.junit.Test
+
+/**
+ * Unit tests for [RunShellCommandHandler] — the tool that runs a shell command in the IDE's visible
+ * Terminal, so the agent runs a script itself instead of handing it to the user (ADFA-6339).
+ */
+class RunShellCommandHandlerTest {
+
+ private lateinit var context: PluginContext
+ private lateinit var services: ServiceRegistry
+ private lateinit var terminal: IdeTerminalService
+ private lateinit var handler: RunShellCommandHandler
+
+ @Before
+ fun setup() {
+ terminal = mockk()
+ services = mockk()
+ context = mockk()
+ every { context.services } returns services
+ every { context.logger } returns mockk(relaxed = true)
+ every { services.get(IdeTerminalService::class.java) } returns terminal
+ handler = RunShellCommandHandler(context)
+ }
+
+ private fun answerWith(result: TerminalCommandResult) {
+ coEvery { terminal.runInTerminal(any(), any()) } returns result
+ }
+
+ @Test
+ fun givenNoTerminalService_whenRunning_thenItFails() = runTest {
+ every { services.get(IdeTerminalService::class.java) } returns null
+
+ val result = handler.execute(mapOf("command" to "ls"))
+
+ assertFalse(result.success)
+ assertTrue(result.message.contains("not available"))
+ }
+
+ @Test
+ fun givenABlankCommand_whenValidating_thenItIsRejectedBeforeApproval() = runTest {
+ val validation = handler.validate(mapOf("command" to " "))
+
+ assertTrue(validation is Validation.Rejected)
+ }
+
+ @Test
+ fun givenNoCommand_whenRunning_thenNothingRuns() = runTest {
+ val result = handler.execute(emptyMap())
+
+ assertFalse(result.success)
+ coVerify(exactly = 0) { terminal.runInTerminal(any(), any()) }
+ }
+
+ @Test
+ fun givenACommandAndDirectory_whenRunning_thenTheTerminalGetsThemAsWritten() = runTest {
+ answerWith(TerminalCommandResult.Completed(0, "ok"))
+
+ handler.execute(mapOf("command" to "./scripts/check.sh --all", "working_directory" to " app "))
+
+ coVerify { terminal.runInTerminal("./scripts/check.sh --all", "app") }
+ }
+
+ @Test
+ fun givenNoDirectory_whenRunning_thenItRunsAtTheProjectRoot() = runTest {
+ answerWith(TerminalCommandResult.Completed(0, "ok"))
+
+ handler.execute(mapOf("command" to "pwd", "working_directory" to ""))
+
+ coVerify { terminal.runInTerminal("pwd", null) }
+ }
+
+ @Test
+ fun givenExitCodeZero_whenRunning_thenItSucceedsWithTheOutput() = runTest {
+ answerWith(TerminalCommandResult.Completed(0, "$ ls\nbuild.gradle.kts"))
+
+ val result = handler.execute(mapOf("command" to "ls"))
+
+ assertTrue(result.success)
+ assertEquals("$ ls\nbuild.gradle.kts", result.data)
+ }
+
+ @Test
+ fun givenANonZeroExitCode_whenRunning_thenItSucceedsWithTheCodeAndOutput() = runTest {
+ answerWith(TerminalCommandResult.Completed(1, "$ grep -rn TODO app/src"))
+
+ val result = handler.execute(mapOf("command" to "grep -rn TODO app/src"))
+
+ // grep finding nothing exits 1: that is the answer, not a failed tool call.
+ assertTrue(result.success)
+ assertTrue(result.message.contains("exited with code 1"))
+ assertEquals("$ grep -rn TODO app/src", result.data)
+ }
+
+ @Test
+ fun givenACommandStillRunning_whenRunning_thenItSucceedsAndSaysToLeaveItRunning() = runTest {
+ answerWith(TerminalCommandResult.Running("cmd-1", "AI Core 1", "$ npm start\nlistening on 3000"))
+
+ val result = handler.execute(mapOf("command" to "npm start"))
+
+ assertTrue(result.success)
+ assertTrue(result.message.contains("still running in Terminal session \"AI Core 1\""))
+ assertTrue(result.message.contains("command id \"cmd-1\""))
+ assertTrue(result.data!!.contains("Do not run it again"))
+ assertTrue(result.data!!.contains("leave it running unless the user asks you to stop it"))
+ assertTrue(result.data!!.endsWith("listening on 3000"))
+ }
+
+ @Test
+ fun givenACommandThatDidNotStart_whenRunning_thenItReportsTheReason() = runTest {
+ answerWith(TerminalCommandResult.NotStarted("The terminal environment is not installed"))
+
+ val result = handler.execute(mapOf("command" to "ls"))
+
+ assertFalse(result.success)
+ assertTrue(result.message.contains("did not start: The terminal environment is not installed"))
+ }
+
+ @Test
+ fun givenADirectoryOutsideTheProject_whenRunning_thenItIsRefusedWithTheReason() = runTest {
+ coEvery { terminal.runInTerminal(any(), any()) } throws
+ SecurityException("Working directory is outside the project root")
+
+ val result = handler.execute(mapOf("command" to "ls", "working_directory" to "/sdcard"))
+
+ assertFalse(result.success)
+ assertTrue(result.message.contains("outside the project root"))
+ }
+
+ @Test
+ fun givenTheShellTool_whenAskingForApproval_thenItIsShownAsACommandAndNeverSessionApproved() {
+ assertEquals(ApprovalPreview.SHELL_COMMAND, handler.approvalPreview)
+ assertFalse(handler.allowsSessionApproval)
+ }
+
+ @Test
+ fun givenTheShellTool_whenDispatched_thenTheWorkingDirectoryIsContainedToTheProject() {
+ assertEquals(listOf(RunShellCommandHandler.ARG_WORKING_DIRECTORY), handler.pathArgs)
+ assertFalse(handler.resolvesPathsInternally)
+ }
+
+ @Test
+ fun givenTheShellTool_whenDispatched_thenItMutatesTheProjectWithoutNamingAPath() {
+ assertTrue(handler.mutatesProject)
+ assertTrue(handler.pathsIn(mapOf("command" to "sed -i s/a/b/ A.kt", "working_directory" to "app")).isEmpty())
+ }
+
+ @Test
+ fun givenARunningCommand_whenTheAgentIsStopped_thenTheCancellationReachesTheTerminal() = runTest {
+ var cancelled = false
+ coEvery { terminal.runInTerminal(any(), any()) } coAnswers {
+ try {
+ awaitCancellation()
+ } finally {
+ cancelled = true
+ }
+ }
+
+ val job = launch { handler.execute(mapOf("command" to "sleep 100")) }
+ runCurrent()
+ job.cancelAndJoin()
+
+ assertTrue(cancelled)
+ }
+}
diff --git a/plugins/AI-Core/src/test/kotlin/com/itsaky/androidide/plugins/aicore/tool/handlers/ShellInvocationTest.kt b/plugins/AI-Core/src/test/kotlin/com/itsaky/androidide/plugins/aicore/tool/handlers/ShellInvocationTest.kt
new file mode 100644
index 00000000..cd253f41
--- /dev/null
+++ b/plugins/AI-Core/src/test/kotlin/com/itsaky/androidide/plugins/aicore/tool/handlers/ShellInvocationTest.kt
@@ -0,0 +1,26 @@
+package com.itsaky.androidide.plugins.aicore.tool.handlers
+
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertNull
+import org.junit.Test
+
+/** Unit tests for [ShellInvocation], a `run_shell_command` call as the host takes it. */
+class ShellInvocationTest {
+
+ @Test
+ fun givenACommandAndPaddedDirectory_whenParsed_thenTheCommandIsKeptAndTheDirectoryTrimmed() {
+ val invocation = ShellInvocation.from(mapOf("command" to " ./check.sh ", "working_directory" to " app "))
+
+ assertEquals(ShellInvocation(command = " ./check.sh ", workingDirectory = "app"), invocation)
+ }
+
+ @Test
+ fun givenABlankDirectory_whenParsed_thenItRunsAtTheProjectRoot() {
+ assertNull(ShellInvocation.from(mapOf("command" to "pwd", "working_directory" to " "))?.workingDirectory)
+ }
+
+ @Test
+ fun givenABlankCommand_whenParsed_thenThereIsNoInvocation() {
+ assertNull(ShellInvocation.from(mapOf("command" to " ", "working_directory" to "app")))
+ }
+}
diff --git a/plugins/AI-Core/src/test/kotlin/com/itsaky/androidide/plugins/aicore/tool/handlers/StopTerminalCommandHandlerTest.kt b/plugins/AI-Core/src/test/kotlin/com/itsaky/androidide/plugins/aicore/tool/handlers/StopTerminalCommandHandlerTest.kt
new file mode 100644
index 00000000..741253a5
--- /dev/null
+++ b/plugins/AI-Core/src/test/kotlin/com/itsaky/androidide/plugins/aicore/tool/handlers/StopTerminalCommandHandlerTest.kt
@@ -0,0 +1,90 @@
+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.aicore.tool.Validation
+import com.itsaky.androidide.plugins.services.IdeTerminalService
+import com.itsaky.androidide.plugins.services.TerminalCommandResult
+import io.mockk.coEvery
+import io.mockk.every
+import io.mockk.mockk
+import kotlinx.coroutines.test.runTest
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertFalse
+import org.junit.Assert.assertTrue
+import org.junit.Before
+import org.junit.Test
+
+/**
+ * Unit tests for [StopTerminalCommandHandler]: the tool that stops a command run_shell_command left
+ * running, such as `ping`, which the agent otherwise told the user it could not do.
+ */
+class StopTerminalCommandHandlerTest {
+
+ private lateinit var context: PluginContext
+ private lateinit var services: ServiceRegistry
+ private lateinit var terminal: IdeTerminalService
+ private lateinit var handler: StopTerminalCommandHandler
+
+ @Before
+ fun setup() {
+ terminal = mockk()
+ services = mockk()
+ context = mockk()
+ every { context.services } returns services
+ every { context.logger } returns mockk(relaxed = true)
+ every { services.get(IdeTerminalService::class.java) } returns terminal
+ handler = StopTerminalCommandHandler(context)
+ }
+
+ @Test
+ fun givenTheStopTool_whenAskingForApproval_thenItIsAskedEveryTime() {
+ assertTrue(handler.requiresApproval)
+ assertFalse(handler.allowsSessionApproval)
+ }
+
+ @Test
+ fun givenNoCommandId_whenValidating_thenItIsRejected() = runTest {
+ assertTrue(handler.validate(mapOf("command_id" to " ")) is Validation.Rejected)
+ }
+
+ @Test
+ fun givenARunningCommand_whenStopped_thenItSucceedsEvenWithTheInterruptsExitCode() = runTest {
+ coEvery { terminal.stopCommand("cmd-1", any()) } returns
+ TerminalCommandResult.Completed(130, "64 bytes from 1.1.1.1\n^C")
+
+ val result = handler.execute(mapOf("command_id" to "cmd-1"))
+
+ assertTrue(result.success)
+ assertTrue(result.message.contains("has stopped: it exited with code 130"))
+ assertTrue(result.data!!.contains("^C"))
+ }
+
+ @Test
+ fun givenTheReadTool_whenComparingArguments_thenStopTakesTheSameCommandIds() {
+ assertEquals(ReadTerminalCommandHandler(context).argAliases, handler.argAliases)
+ }
+
+ @Test
+ fun givenACommandThatIgnoresCtrlC_whenStopped_thenItFailsAndNamesTheSessionToStopItIn() = runTest {
+ coEvery { terminal.stopCommand(any(), any()) } returns
+ TerminalCommandResult.Running("cmd-1", "AI Core 2", "still here")
+
+ val result = handler.execute(mapOf("command_id" to "cmd-1"))
+
+ assertFalse(result.success)
+ assertTrue(result.message.contains("still running"))
+ assertTrue(result.error_details!!.contains("\"AI Core 2\""))
+ assertTrue(result.error_details!!.contains("still here"))
+ }
+
+ @Test
+ fun givenAnUnknownCommandId_whenStopped_thenItFailsNamingIt() = runTest {
+ coEvery { terminal.stopCommand(any(), any()) } returns null
+
+ val result = handler.execute(mapOf("command_id" to "cmd-9"))
+
+ assertFalse(result.success)
+ assertTrue(result.message.contains("\"cmd-9\""))
+ }
+}
diff --git a/plugins/AI-Core/src/test/kotlin/com/itsaky/androidide/plugins/aicore/tool/handlers/TerminalOutputTest.kt b/plugins/AI-Core/src/test/kotlin/com/itsaky/androidide/plugins/aicore/tool/handlers/TerminalOutputTest.kt
new file mode 100644
index 00000000..bbaccb60
--- /dev/null
+++ b/plugins/AI-Core/src/test/kotlin/com/itsaky/androidide/plugins/aicore/tool/handlers/TerminalOutputTest.kt
@@ -0,0 +1,32 @@
+package com.itsaky.androidide.plugins.aicore.tool.handlers
+
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertTrue
+import org.junit.Test
+
+/** Unit tests for [TerminalOutput], the slice of a shell command's output the model reads. */
+class TerminalOutputTest {
+
+ @Test
+ fun givenLongOutput_whenShaped_thenTheEndIsKeptAndTheCutIsMarked() {
+ val output = "a".repeat(TerminalOutput.MAX_CHARS) + "THE END"
+
+ val tail = TerminalOutput.tailOf(output)
+
+ assertTrue(tail.startsWith("$TRUNCATION_MARKER\n"))
+ assertTrue(tail.endsWith("THE END"))
+ assertEquals(TRUNCATION_MARKER.length + 1 + TerminalOutput.MAX_CHARS, tail.length)
+ }
+
+ @Test
+ fun givenOutputAtTheCap_whenShaped_thenItIsKeptWhole() {
+ val output = "b".repeat(TerminalOutput.MAX_CHARS)
+
+ assertEquals(output, TerminalOutput.tailOf(output))
+ }
+
+ @Test
+ fun givenNoOutput_whenShaped_thenItSaysSo() {
+ assertEquals("(No output)", TerminalOutput.tailOf(" "))
+ }
+}
diff --git a/plugins/AI-Core/src/test/kotlin/com/itsaky/androidide/plugins/aicore/viewmodel/AgentReplyRendererTest.kt b/plugins/AI-Core/src/test/kotlin/com/itsaky/androidide/plugins/aicore/viewmodel/AgentReplyRendererTest.kt
index 85fdb15e..e1637d95 100644
--- a/plugins/AI-Core/src/test/kotlin/com/itsaky/androidide/plugins/aicore/viewmodel/AgentReplyRendererTest.kt
+++ b/plugins/AI-Core/src/test/kotlin/com/itsaky/androidide/plugins/aicore/viewmodel/AgentReplyRendererTest.kt
@@ -155,6 +155,52 @@ class AgentReplyRendererTest {
assertFalse(AgentReplyRenderer.isSilentTurn(respond("message" to "Done."), TERMINAL))
}
+ @Test
+ fun givenARecapOfAProseAnswer_whenChecked_thenItIsSilencedSoTheAnswerShowsOnce() {
+ val recap = respond("message" to "The ls command ran successfully.")
+
+ assertTrue(AgentReplyRenderer.isSilentTurn(recap, TERMINAL, recapsShownAnswer = true))
+ }
+
+ @Test
+ fun givenAMultiLineAnswerAfterTheFinishRequest_whenChecked_thenItIsShownAsTheRealAnswer() {
+ val answer = respond("message" to "The build passed.\n\n3 tests ran, none failed.")
+
+ assertFalse(AgentReplyRenderer.isSilentTurn(answer, TERMINAL, recapsShownAnswer = true))
+ }
+
+ @Test
+ fun givenProseOnlyThinking_whenCheckedForAnAnswer_thenNoneWasShown() {
+ assertFalse(AgentReplyRenderer.showsAnswer("check the output\n", NO_RESPONSE))
+ assertFalse(AgentReplyRenderer.showsAnswer("still going", NO_RESPONSE))
+ }
+
+ @Test
+ fun givenTheNoResponseFallback_whenCheckedForAnAnswer_thenNoneWasShown() {
+ assertFalse(AgentReplyRenderer.showsAnswer(NO_RESPONSE, NO_RESPONSE))
+ }
+
+ @Test
+ fun givenProseBesideThinking_whenCheckedForAnAnswer_thenItWasShown() {
+ assertTrue(AgentReplyRenderer.showsAnswer("hmThe tests pass.", NO_RESPONSE))
+ }
+
+ @Test
+ fun givenARecapAfterAFailedTool_whenChecked_thenItKeepsItsBubbleForTheWarning() {
+ val recap = respond("message" to "Done.")
+
+ assertFalse(
+ AgentReplyRenderer.isSilentTurn(recap, TERMINAL, recapsShownAnswer = true, lastToolFailed = true)
+ )
+ }
+
+ @Test
+ fun givenAnAnswerBesideARealCallAfterAProseAnswer_whenChecked_thenItIsNotSilenced() {
+ val calls = respond("message" to "Running it now.") + ToolCall("run_shell_command", mapOf("command" to "ls"))
+
+ assertFalse(AgentReplyRenderer.isSilentTurn(calls, TERMINAL, recapsShownAnswer = true))
+ }
+
@Test
fun givenNoToolCallsAtAll_whenChecked_thenTheProseTurnIsNotSilent() {
// A plain answer parses to no calls; silencing it would drop the reply entirely.