From 44c2a7ba67cc7cc9af563f43a3bacba5d3933405 Mon Sep 17 00:00:00 2001 From: John Trujillo Date: Wed, 7 Oct 2026 11:29:52 -0500 Subject: [PATCH 1/3] feat(ai-agent): let the agent run, read and stop shell commands in the Terminal Add run_shell_command, read_terminal_session and stop_terminal_session on IdeTerminalService; run and stop always ask first. Handlers declare their approval preview; working_directory is contained before approval; each answer shows once. Fix the OpenAI backend dropping all tools after one model refused them. Bumps AI-Core to 3.4.0. Refs: ADFA-6339 --- .../aiagentopenai/backend/OpenAiBackend.kt | 44 +++-- .../src/main/res/values/strings.xml | 2 +- plugins/AI-Core/ai-core.html | 9 +- plugins/AI-Core/build.gradle.kts | 4 +- .../AI-Core/src/main/assets/docs/index.html | 11 +- .../main/assets/prompts/tool_descriptions.yml | 29 +++ .../fragments/ApprovalDialogFragment.kt | 39 ++-- .../plugins/aicore/fragments/ApprovalKind.kt | 44 +++++ .../aicore/fragments/ApprovalTextFormatter.kt | 15 ++ .../plugins/aicore/fragments/ChatFragment.kt | 2 +- .../aicore/prompt/ToolResultsPrompt.kt | 24 ++- .../plugins/aicore/tool/ApprovalPreview.kt | 16 ++ .../aicore/tool/ToolApprovalManager.kt | 8 +- .../plugins/aicore/tool/ToolHandler.kt | 4 + .../tool/handlers/BuiltInToolHandlers.kt | 14 +- .../aicore/tool/handlers/EditFileHandler.kt | 2 + .../handlers/ReadTerminalSessionHandler.kt | 98 ++++++++++ .../tool/handlers/RunShellCommandHandler.kt | 116 ++++++++++++ .../aicore/tool/handlers/ShellInvocation.kt | 26 +++ .../handlers/StopTerminalSessionHandler.kt | 77 ++++++++ .../aicore/tool/handlers/TerminalOutput.kt | 19 ++ .../aicore/tool/handlers/TerminalToolCall.kt | 50 +++++ .../aicore/viewmodel/AgentReplyRenderer.kt | 17 +- .../aicore/viewmodel/AgentRunReporter.kt | 7 +- .../plugins/aicore/viewmodel/ChatViewModel.kt | 11 +- .../AI-Core/src/main/res/values/strings.xml | 2 + .../aicore/fragments/ApprovalKindTest.kt | 30 +++ .../fragments/ApprovalTextFormatterTest.kt | 27 +++ .../aicore/prompt/ToolResultsPromptTest.kt | 35 ++++ .../plugins/aicore/tool/ExecutorTest.kt | 19 ++ .../handlers/BuiltInHandlerApprovalTest.kt | 6 +- .../ReadTerminalSessionHandlerTest.kt | 109 +++++++++++ .../handlers/RunShellCommandHandlerTest.kt | 177 ++++++++++++++++++ .../tool/handlers/ShellInvocationTest.kt | 26 +++ .../StopTerminalSessionHandlerTest.kt | 88 +++++++++ .../tool/handlers/TerminalOutputTest.kt | 32 ++++ .../viewmodel/AgentReplyRendererTest.kt | 23 +++ 37 files changed, 1204 insertions(+), 58 deletions(-) create mode 100644 plugins/AI-Core/src/main/kotlin/com/itsaky/androidide/plugins/aicore/fragments/ApprovalKind.kt create mode 100644 plugins/AI-Core/src/main/kotlin/com/itsaky/androidide/plugins/aicore/tool/ApprovalPreview.kt create mode 100644 plugins/AI-Core/src/main/kotlin/com/itsaky/androidide/plugins/aicore/tool/handlers/ReadTerminalSessionHandler.kt create mode 100644 plugins/AI-Core/src/main/kotlin/com/itsaky/androidide/plugins/aicore/tool/handlers/RunShellCommandHandler.kt create mode 100644 plugins/AI-Core/src/main/kotlin/com/itsaky/androidide/plugins/aicore/tool/handlers/ShellInvocation.kt create mode 100644 plugins/AI-Core/src/main/kotlin/com/itsaky/androidide/plugins/aicore/tool/handlers/StopTerminalSessionHandler.kt create mode 100644 plugins/AI-Core/src/main/kotlin/com/itsaky/androidide/plugins/aicore/tool/handlers/TerminalOutput.kt create mode 100644 plugins/AI-Core/src/main/kotlin/com/itsaky/androidide/plugins/aicore/tool/handlers/TerminalToolCall.kt create mode 100644 plugins/AI-Core/src/test/kotlin/com/itsaky/androidide/plugins/aicore/fragments/ApprovalKindTest.kt create mode 100644 plugins/AI-Core/src/test/kotlin/com/itsaky/androidide/plugins/aicore/tool/handlers/ReadTerminalSessionHandlerTest.kt create mode 100644 plugins/AI-Core/src/test/kotlin/com/itsaky/androidide/plugins/aicore/tool/handlers/RunShellCommandHandlerTest.kt create mode 100644 plugins/AI-Core/src/test/kotlin/com/itsaky/androidide/plugins/aicore/tool/handlers/ShellInvocationTest.kt create mode 100644 plugins/AI-Core/src/test/kotlin/com/itsaky/androidide/plugins/aicore/tool/handlers/StopTerminalSessionHandlerTest.kt create mode 100644 plugins/AI-Core/src/test/kotlin/com/itsaky/androidide/plugins/aicore/tool/handlers/TerminalOutputTest.kt 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..4ea89eb3 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 @@ -54,6 +54,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 +117,11 @@ 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. + * The server and model that answered a tool declaration with a refusal, so the next 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 var toolsRejectedBy: ServerModel? = null /** * 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() || toolsRejectedBy == target) { attempt(emptyList()) return } @@ -853,16 +860,21 @@ 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 = 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) + ) context.logger.warn( - "OpenAiBackend: $baseUrl does not accept tool declarations; " + - "the agent cannot call tools on this server" + "OpenAiBackend: ${target.model} on ${target.baseUrl} does not accept tool " + + "declarations; the agent cannot call tools with it" ) // 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 flag above short-circuits every later turn. + notifyToolsUnsupported(target) attempt(emptyList()) } } @@ -871,11 +883,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..5bd6e3f5 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 does not support tool calling, so the AI can answer but cannot use its tools. Pick another model. 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..e7128af6 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,7 +140,8 @@

    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 + the IDE's own build service, runs shell commands in the IDE's Terminal, 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 @@ -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 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..604e1949 100644 --- a/plugins/AI-Core/src/main/assets/docs/index.html +++ b/plugins/AI-Core/src/main/assets/docs/index.html @@ -111,6 +111,13 @@

    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 share one Terminal session. A command still running after + 30 seconds, such as a server, is left running, and the next command opens a + second session beside it; the agent can check on it later without asking, and + asks before stopping it, touching only its own sessions. 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 +165,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..34e9c4f2 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,35 @@ 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". + 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. 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_session: + 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: + session: The Terminal session name the run_shell_command result reported. + stop_terminal_session: + # 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: + session: The Terminal session name 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..8a5a2e95 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 @@ -13,14 +14,13 @@ import com.itsaky.androidide.plugins.aicore.plugin.AiCorePlugin import com.itsaky.androidide.plugins.aicore.R 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 +57,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_KIND = "kind" + 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 kind = ApprovalKind.of(request.preview) return ApprovalDialogFragment().apply { arguments = Bundle().apply { // The registered name, not the provider's: it is the tool that will actually @@ -77,12 +79,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_KIND, kind.name) + putString(ARG_ARGS, kind.format(request.args, resources)) + putBoolean(ARG_ALLOWS_ALWAYS_ALLOW, request.allowsSessionApproval) } } } @@ -94,7 +93,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 kind = ApprovalKind.named(arguments?.getString(ARG_KIND)) + val isEdit = kind == ApprovalKind.EDIT + val allowsAlwaysAllow = arguments?.getBoolean(ARG_ALLOWS_ALWAYS_ALLOW) ?: false val message = buildString { append(getString(R.string.approval_header)) @@ -114,7 +115,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(kind.argsLabel)) append("\n") append(argsText) } @@ -142,9 +143,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() + } } } diff --git a/plugins/AI-Core/src/main/kotlin/com/itsaky/androidide/plugins/aicore/fragments/ApprovalKind.kt b/plugins/AI-Core/src/main/kotlin/com/itsaky/androidide/plugins/aicore/fragments/ApprovalKind.kt new file mode 100644 index 00000000..19150e27 --- /dev/null +++ b/plugins/AI-Core/src/main/kotlin/com/itsaky/androidide/plugins/aicore/fragments/ApprovalKind.kt @@ -0,0 +1,44 @@ +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 a pending tool call, one per [ApprovalPreview] the tool's + * handler declares. Whether "Always Allow" is offered is the request's, not the kind's. + * + * @property argsLabel the heading above the formatted arguments. + */ +internal enum class ApprovalKind(@StringRes val argsLabel: Int) { + EDIT(R.string.approval_proposed_change) { + override fun format(args: Map, resources: Resources) = + ApprovalTextFormatter.formatEdit(args) + }, + SHELL_COMMAND(R.string.approval_command) { + override fun format(args: Map, resources: Resources) = + ApprovalTextFormatter.formatShellCommand(args) { directory -> + resources.getString(R.string.approval_working_directory, directory) + } + }, + OTHER(R.string.approval_args) { + override fun format(args: Map, resources: Resources) = + ApprovalTextFormatter.formatArgs(args) + }; + + /** The call's arguments as the dialog shows them; [resources] words any label among them. */ + abstract fun format(args: Map, resources: Resources): String + + companion object { + /** The kind that renders [preview]. */ + fun of(preview: ApprovalPreview): ApprovalKind = when (preview) { + ApprovalPreview.EDIT -> EDIT + ApprovalPreview.SHELL_COMMAND -> SHELL_COMMAND + ApprovalPreview.ARGS -> OTHER + } + + /** The kind saved as [name] in the dialog's arguments; [OTHER] when it is missing or unknown. */ + fun named(name: String?): ApprovalKind = entries.firstOrNull { it.name == name } ?: OTHER + } +} 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..150207c2 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 @@ -47,6 +48,20 @@ object ApprovalTextFormatter { } } + /** + * Renders a shell command exactly as it will run, uncut and unescaped, since a hidden line would + * still run. A directory outside the project root goes above it as a label, not as a command. + * @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 "" + return invocation.workingDirectory + ?.let { directory -> "${directoryLabel(directory)}\n\n${invocation.command}" } + ?: invocation.command + } + /** * 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/prompt/ToolResultsPrompt.kt b/plugins/AI-Core/src/main/kotlin/com/itsaky/androidide/plugins/aicore/prompt/ToolResultsPrompt.kt index 9bf458b7..e44bd8c3 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.ReadTerminalSessionHandler +import com.itsaky.androidide.plugins.aicore.tool.handlers.RunShellCommandHandler +import com.itsaky.androidide.plugins.aicore.tool.handlers.StopTerminalSessionHandler +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, + ReadTerminalSessionHandler.TOOL_NAME to SHELL_CHAR_LIMIT, + StopTerminalSessionHandler.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/ToolHandler.kt b/plugins/AI-Core/src/main/kotlin/com/itsaky/androidide/plugins/aicore/tool/ToolHandler.kt index 8c68c870..edde5d8d 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 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..c351b2b5 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), + ReadTerminalSessionHandler(context), + StopTerminalSessionHandler(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/ReadTerminalSessionHandler.kt b/plugins/AI-Core/src/main/kotlin/com/itsaky/androidide/plugins/aicore/tool/handlers/ReadTerminalSessionHandler.kt new file mode 100644 index 00000000..e8c7bf99 --- /dev/null +++ b/plugins/AI-Core/src/main/kotlin/com/itsaky/androidide/plugins/aicore/tool/handlers/ReadTerminalSessionHandler.kt @@ -0,0 +1,98 @@ +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.TerminalToolCall.Companion.TRACE_STAGE +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 Terminal sessions, never the user's. + */ +class ReadTerminalSessionHandler( + pluginContext: PluginContext, +) : ToolHandler { + override val toolName = TOOL_NAME + + // Reads output of a command the user already approved; changes nothing. + override val requiresApproval = false + + override val parametersSchema = ToolSchema.objectOf( + ARG_SESSION to ToolSchema.string(), + required = listOf(ARG_SESSION), + ) + + override val argAliases = SESSION_ALIASES + + private val terminalCall = TerminalToolCall(pluginContext, toolName) + + override suspend fun validate(args: Map): Validation = + sessionOf(args)?.let { Validation.Accepted(args) } ?: Validation.Rejected(NO_SESSION) + + override suspend fun execute(args: Map): ToolResult { + val sessionName = sessionOf(args) ?: return NO_SESSION + + return terminalCall.run { terminal -> + val state = terminal.readSession(sessionName) + AgentTrace.stage(TRACE_STAGE, "$toolName session=$sessionName state=${state?.let { it::class.simpleName }}") + resultFor(sessionName, state) + } + } + + companion object { + const val TOOL_NAME = "read_terminal_session" + const val ARG_SESSION = "session" + + /** The names models reach for instead of [ARG_SESSION]; shared with [StopTerminalSessionHandler]. */ + internal val SESSION_ALIASES = mapOf( + "session_name" to ARG_SESSION, + "name" to ARG_SESSION, + "terminal" to ARG_SESSION, + ) + + internal val NO_SESSION = ToolResult.failure( + "session is required", + "Give the Terminal session name a ${RunShellCommandHandler.TOOL_NAME} result reported." + ) + + /** The session [args] names, or null when it names none. */ + internal fun sessionOf(args: Map): String? = + args[ARG_SESSION]?.toString()?.trim()?.takeIf(String::isNotEmpty) + + /** The failure for a session the host does not know, shared with [StopTerminalSessionHandler]. */ + internal fun unknownSession(sessionName: String): ToolResult = ToolResult.failure( + "No Terminal session named \"$sessionName\"", + "It was closed, or the name is wrong. Use the session name a " + + "${RunShellCommandHandler.TOOL_NAME} result reported." + ) + + /** + * The tool result for the [state] of session [sessionName]. Exit code 0 succeeds and another + * code fails, as in [RunShellCommandHandler]; a command still running succeeds. + */ + // 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(sessionName: String, state: TerminalCommandResult?): ToolResult = when (state) { + null -> unknownSession(sessionName) + is TerminalCommandResult.Running -> ToolResult.success( + message = "The command in \"$sessionName\" is still running", + data = TerminalOutput.tailOf(state.output) + ) + is TerminalCommandResult.Completed -> { + val message = "The command in \"$sessionName\" exited with code ${state.exitCode}" + val output = TerminalOutput.tailOf(state.output) + if (state.exitCode == 0) { + ToolResult.success(message = message, data = output) + } else { + ToolResult.failure(message, output) + } + } + else -> ToolResult.failure("Session \"$sessionName\" is in an unknown state: $state") + } + } +} 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..74fae086 --- /dev/null +++ b/plugins/AI-Core/src/main/kotlin/com/itsaky/androidide/plugins/aicore/tool/handlers/RunShellCommandHandler.kt @@ -0,0 +1,116 @@ +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.TerminalToolCall.Companion.TRACE_STAGE +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 + + // Checked against the project root before the user is asked, so a doomed call costs no dialog. + override val pathArgs = listOf(ARG_WORKING_DIRECTORY) + + 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 = TerminalToolCall(pluginContext, toolName) + + 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(TRACE_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( + TRACE_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 session name is what " + + "${ReadTerminalSessionHandler.TOOL_NAME} and ${StopTerminalSessionHandler.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]: exit code 0 succeeds, another code fails, and a command + * still running (a server, a watch task) succeeds with what it printed 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 -> { + val output = TerminalOutput.tailOf(outcome.output) + if (outcome.exitCode == 0) { + ToolResult.success(message = "Command exited with code 0", data = output) + } else { + ToolResult.failure("Command exited with code ${outcome.exitCode}", output) + } + } + is TerminalCommandResult.Running -> ToolResult.success( + message = "Command is still running in Terminal session \"${outcome.sessionName}\"", + 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/StopTerminalSessionHandler.kt b/plugins/AI-Core/src/main/kotlin/com/itsaky/androidide/plugins/aicore/tool/handlers/StopTerminalSessionHandler.kt new file mode 100644 index 00000000..163c52dc --- /dev/null +++ b/plugins/AI-Core/src/main/kotlin/com/itsaky/androidide/plugins/aicore/tool/handlers/StopTerminalSessionHandler.kt @@ -0,0 +1,77 @@ +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.ReadTerminalSessionHandler.Companion.ARG_SESSION +import com.itsaky.androidide.plugins.aicore.tool.handlers.ReadTerminalSessionHandler.Companion.NO_SESSION +import com.itsaky.androidide.plugins.aicore.tool.handlers.ReadTerminalSessionHandler.Companion.SESSION_ALIASES +import com.itsaky.androidide.plugins.aicore.tool.handlers.ReadTerminalSessionHandler.Companion.sessionOf +import com.itsaky.androidide.plugins.aicore.tool.handlers.ReadTerminalSessionHandler.Companion.unknownSession +import com.itsaky.androidide.plugins.aicore.tool.handlers.TerminalToolCall.Companion.TRACE_STAGE +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 sessions. + */ +class StopTerminalSessionHandler( + pluginContext: PluginContext, +) : ToolHandler { + override val toolName = 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 val parametersSchema = ToolSchema.objectOf( + ARG_SESSION to ToolSchema.string(), + required = listOf(ARG_SESSION), + ) + + override val argAliases = SESSION_ALIASES + + private val terminalCall = TerminalToolCall(pluginContext, toolName) + + override suspend fun validate(args: Map): Validation = + sessionOf(args)?.let { Validation.Accepted(args) } ?: Validation.Rejected(NO_SESSION) + + override suspend fun execute(args: Map): ToolResult { + val sessionName = sessionOf(args) ?: return NO_SESSION + + return terminalCall.run { terminal -> + val state = terminal.stopSession(sessionName) + AgentTrace.stage(TRACE_STAGE, "$toolName session=$sessionName state=${state?.let { it::class.simpleName }}") + resultFor(sessionName, state) + } + } + + companion object { + const val TOOL_NAME = "stop_terminal_session" + + /** + * The tool result for the [state] session [sessionName] is in after the stop. Any exit + * code succeeds, since Ctrl-C itself makes most commands exit non-zero. + */ + // 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(sessionName: String, state: TerminalCommandResult?): ToolResult = when (state) { + null -> unknownSession(sessionName) + is TerminalCommandResult.Completed -> ToolResult.success( + message = "The command in \"$sessionName\" has stopped (exit code ${state.exitCode})", + data = TerminalOutput.tailOf(state.output) + ) + is TerminalCommandResult.Running -> ToolResult.failure( + "The command in \"$sessionName\" is still running; it did not stop on Ctrl-C", + "Tell the user to stop it in the Terminal. Its latest output:\n" + + TerminalOutput.tailOf(state.output) + ) + else -> ToolResult.failure("Session \"$sessionName\" is in an unknown state: $state") + } + } +} 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..3b285c08 --- /dev/null +++ b/plugins/AI-Core/src/main/kotlin/com/itsaky/androidide/plugins/aicore/tool/handlers/TerminalOutput.kt @@ -0,0 +1,19 @@ +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)}" + } +} diff --git a/plugins/AI-Core/src/main/kotlin/com/itsaky/androidide/plugins/aicore/tool/handlers/TerminalToolCall.kt b/plugins/AI-Core/src/main/kotlin/com/itsaky/androidide/plugins/aicore/tool/handlers/TerminalToolCall.kt new file mode 100644 index 00000000..315f48b3 --- /dev/null +++ b/plugins/AI-Core/src/main/kotlin/com/itsaky/androidide/plugins/aicore/tool/handlers/TerminalToolCall.kt @@ -0,0 +1,50 @@ +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.services.IdeTerminalService +import kotlinx.coroutines.CancellationException + +/** + * What the shell tools share around a call to the host's [IdeTerminalService]: finding it, and + * turning a host error into a result the model reads. Stop still cancels the call. + */ +internal class TerminalToolCall( + private val pluginContext: PluginContext, + private val toolName: String, +) { + /** Runs [block] with the terminal service, or fails when the host has none. */ + suspend fun run(block: suspend (IdeTerminalService) -> ToolResult): ToolResult = try { + pluginContext.services.get(IdeTerminalService::class.java) + ?.let { terminal -> block(terminal) } + ?: unavailable() + } catch (ce: CancellationException) { + // An Exception on the JVM, so the catches below would report Stop as a command failure. + throw ce + } catch (e: SecurityException) { + // A working directory outside the project, or the permission missing: the model can fix the first. + AgentTrace.refusal(TRACE_STAGE, "$toolName refused", e.toString()) + ToolResult.failure("Command refused: ${e.message ?: "not allowed"}") + } catch (e: Exception) { + AgentTrace.refusal(TRACE_STAGE, "$toolName failed", e.toString()) + pluginContext.logger.error("$toolName failed", e) + ToolResult.failure( + "Error: ${e.javaClass.simpleName}", + "${e.message ?: "Unknown error"}\n\n${e.stackTraceToString()}" + ) + } + + private fun unavailable(): ToolResult { + AgentTrace.refusal(TRACE_STAGE, "$toolName rejected", "IdeTerminalService not available") + return ToolResult.failure( + "Terminal service not available", + "The IDE terminal service is not available." + ) + } + + companion object { + /** The [AgentTrace] stage both shell tools log under. */ + const val TRACE_STAGE = "SHELL" + } +} 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..5ea139b2 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 @@ -20,16 +20,25 @@ 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 only recaps a prose answer already on screen; 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 answer. + * @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 + return recapsShownAnswer && !lastToolFailed && answers.size == toolCalls.size } /** 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..b014859d 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,10 @@ 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 + /** * 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 +964,7 @@ class ChatViewModel( AgentTrace.beginRun(currentBackendId, userMessage, runFiles.size) // Reset per-run tool tracking. lastToolFailedThisRun = false + askedToFinishAfterProse = false activityMessageId = null runToolNames.clear() runToolLog.clear() @@ -1067,7 +1072,7 @@ class ChatViewModel( tools.router.getHandler(call.name)?.mutatesProject == true }, requiredTool = requiredTool, - events = AgentRunReporter(runNotices), + events = AgentRunReporter(runNotices) { askedToFinishAfterProse = true }, ) if (loopResult.completed && generationEpoch.get() == epoch) { runCodeReply?.let { draft -> reviewAnswer(llmService, userMessage, draft, history, epoch) } @@ -1531,8 +1536,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) } 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/ApprovalKindTest.kt b/plugins/AI-Core/src/test/kotlin/com/itsaky/androidide/plugins/aicore/fragments/ApprovalKindTest.kt new file mode 100644 index 00000000..ad9e0b6e --- /dev/null +++ b/plugins/AI-Core/src/test/kotlin/com/itsaky/androidide/plugins/aicore/fragments/ApprovalKindTest.kt @@ -0,0 +1,30 @@ +package com.itsaky.androidide.plugins.aicore.fragments + +import com.itsaky.androidide.plugins.aicore.tool.ApprovalPreview +import org.junit.Assert.assertEquals +import org.junit.Test + +/** + * Unit tests for [ApprovalKind]: the dialog picks its presentation from the preview the handler + * declares, never from the tool's name. + */ +class ApprovalKindTest { + + @Test + fun givenEachPreview_whenItsKindIsPicked_thenTheMatchingKindComesBack() { + assertEquals(ApprovalKind.EDIT, ApprovalKind.of(ApprovalPreview.EDIT)) + assertEquals(ApprovalKind.SHELL_COMMAND, ApprovalKind.of(ApprovalPreview.SHELL_COMMAND)) + assertEquals(ApprovalKind.OTHER, ApprovalKind.of(ApprovalPreview.ARGS)) + } + + @Test + fun givenASavedKindName_whenRestored_thenTheSameKindComesBack() { + assertEquals(ApprovalKind.SHELL_COMMAND, ApprovalKind.named(ApprovalKind.SHELL_COMMAND.name)) + } + + @Test + fun givenAMissingOrUnknownName_whenRestored_thenItFallsBackToOther() { + assertEquals(ApprovalKind.OTHER, ApprovalKind.named(null)) + assertEquals(ApprovalKind.OTHER, ApprovalKind.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..84f3ef90 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,30 @@ 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 ")) + } } 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..fc561686 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,10 @@ 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.ReadTerminalSessionHandler +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 kotlinx.coroutines.runBlocking import org.junit.Assert.assertEquals import org.junit.Assert.assertFalse @@ -170,4 +174,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("AI Core 1", output)) + + val turn = render(listOf(ToolCall(RunShellCommandHandler.TOOL_NAME, emptyMap())), listOf(result)) + + assertTrue(turn.contains("listening on 3000\n")) + } + + @Test + fun givenAFailedCommandWithFullOutput_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 givenASessionReadWithFullOutput_whenRendering_thenItsLatestOutputIsNotCut() { + val output = "x".repeat(TerminalOutput.MAX_CHARS * 2) + "GET / 200" + val result = ReadTerminalSessionHandler.resultFor("AI Core 1", TerminalCommandResult.Running("AI Core 1", output)) + + val turn = render(listOf(ToolCall(ReadTerminalSessionHandler.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/handlers/BuiltInHandlerApprovalTest.kt b/plugins/AI-Core/src/test/kotlin/com/itsaky/androidide/plugins/aicore/tool/handlers/BuiltInHandlerApprovalTest.kt index 955fa0bd..576d59cb 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,6 +24,8 @@ class BuiltInHandlerApprovalTest { "run_app", "gradle_sync", "run_gradle_task", + "run_shell_command", + "stop_terminal_session", "generate_from_template", ) @@ -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_session", ) /** 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/ReadTerminalSessionHandlerTest.kt b/plugins/AI-Core/src/test/kotlin/com/itsaky/androidide/plugins/aicore/tool/handlers/ReadTerminalSessionHandlerTest.kt new file mode 100644 index 00000000..10ae39d4 --- /dev/null +++ b/plugins/AI-Core/src/test/kotlin/com/itsaky/androidide/plugins/aicore/tool/handlers/ReadTerminalSessionHandlerTest.kt @@ -0,0 +1,109 @@ +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 [ReadTerminalSessionHandler] — the tool that checks on a command + * run_shell_command left running, such as a dev server (ADFA-6339). + */ +class ReadTerminalSessionHandlerTest { + + private lateinit var context: PluginContext + private lateinit var services: ServiceRegistry + private lateinit var terminal: IdeTerminalService + private lateinit var handler: ReadTerminalSessionHandler + + @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 = ReadTerminalSessionHandler(context) + } + + @Test + fun givenNoTerminalService_whenReading_thenItFails() = runTest { + every { services.get(IdeTerminalService::class.java) } returns null + + val result = handler.execute(mapOf("session" to "AI Core 1")) + + assertFalse(result.success) + assertTrue(result.message.contains("not available")) + } + + @Test + fun givenNoSession_whenValidating_thenItIsRejected() = runTest { + assertTrue(handler.validate(mapOf("session" to " ")) is Validation.Rejected) + } + + @Test + fun givenASessionName_whenReading_thenTheHostGetsItTrimmed() = runTest { + coEvery { terminal.readSession(any()) } returns null + + handler.execute(mapOf("session" to " AI Core 1 ")) + + coVerify { terminal.readSession("AI Core 1") } + } + + @Test + fun givenARunningCommand_whenReading_thenItSaysSoWithTheOutput() = runTest { + coEvery { terminal.readSession(any()) } returns + TerminalCommandResult.Running("AI Core 1", "$ npm start\nlistening on 3000") + + val result = handler.execute(mapOf("session" to "AI Core 1")) + + assertTrue(result.success) + assertTrue(result.message.contains("still running")) + assertEquals("$ npm start\nlistening on 3000", result.data) + } + + @Test + fun givenACommandThatExitedWithAnError_whenReading_thenItFailsWithTheCodeAndOutput() = runTest { + coEvery { terminal.readSession(any()) } returns + TerminalCommandResult.Completed(1, "$ npm start\nEADDRINUSE") + + val result = handler.execute(mapOf("session" to "AI Core 1")) + + assertFalse(result.success) + assertTrue(result.message.contains("exited with code 1")) + assertTrue(result.error_details!!.contains("EADDRINUSE")) + } + + @Test + fun givenACommandThatExitedCleanly_whenReading_thenItSucceedsWithTheOutput() = runTest { + coEvery { terminal.readSession(any()) } returns TerminalCommandResult.Completed(0, "done") + + val result = handler.execute(mapOf("session" to "AI Core 1")) + + assertTrue(result.success) + assertTrue(result.message.contains("exited with code 0")) + assertEquals("done", result.data) + } + + @Test + fun givenAnUnknownSession_whenReading_thenItFailsNamingIt() = runTest { + coEvery { terminal.readSession(any()) } returns null + + val result = handler.execute(mapOf("session" to "other 1")) + + assertFalse(result.success) + assertTrue(result.message.contains("No Terminal session named \"other 1\"")) + } +} 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..f8c5427f --- /dev/null +++ b/plugins/AI-Core/src/test/kotlin/com/itsaky/androidide/plugins/aicore/tool/handlers/RunShellCommandHandlerTest.kt @@ -0,0 +1,177 @@ +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.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_thenItFailsWithTheCodeAndOutput() = runTest { + answerWith(TerminalCommandResult.Completed(127, "bash: foo: command not found")) + + val result = handler.execute(mapOf("command" to "foo")) + + assertFalse(result.success) + assertTrue(result.message.contains("127")) + assertTrue(result.error_details!!.contains("command not found")) + } + + @Test + fun givenACommandStillRunning_whenRunning_thenItSucceedsAndSaysToLeaveItRunning() = runTest { + answerWith(TerminalCommandResult.Running("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.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 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/StopTerminalSessionHandlerTest.kt b/plugins/AI-Core/src/test/kotlin/com/itsaky/androidide/plugins/aicore/tool/handlers/StopTerminalSessionHandlerTest.kt new file mode 100644 index 00000000..4dfa51a4 --- /dev/null +++ b/plugins/AI-Core/src/test/kotlin/com/itsaky/androidide/plugins/aicore/tool/handlers/StopTerminalSessionHandlerTest.kt @@ -0,0 +1,88 @@ +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 [StopTerminalSessionHandler]: 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 StopTerminalSessionHandlerTest { + + private lateinit var context: PluginContext + private lateinit var services: ServiceRegistry + private lateinit var terminal: IdeTerminalService + private lateinit var handler: StopTerminalSessionHandler + + @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 = StopTerminalSessionHandler(context) + } + + @Test + fun givenTheStopTool_whenAskingForApproval_thenItIsAskedEveryTime() { + assertTrue(handler.requiresApproval) + assertFalse(handler.allowsSessionApproval) + } + + @Test + fun givenNoSession_whenValidating_thenItIsRejected() = runTest { + assertTrue(handler.validate(mapOf("session" to " ")) is Validation.Rejected) + } + + @Test + fun givenARunningCommand_whenStopped_thenItSucceedsEvenWithTheInterruptsExitCode() = runTest { + coEvery { terminal.stopSession("AI Core 1", any()) } returns + TerminalCommandResult.Completed(130, "64 bytes from 1.1.1.1\n^C") + + val result = handler.execute(mapOf("session" to "AI Core 1")) + + assertTrue(result.success) + assertTrue(result.message.contains("has stopped (exit code 130)")) + assertTrue(result.data!!.contains("^C")) + } + + @Test + fun givenTheReadTool_whenComparingArguments_thenStopTakesTheSameSessionNames() { + assertEquals(ReadTerminalSessionHandler(context).argAliases, handler.argAliases) + } + + @Test + fun givenACommandThatIgnoresCtrlC_whenStopped_thenItFailsAndSaysItStillRuns() = runTest { + coEvery { terminal.stopSession(any(), any()) } returns TerminalCommandResult.Running("AI Core 1", "still here") + + val result = handler.execute(mapOf("session" to "AI Core 1")) + + assertFalse(result.success) + assertTrue(result.message.contains("still running")) + assertTrue(result.error_details!!.contains("still here")) + } + + @Test + fun givenAnUnknownSession_whenStopped_thenItFailsNamingIt() = runTest { + coEvery { terminal.stopSession(any(), any()) } returns null + + val result = handler.execute(mapOf("session" to "AI Core 9")) + + assertFalse(result.success) + assertTrue(result.message.contains("\"AI Core 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..9bb97a22 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,29 @@ 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 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. From d2dc24c40465b32512619f96ae7ff7a937d96f07 Mon Sep 17 00:00:00 2001 From: John Trujillo Date: Thu, 8 Oct 2026 09:31:23 -0500 Subject: [PATCH 2/3] fix(ai-core): address shell-command review feedback Report non-zero exits as results, poll terminal sessions as live reads, count shell commands as project changes, share Read/Stop session code, and correct the shell-state and network-access docs. Refs: ADFA-6339 --- plugins/AI-Core/ai-core.html | 17 ++-- .../AI-Core/src/main/assets/docs/index.html | 3 +- .../main/assets/prompts/tool_descriptions.yml | 12 ++- .../plugins/aicore/tool/ToolArgs.kt | 4 +- .../aicore/tool/ToolCallProgressGuard.kt | 14 +-- .../plugins/aicore/tool/ToolHandler.kt | 7 ++ .../handlers/ReadTerminalSessionHandler.kt | 87 +++---------------- .../tool/handlers/RunShellCommandHandler.kt | 22 ++--- .../handlers/StopTerminalSessionHandler.kt | 64 +++----------- .../tool/handlers/TerminalSessionHandler.kt | 82 +++++++++++++++++ .../aicore/prompt/ToolResultsPromptTest.kt | 5 +- .../aicore/tool/ToolCallProgressGuardTest.kt | 35 ++++++++ .../handlers/BuiltInHandlerApprovalTest.kt | 2 +- .../ReadTerminalSessionHandlerTest.kt | 6 +- .../handlers/RunShellCommandHandlerTest.kt | 20 +++-- 15 files changed, 211 insertions(+), 169 deletions(-) create mode 100644 plugins/AI-Core/src/main/kotlin/com/itsaky/androidide/plugins/aicore/tool/handlers/TerminalSessionHandler.kt diff --git a/plugins/AI-Core/ai-core.html b/plugins/AI-Core/ai-core.html index e7128af6..ed710fa9 100644 --- a/plugins/AI-Core/ai-core.html +++ b/plugins/AI-Core/ai-core.html @@ -141,10 +141,10 @@

      Technical architecture

      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, runs shell commands in the IDE's Terminal, 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 + 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.

      @@ -176,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/src/main/assets/docs/index.html b/plugins/AI-Core/src/main/assets/docs/index.html index 604e1949..aca0c7d8 100644 --- a/plugins/AI-Core/src/main/assets/docs/index.html +++ b/plugins/AI-Core/src/main/assets/docs/index.html @@ -113,7 +113,8 @@

      What the agent can do

      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 share one Terminal session. A command still running after + output. Commands reuse one 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 a second session beside it; the agent can check on it later without asking, and asks before stopping it, touching only its own sessions. A command starts in the project folder; 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 34e9c4f2..11b33fba 100644 --- a/plugins/AI-Core/src/main/assets/prompts/tool_descriptions.yml +++ b/plugins/AI-Core/src/main/assets/prompts/tool_descriptions.yml @@ -94,13 +94,17 @@ built_in_tools: 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". + # 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. 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 + 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. 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..8701bb5e 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 @@ -47,7 +47,8 @@ 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 @@ -69,7 +70,8 @@ internal class ToolCallProgressGuard( fun inspect(calls: List): Verdict { val signature = signatureOf(calls) 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) @@ -88,12 +90,12 @@ internal class ToolCallProgressGuard( previousBatchSucceeded = results.isNotEmpty() && results.all { it.success } // 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 +140,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_session") } } 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 edde5d8d..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 @@ -85,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/ReadTerminalSessionHandler.kt b/plugins/AI-Core/src/main/kotlin/com/itsaky/androidide/plugins/aicore/tool/handlers/ReadTerminalSessionHandler.kt index e8c7bf99..89fdaf84 100644 --- a/plugins/AI-Core/src/main/kotlin/com/itsaky/androidide/plugins/aicore/tool/handlers/ReadTerminalSessionHandler.kt +++ b/plugins/AI-Core/src/main/kotlin/com/itsaky/androidide/plugins/aicore/tool/handlers/ReadTerminalSessionHandler.kt @@ -1,12 +1,8 @@ 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.TerminalToolCall.Companion.TRACE_STAGE +import com.itsaky.androidide.plugins.services.IdeTerminalService import com.itsaky.androidide.plugins.services.TerminalCommandResult /** @@ -16,83 +12,26 @@ import com.itsaky.androidide.plugins.services.TerminalCommandResult */ class ReadTerminalSessionHandler( pluginContext: PluginContext, -) : ToolHandler { - override val toolName = TOOL_NAME +) : TerminalSessionHandler(pluginContext, TOOL_NAME) { // Reads output of a command the user already approved; changes nothing. override val requiresApproval = false - override val parametersSchema = ToolSchema.objectOf( - ARG_SESSION to ToolSchema.string(), - required = listOf(ARG_SESSION), - ) - - override val argAliases = SESSION_ALIASES - - private val terminalCall = TerminalToolCall(pluginContext, toolName) + override suspend fun callHost(terminal: IdeTerminalService, sessionName: String) = + terminal.readSession(sessionName) - override suspend fun validate(args: Map): Validation = - sessionOf(args)?.let { Validation.Accepted(args) } ?: Validation.Rejected(NO_SESSION) - - override suspend fun execute(args: Map): ToolResult { - val sessionName = sessionOf(args) ?: return NO_SESSION + override fun running(sessionName: String, running: TerminalCommandResult.Running) = ToolResult.success( + message = "The command in \"$sessionName\" is still running", + data = TerminalOutput.tailOf(running.output) + ) - return terminalCall.run { terminal -> - val state = terminal.readSession(sessionName) - AgentTrace.stage(TRACE_STAGE, "$toolName session=$sessionName state=${state?.let { it::class.simpleName }}") - resultFor(sessionName, state) - } - } + // Any exit code succeeds, as in RunShellCommandHandler: the model reads what the code means. + override fun completed(sessionName: String, completed: TerminalCommandResult.Completed) = ToolResult.success( + message = "The command in \"$sessionName\" exited with code ${completed.exitCode}", + data = TerminalOutput.tailOf(completed.output) + ) companion object { const val TOOL_NAME = "read_terminal_session" - const val ARG_SESSION = "session" - - /** The names models reach for instead of [ARG_SESSION]; shared with [StopTerminalSessionHandler]. */ - internal val SESSION_ALIASES = mapOf( - "session_name" to ARG_SESSION, - "name" to ARG_SESSION, - "terminal" to ARG_SESSION, - ) - - internal val NO_SESSION = ToolResult.failure( - "session is required", - "Give the Terminal session name a ${RunShellCommandHandler.TOOL_NAME} result reported." - ) - - /** The session [args] names, or null when it names none. */ - internal fun sessionOf(args: Map): String? = - args[ARG_SESSION]?.toString()?.trim()?.takeIf(String::isNotEmpty) - - /** The failure for a session the host does not know, shared with [StopTerminalSessionHandler]. */ - internal fun unknownSession(sessionName: String): ToolResult = ToolResult.failure( - "No Terminal session named \"$sessionName\"", - "It was closed, or the name is wrong. Use the session name a " + - "${RunShellCommandHandler.TOOL_NAME} result reported." - ) - - /** - * The tool result for the [state] of session [sessionName]. Exit code 0 succeeds and another - * code fails, as in [RunShellCommandHandler]; a command still running succeeds. - */ - // 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(sessionName: String, state: TerminalCommandResult?): ToolResult = when (state) { - null -> unknownSession(sessionName) - is TerminalCommandResult.Running -> ToolResult.success( - message = "The command in \"$sessionName\" is still running", - data = TerminalOutput.tailOf(state.output) - ) - is TerminalCommandResult.Completed -> { - val message = "The command in \"$sessionName\" exited with code ${state.exitCode}" - val output = TerminalOutput.tailOf(state.output) - if (state.exitCode == 0) { - ToolResult.success(message = message, data = output) - } else { - ToolResult.failure(message, output) - } - } - else -> ToolResult.failure("Session \"$sessionName\" is in an unknown state: $state") - } } } 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 index 74fae086..749d1ae5 100644 --- 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 @@ -28,9 +28,15 @@ class RunShellCommandHandler( // 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(), @@ -88,20 +94,16 @@ class RunShellCommandHandler( ) /** - * The tool result for [outcome]: exit code 0 succeeds, another code fails, and a command - * still running (a server, a watch task) succeeds with what it printed so far. + * 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 -> { - val output = TerminalOutput.tailOf(outcome.output) - if (outcome.exitCode == 0) { - ToolResult.success(message = "Command exited with code 0", data = output) - } else { - ToolResult.failure("Command exited with code ${outcome.exitCode}", output) - } - } + is TerminalCommandResult.Completed -> ToolResult.success( + message = "Command exited with code ${outcome.exitCode}", + data = TerminalOutput.tailOf(outcome.output) + ) is TerminalCommandResult.Running -> ToolResult.success( message = "Command is still running in Terminal session \"${outcome.sessionName}\"", data = "$STILL_RUNNING_NOTE\n\n${TerminalOutput.tailOf(outcome.output)}" diff --git a/plugins/AI-Core/src/main/kotlin/com/itsaky/androidide/plugins/aicore/tool/handlers/StopTerminalSessionHandler.kt b/plugins/AI-Core/src/main/kotlin/com/itsaky/androidide/plugins/aicore/tool/handlers/StopTerminalSessionHandler.kt index 163c52dc..d564a20c 100644 --- a/plugins/AI-Core/src/main/kotlin/com/itsaky/androidide/plugins/aicore/tool/handlers/StopTerminalSessionHandler.kt +++ b/plugins/AI-Core/src/main/kotlin/com/itsaky/androidide/plugins/aicore/tool/handlers/StopTerminalSessionHandler.kt @@ -1,17 +1,8 @@ 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.ReadTerminalSessionHandler.Companion.ARG_SESSION -import com.itsaky.androidide.plugins.aicore.tool.handlers.ReadTerminalSessionHandler.Companion.NO_SESSION -import com.itsaky.androidide.plugins.aicore.tool.handlers.ReadTerminalSessionHandler.Companion.SESSION_ALIASES -import com.itsaky.androidide.plugins.aicore.tool.handlers.ReadTerminalSessionHandler.Companion.sessionOf -import com.itsaky.androidide.plugins.aicore.tool.handlers.ReadTerminalSessionHandler.Companion.unknownSession -import com.itsaky.androidide.plugins.aicore.tool.handlers.TerminalToolCall.Companion.TRACE_STAGE +import com.itsaky.androidide.plugins.services.IdeTerminalService import com.itsaky.androidide.plugins.services.TerminalCommandResult /** @@ -20,8 +11,7 @@ import com.itsaky.androidide.plugins.services.TerminalCommandResult */ class StopTerminalSessionHandler( pluginContext: PluginContext, -) : ToolHandler { - override val toolName = TOOL_NAME +) : TerminalSessionHandler(pluginContext, TOOL_NAME) { // Asked every time: unasked, Gemini stopped a ping the user had just started. override val requiresApproval = true @@ -29,49 +19,21 @@ class StopTerminalSessionHandler( // A session grant would let every later stop through unasked, which is what the dialog prevents. override val allowsSessionApproval = false - override val parametersSchema = ToolSchema.objectOf( - ARG_SESSION to ToolSchema.string(), - required = listOf(ARG_SESSION), - ) - - override val argAliases = SESSION_ALIASES - - private val terminalCall = TerminalToolCall(pluginContext, toolName) - - override suspend fun validate(args: Map): Validation = - sessionOf(args)?.let { Validation.Accepted(args) } ?: Validation.Rejected(NO_SESSION) + override suspend fun callHost(terminal: IdeTerminalService, sessionName: String) = + terminal.stopSession(sessionName) - override suspend fun execute(args: Map): ToolResult { - val sessionName = sessionOf(args) ?: return NO_SESSION + override fun running(sessionName: String, running: TerminalCommandResult.Running) = ToolResult.failure( + "The command in \"$sessionName\" is still running; it did not stop on Ctrl-C", + "Tell the user to stop it in the Terminal. Its latest output:\n" + TerminalOutput.tailOf(running.output) + ) - return terminalCall.run { terminal -> - val state = terminal.stopSession(sessionName) - AgentTrace.stage(TRACE_STAGE, "$toolName session=$sessionName state=${state?.let { it::class.simpleName }}") - resultFor(sessionName, state) - } - } + // Any exit code succeeds, since Ctrl-C itself makes most commands exit non-zero. + override fun completed(sessionName: String, completed: TerminalCommandResult.Completed) = ToolResult.success( + message = "The command in \"$sessionName\" has stopped (exit code ${completed.exitCode})", + data = TerminalOutput.tailOf(completed.output) + ) companion object { const val TOOL_NAME = "stop_terminal_session" - - /** - * The tool result for the [state] session [sessionName] is in after the stop. Any exit - * code succeeds, since Ctrl-C itself makes most commands exit non-zero. - */ - // 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(sessionName: String, state: TerminalCommandResult?): ToolResult = when (state) { - null -> unknownSession(sessionName) - is TerminalCommandResult.Completed -> ToolResult.success( - message = "The command in \"$sessionName\" has stopped (exit code ${state.exitCode})", - data = TerminalOutput.tailOf(state.output) - ) - is TerminalCommandResult.Running -> ToolResult.failure( - "The command in \"$sessionName\" is still running; it did not stop on Ctrl-C", - "Tell the user to stop it in the Terminal. Its latest output:\n" + - TerminalOutput.tailOf(state.output) - ) - else -> ToolResult.failure("Session \"$sessionName\" is in an unknown state: $state") - } } } diff --git a/plugins/AI-Core/src/main/kotlin/com/itsaky/androidide/plugins/aicore/tool/handlers/TerminalSessionHandler.kt b/plugins/AI-Core/src/main/kotlin/com/itsaky/androidide/plugins/aicore/tool/handlers/TerminalSessionHandler.kt new file mode 100644 index 00000000..0bcb256d --- /dev/null +++ b/plugins/AI-Core/src/main/kotlin/com/itsaky/androidide/plugins/aicore/tool/handlers/TerminalSessionHandler.kt @@ -0,0 +1,82 @@ +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.TerminalToolCall.Companion.TRACE_STAGE +import com.itsaky.androidide.plugins.services.IdeTerminalService +import com.itsaky.androidide.plugins.services.TerminalCommandResult + +/** + * A tool acting on one Terminal session [RunShellCommandHandler] reported: the session argument, + * the host call, tracing and the unknown-session result. Subclasses say what to call and report. + */ +abstract class TerminalSessionHandler( + pluginContext: PluginContext, + final override val toolName: String, +) : ToolHandler { + + final override val parametersSchema = ToolSchema.objectOf( + ARG_SESSION to ToolSchema.string(), + required = listOf(ARG_SESSION), + ) + + final override val argAliases = mapOf( + "session_name" to ARG_SESSION, + "name" to ARG_SESSION, + "terminal" to ARG_SESSION, + ) + + private val terminalCall = TerminalToolCall(pluginContext, toolName) + + /** Calls the host on [sessionName]; null when the host has no such session of the agent's. */ + internal abstract suspend fun callHost(terminal: IdeTerminalService, sessionName: String): TerminalCommandResult? + + /** The result for [sessionName] when the call leaves its command [running]. */ + internal abstract fun running(sessionName: String, running: TerminalCommandResult.Running): ToolResult + + /** The result for [sessionName] when the call finds its command [completed]. */ + internal abstract fun completed(sessionName: String, completed: TerminalCommandResult.Completed): ToolResult + + final override suspend fun validate(args: Map): Validation = + sessionOf(args)?.let { Validation.Accepted(args) } ?: Validation.Rejected(NO_SESSION) + + final override suspend fun execute(args: Map): ToolResult { + val sessionName = sessionOf(args) ?: return NO_SESSION + + return terminalCall.run { terminal -> + val state = callHost(terminal, sessionName) + AgentTrace.stage(TRACE_STAGE, "$toolName session=$sessionName state=${state?.let { it::class.simpleName }}") + resultFor(sessionName, state) + } + } + + /** The tool result for the [state] the call left session [sessionName] 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(sessionName: String, state: TerminalCommandResult?): ToolResult = when (state) { + null -> ToolResult.failure( + "No Terminal session named \"$sessionName\"", + "It was closed, or the name is wrong. Use the session name a " + + "${RunShellCommandHandler.TOOL_NAME} result reported." + ) + is TerminalCommandResult.Running -> running(sessionName, state) + is TerminalCommandResult.Completed -> completed(sessionName, state) + else -> ToolResult.failure("Session \"$sessionName\" is in an unknown state: $state") + } + + companion object { + const val ARG_SESSION = "session" + + private val NO_SESSION = ToolResult.failure( + "session is required", + "Give the Terminal session name a ${RunShellCommandHandler.TOOL_NAME} result reported." + ) + + private fun sessionOf(args: Map): String? = + args[ARG_SESSION]?.toString()?.trim()?.takeIf(String::isNotEmpty) + } +} 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 fc561686..69bdb0fa 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 @@ -9,6 +9,7 @@ import com.itsaky.androidide.plugins.aicore.tool.handlers.ReadTerminalSessionHan 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 @@ -186,7 +187,7 @@ class ToolResultsPromptTest { } @Test - fun givenAFailedCommandWithFullOutput_whenRendering_thenTheErrorAtTheEndIsNotCut() { + fun givenANonZeroExitWithFullOutput_whenRendering_thenTheErrorAtTheEndIsNotCut() { val output = "x".repeat(TerminalOutput.MAX_CHARS * 2) + "error: no such file" val result = RunShellCommandHandler.resultFor(TerminalCommandResult.Completed(1, output)) @@ -199,7 +200,7 @@ class ToolResultsPromptTest { @Test fun givenASessionReadWithFullOutput_whenRendering_thenItsLatestOutputIsNotCut() { val output = "x".repeat(TerminalOutput.MAX_CHARS * 2) + "GET / 200" - val result = ReadTerminalSessionHandler.resultFor("AI Core 1", TerminalCommandResult.Running("AI Core 1", output)) + val result = ReadTerminalSessionHandler(mockk(relaxed = true)).resultFor("AI Core 1", TerminalCommandResult.Running("AI Core 1", output)) val turn = render(listOf(ToolCall(ReadTerminalSessionHandler.TOOL_NAME, emptyMap())), listOf(result)) 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..aece621f 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 @@ -231,4 +231,39 @@ class ToolCallProgressGuardTest { assertEquals(ToolCallProgressGuard.Verdict.PROCEED, guard.inspect(read)) } + + @Test + fun givenATerminalSessionReadReissued_whenInspected_thenItPollsAgain() { + val guard = realGuard() + val read = listOf(ToolCall("read_terminal_session", mapOf("session" to "AI Core 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 576d59cb..0c4bebd8 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 @@ -30,7 +30,7 @@ class BuiltInHandlerApprovalTest { ) /** 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 diff --git a/plugins/AI-Core/src/test/kotlin/com/itsaky/androidide/plugins/aicore/tool/handlers/ReadTerminalSessionHandlerTest.kt b/plugins/AI-Core/src/test/kotlin/com/itsaky/androidide/plugins/aicore/tool/handlers/ReadTerminalSessionHandlerTest.kt index 10ae39d4..aada287d 100644 --- a/plugins/AI-Core/src/test/kotlin/com/itsaky/androidide/plugins/aicore/tool/handlers/ReadTerminalSessionHandlerTest.kt +++ b/plugins/AI-Core/src/test/kotlin/com/itsaky/androidide/plugins/aicore/tool/handlers/ReadTerminalSessionHandlerTest.kt @@ -75,15 +75,15 @@ class ReadTerminalSessionHandlerTest { } @Test - fun givenACommandThatExitedWithAnError_whenReading_thenItFailsWithTheCodeAndOutput() = runTest { + fun givenACommandThatExitedWithAnError_whenReading_thenItSucceedsWithTheCodeAndOutput() = runTest { coEvery { terminal.readSession(any()) } returns TerminalCommandResult.Completed(1, "$ npm start\nEADDRINUSE") val result = handler.execute(mapOf("session" to "AI Core 1")) - assertFalse(result.success) + assertTrue(result.success) assertTrue(result.message.contains("exited with code 1")) - assertTrue(result.error_details!!.contains("EADDRINUSE")) + assertTrue(result.data!!.contains("EADDRINUSE")) } @Test 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 index f8c5427f..8fc23be7 100644 --- 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 @@ -4,6 +4,7 @@ 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 @@ -101,14 +102,15 @@ class RunShellCommandHandlerTest { } @Test - fun givenANonZeroExitCode_whenRunning_thenItFailsWithTheCodeAndOutput() = runTest { - answerWith(TerminalCommandResult.Completed(127, "bash: foo: command not found")) + fun givenANonZeroExitCode_whenRunning_thenItSucceedsWithTheCodeAndOutput() = runTest { + answerWith(TerminalCommandResult.Completed(1, "$ grep -rn TODO app/src")) - val result = handler.execute(mapOf("command" to "foo")) + val result = handler.execute(mapOf("command" to "grep -rn TODO app/src")) - assertFalse(result.success) - assertTrue(result.message.contains("127")) - assertTrue(result.error_details!!.contains("command not found")) + // 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 @@ -157,6 +159,12 @@ class RunShellCommandHandlerTest { 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 From 0692c77672abf9bdf8e3e018adf376db66a4a57c Mon Sep 17 00:00:00 2001 From: John Trujillo Date: Thu, 8 Oct 2026 17:09:33 -0500 Subject: [PATCH 3/3] refactor(ai-core): key terminal tools by command id Follow the host's readCommand/stopCommand: read_terminal_command and stop_terminal_command take command_id, and an unknown exit code (-1) is reported as unknown. Refs: ADFA-6339 --- .../aiagentopenai/backend/OpenAiBackend.kt | 18 ++-- .../src/main/res/values/strings.xml | 2 +- .../AI-Core/src/main/assets/docs/index.html | 8 +- .../main/assets/prompts/tool_descriptions.yml | 8 +- .../fragments/ApprovalDialogFragment.kt | 22 +++-- .../plugins/aicore/fragments/ApprovalKind.kt | 44 ---------- .../aicore/fragments/ApprovalPresentation.kt | 35 ++++++++ .../aicore/fragments/ApprovalTextFormatter.kt | 42 ++++++++-- .../plugins/aicore/plugin/AiCorePlugin.kt | 21 ++++- .../aicore/prompt/ToolResultsPrompt.kt | 8 +- .../aicore/tool/ToolCallProgressGuard.kt | 17 +++- .../tool/handlers/BuiltInToolHandlers.kt | 4 +- .../aicore/tool/handlers/HostServiceCall.kt | 61 ++++++++++++++ .../tool/handlers/ListGradleTasksHandler.kt | 31 ++----- .../tool/handlers/ReadBuildOutputHandler.kt | 42 +++------- ...ndler.kt => ReadTerminalCommandHandler.kt} | 20 ++--- .../tool/handlers/RunGradleTaskHandler.kt | 40 +++------ .../tool/handlers/RunShellCommandHandler.kt | 20 +++-- ...ndler.kt => StopTerminalCommandHandler.kt} | 23 ++--- .../tool/handlers/TerminalCommandHandler.kt | 84 +++++++++++++++++++ .../aicore/tool/handlers/TerminalOutput.kt | 7 ++ .../tool/handlers/TerminalSessionHandler.kt | 82 ------------------ .../aicore/tool/handlers/TerminalToolCall.kt | 50 ----------- .../aicore/viewmodel/AgentReplyRenderer.kt | 22 ++++- .../plugins/aicore/viewmodel/ChatViewModel.kt | 10 ++- .../aicore/fragments/ApprovalKindTest.kt | 30 ------- .../fragments/ApprovalPresentationTest.kt | 33 ++++++++ .../fragments/ApprovalTextFormatterTest.kt | 38 +++++++++ .../aicore/prompt/ToolResultsPromptTest.kt | 10 +-- .../aicore/tool/ToolCallProgressGuardTest.kt | 28 ++++++- .../handlers/BuiltInHandlerApprovalTest.kt | 4 +- ...t.kt => ReadTerminalCommandHandlerTest.kt} | 59 +++++++------ .../handlers/RunShellCommandHandlerTest.kt | 3 +- ...t.kt => StopTerminalCommandHandlerTest.kt} | 38 +++++---- .../viewmodel/AgentReplyRendererTest.kt | 23 +++++ 35 files changed, 576 insertions(+), 411 deletions(-) delete mode 100644 plugins/AI-Core/src/main/kotlin/com/itsaky/androidide/plugins/aicore/fragments/ApprovalKind.kt create mode 100644 plugins/AI-Core/src/main/kotlin/com/itsaky/androidide/plugins/aicore/fragments/ApprovalPresentation.kt create mode 100644 plugins/AI-Core/src/main/kotlin/com/itsaky/androidide/plugins/aicore/tool/handlers/HostServiceCall.kt rename plugins/AI-Core/src/main/kotlin/com/itsaky/androidide/plugins/aicore/tool/handlers/{ReadTerminalSessionHandler.kt => ReadTerminalCommandHandler.kt} (59%) rename plugins/AI-Core/src/main/kotlin/com/itsaky/androidide/plugins/aicore/tool/handlers/{StopTerminalSessionHandler.kt => StopTerminalCommandHandler.kt} (56%) create mode 100644 plugins/AI-Core/src/main/kotlin/com/itsaky/androidide/plugins/aicore/tool/handlers/TerminalCommandHandler.kt delete mode 100644 plugins/AI-Core/src/main/kotlin/com/itsaky/androidide/plugins/aicore/tool/handlers/TerminalSessionHandler.kt delete mode 100644 plugins/AI-Core/src/main/kotlin/com/itsaky/androidide/plugins/aicore/tool/handlers/TerminalToolCall.kt delete mode 100644 plugins/AI-Core/src/test/kotlin/com/itsaky/androidide/plugins/aicore/fragments/ApprovalKindTest.kt create mode 100644 plugins/AI-Core/src/test/kotlin/com/itsaky/androidide/plugins/aicore/fragments/ApprovalPresentationTest.kt rename plugins/AI-Core/src/test/kotlin/com/itsaky/androidide/plugins/aicore/tool/handlers/{ReadTerminalSessionHandlerTest.kt => ReadTerminalCommandHandlerTest.kt} (52%) rename plugins/AI-Core/src/test/kotlin/com/itsaky/androidide/plugins/aicore/tool/handlers/{StopTerminalSessionHandlerTest.kt => StopTerminalCommandHandlerTest.kt} (62%) 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 4ea89eb3..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: @@ -117,11 +118,10 @@ class OpenAiBackend( private var currentJob: Job? = null /** - * The server and model that answered a tool declaration with a refusal, so the next turn does + * 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: ServerModel? = null + private val toolsRejectedBy: MutableSet = ConcurrentHashMap.newKeySet() /** * Vector length this server actually returned, as (embedding model -> dimensions). @@ -850,7 +850,7 @@ class OpenAiBackend( attempt: suspend (List) -> Unit ) { val target = ServerModel(getBaseUrl(), getModelName()) - if (tools.isEmpty() || toolsRejectedBy == target) { + if (tools.isEmpty() || target in toolsRejectedBy) { attempt(emptyList()) return } @@ -860,20 +860,22 @@ class OpenAiBackend( throw e } catch (e: OpenAiHttpException) { if (!UnsupportedTools.rejectedIn(e.statusCode, e.body)) throw e - toolsRejectedBy = target + 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: ${target.model} on ${target.baseUrl} does not accept tool " + - "declarations; the agent cannot call tools with it" + "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 and model, since - // the flag above short-circuits every later turn. + // the set above short-circuits every later turn. notifyToolsUnsupported(target) attempt(emptyList()) } 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 5bd6e3f5..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 on %2$s does not support tool calling, so the AI can answer but cannot use its tools. Pick another model. + %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/src/main/assets/docs/index.html b/plugins/AI-Core/src/main/assets/docs/index.html index aca0c7d8..d758d608 100644 --- a/plugins/AI-Core/src/main/assets/docs/index.html +++ b/plugins/AI-Core/src/main/assets/docs/index.html @@ -113,11 +113,11 @@

      What the agent can do

      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 one Terminal session, but each runs in a fresh shell, so a + 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 a - second session beside it; the agent can check on it later without asking, and - asks before stopping it, touching only its own sessions. A command starts in the project folder; + 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 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 11b33fba..92dbc819 100644 --- a/plugins/AI-Core/src/main/assets/prompts/tool_descriptions.yml +++ b/plugins/AI-Core/src/main/assets/prompts/tool_descriptions.yml @@ -111,20 +111,20 @@ built_in_tools: working_directory: >- Project-relative directory to run in; it must be inside the project. Omit to run at the project root. - read_terminal_session: + 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: - session: The Terminal session name the run_shell_command result reported. - stop_terminal_session: + 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: - session: The Terminal session name the run_shell_command result reported. + 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 8a5a2e95..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 @@ -12,6 +12,7 @@ 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.base.PluginFragmentHelper @@ -57,7 +58,7 @@ 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_KIND = "kind" + private const val ARG_PREVIEW = "preview" private const val ARG_ALLOWS_ALWAYS_ALLOW = "allows_always_allow" /** @@ -67,7 +68,7 @@ class ApprovalDialogFragment : DialogFragment() { * @param resources words the labels inside the formatted arguments. */ fun newInstance(request: ApprovalRequest, resources: Resources): ApprovalDialogFragment { - val kind = ApprovalKind.of(request.preview) + val preview = request.preview return ApprovalDialogFragment().apply { arguments = Bundle().apply { // The registered name, not the provider's: it is the tool that will actually @@ -79,8 +80,8 @@ class ApprovalDialogFragment : DialogFragment() { ) putString(ARG_SOURCE, request.sourceLabel) putString(ARG_DESCRIPTION, request.description) - putString(ARG_KIND, kind.name) - putString(ARG_ARGS, kind.format(request.args, resources)) + putString(ARG_PREVIEW, preview.name) + putString(ARG_ARGS, ApprovalPresentation.format(preview, request.args, resources)) putBoolean(ARG_ALLOWS_ALWAYS_ALLOW, request.allowsSessionApproval) } } @@ -93,8 +94,8 @@ class ApprovalDialogFragment : DialogFragment() { val source = arguments?.getString(ARG_SOURCE) val description = arguments?.getString(ARG_DESCRIPTION) ?: "" val argsText = arguments?.getString(ARG_ARGS) ?: "{}" - val kind = ApprovalKind.named(arguments?.getString(ARG_KIND)) - val isEdit = kind == ApprovalKind.EDIT + 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 { @@ -115,7 +116,7 @@ class ApprovalDialogFragment : DialogFragment() { } append(description) append("\n\n") - append(getString(kind.argsLabel)) + append(getString(ApprovalPresentation.argsLabel(preview))) append("\n") append(argsText) } @@ -163,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/ApprovalKind.kt b/plugins/AI-Core/src/main/kotlin/com/itsaky/androidide/plugins/aicore/fragments/ApprovalKind.kt deleted file mode 100644 index 19150e27..00000000 --- a/plugins/AI-Core/src/main/kotlin/com/itsaky/androidide/plugins/aicore/fragments/ApprovalKind.kt +++ /dev/null @@ -1,44 +0,0 @@ -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 a pending tool call, one per [ApprovalPreview] the tool's - * handler declares. Whether "Always Allow" is offered is the request's, not the kind's. - * - * @property argsLabel the heading above the formatted arguments. - */ -internal enum class ApprovalKind(@StringRes val argsLabel: Int) { - EDIT(R.string.approval_proposed_change) { - override fun format(args: Map, resources: Resources) = - ApprovalTextFormatter.formatEdit(args) - }, - SHELL_COMMAND(R.string.approval_command) { - override fun format(args: Map, resources: Resources) = - ApprovalTextFormatter.formatShellCommand(args) { directory -> - resources.getString(R.string.approval_working_directory, directory) - } - }, - OTHER(R.string.approval_args) { - override fun format(args: Map, resources: Resources) = - ApprovalTextFormatter.formatArgs(args) - }; - - /** The call's arguments as the dialog shows them; [resources] words any label among them. */ - abstract fun format(args: Map, resources: Resources): String - - companion object { - /** The kind that renders [preview]. */ - fun of(preview: ApprovalPreview): ApprovalKind = when (preview) { - ApprovalPreview.EDIT -> EDIT - ApprovalPreview.SHELL_COMMAND -> SHELL_COMMAND - ApprovalPreview.ARGS -> OTHER - } - - /** The kind saved as [name] in the dialog's arguments; [OTHER] when it is missing or unknown. */ - fun named(name: String?): ApprovalKind = entries.firstOrNull { it.name == name } ?: OTHER - } -} 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 150207c2..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 @@ -18,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. @@ -49,17 +60,38 @@ object ApprovalTextFormatter { } /** - * Renders a shell command exactly as it will run, uncut and unescaped, since a hidden line would - * still run. A directory outside the project root goes above it as a label, not as a command. + * 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 "" - return invocation.workingDirectory - ?.let { directory -> "${directoryLabel(directory)}\n\n${invocation.command}" } - ?: invocation.command + 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 } /** 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 e44bd8c3..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,9 +6,9 @@ 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.ReadTerminalSessionHandler +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.StopTerminalSessionHandler +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 @@ -57,8 +57,8 @@ class ToolResultsPrompt( 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, - ReadTerminalSessionHandler.TOOL_NAME to SHELL_CHAR_LIMIT, - StopTerminalSessionHandler.TOOL_NAME to SHELL_CHAR_LIMIT, + ReadTerminalCommandHandler.TOOL_NAME to SHELL_CHAR_LIMIT, + StopTerminalCommandHandler.TOOL_NAME to SHELL_CHAR_LIMIT, ) /** 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 8701bb5e..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. @@ -52,6 +52,10 @@ internal class ToolCallProgressGuard( 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 @@ -69,6 +73,7 @@ internal class ToolCallProgressGuard( */ fun inspect(calls: List): Verdict { val signature = signatureOf(calls) + currentSignature = signature currentBatchPaths = pathsNamedBy(calls) val writes = calls.filter(changesPaths).map { pathsNamedBy(listOf(it)) } currentBatchWrites = writes.takeIf { it.none(Set::isEmpty) }?.flatten()?.toSet() @@ -88,6 +93,14 @@ 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, such as a shell command, invalidates every read: there @@ -140,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", "read_terminal_session") + 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/handlers/BuiltInToolHandlers.kt b/plugins/AI-Core/src/main/kotlin/com/itsaky/androidide/plugins/aicore/tool/handlers/BuiltInToolHandlers.kt index c351b2b5..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 @@ -41,8 +41,8 @@ object BuiltInToolHandlers { (if (hostHasTaskListApi()) listOf(ListGradleTasksHandler(context)) else emptyList()) + (if (hostHasTerminalService(context)) listOf( RunShellCommandHandler(context), - ReadTerminalSessionHandler(context), - StopTerminalSessionHandler(context), + ReadTerminalCommandHandler(context), + StopTerminalCommandHandler(context), ) else emptyList()) + listOf( // Template tool 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/ReadTerminalSessionHandler.kt b/plugins/AI-Core/src/main/kotlin/com/itsaky/androidide/plugins/aicore/tool/handlers/ReadTerminalCommandHandler.kt similarity index 59% rename from plugins/AI-Core/src/main/kotlin/com/itsaky/androidide/plugins/aicore/tool/handlers/ReadTerminalSessionHandler.kt rename to plugins/AI-Core/src/main/kotlin/com/itsaky/androidide/plugins/aicore/tool/handlers/ReadTerminalCommandHandler.kt index 89fdaf84..88dabae4 100644 --- a/plugins/AI-Core/src/main/kotlin/com/itsaky/androidide/plugins/aicore/tool/handlers/ReadTerminalSessionHandler.kt +++ b/plugins/AI-Core/src/main/kotlin/com/itsaky/androidide/plugins/aicore/tool/handlers/ReadTerminalCommandHandler.kt @@ -8,30 +8,30 @@ 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 Terminal sessions, never the user's. + * the agent's own commands, never the user's. */ -class ReadTerminalSessionHandler( +class ReadTerminalCommandHandler( pluginContext: PluginContext, -) : TerminalSessionHandler(pluginContext, TOOL_NAME) { +) : 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, sessionName: String) = - terminal.readSession(sessionName) + override suspend fun callHost(terminal: IdeTerminalService, commandId: String) = + terminal.readCommand(commandId) - override fun running(sessionName: String, running: TerminalCommandResult.Running) = ToolResult.success( - message = "The command in \"$sessionName\" is still running", + 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(sessionName: String, completed: TerminalCommandResult.Completed) = ToolResult.success( - message = "The command in \"$sessionName\" exited with code ${completed.exitCode}", + 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_session" + 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 index 749d1ae5..fb8cacfe 100644 --- 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 @@ -7,7 +7,8 @@ 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.TerminalToolCall.Companion.TRACE_STAGE +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 @@ -52,7 +53,9 @@ class RunShellCommandHandler( "working_dir" to ARG_WORKING_DIRECTORY, ) - private val terminalCall = TerminalToolCall(pluginContext, toolName) + 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) @@ -61,13 +64,13 @@ class RunShellCommandHandler( val invocation = ShellInvocation.from(args) ?: return NO_COMMAND return terminalCall.run { terminal -> - AgentTrace.stage(TRACE_STAGE, "$toolName cwd=${invocation.workingDirectory ?: ""}") + 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( - TRACE_STAGE, + SHELL_STAGE, "$toolName outcome=${outcome::class.simpleName} waitedMs=${waited.inWholeMilliseconds}" ) resultFor(outcome) @@ -84,8 +87,8 @@ class RunShellCommandHandler( "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 session name is what " + - "${ReadTerminalSessionHandler.TOOL_NAME} and ${StopTerminalSessionHandler.TOOL_NAME} " + + "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( @@ -101,11 +104,12 @@ class RunShellCommandHandler( @Suppress("REDUNDANT_ELSE_IN_WHEN") internal fun resultFor(outcome: TerminalCommandResult): ToolResult = when (outcome) { is TerminalCommandResult.Completed -> ToolResult.success( - message = "Command exited with code ${outcome.exitCode}", + 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}\"", + 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( diff --git a/plugins/AI-Core/src/main/kotlin/com/itsaky/androidide/plugins/aicore/tool/handlers/StopTerminalSessionHandler.kt b/plugins/AI-Core/src/main/kotlin/com/itsaky/androidide/plugins/aicore/tool/handlers/StopTerminalCommandHandler.kt similarity index 56% rename from plugins/AI-Core/src/main/kotlin/com/itsaky/androidide/plugins/aicore/tool/handlers/StopTerminalSessionHandler.kt rename to plugins/AI-Core/src/main/kotlin/com/itsaky/androidide/plugins/aicore/tool/handlers/StopTerminalCommandHandler.kt index d564a20c..386f537e 100644 --- a/plugins/AI-Core/src/main/kotlin/com/itsaky/androidide/plugins/aicore/tool/handlers/StopTerminalSessionHandler.kt +++ b/plugins/AI-Core/src/main/kotlin/com/itsaky/androidide/plugins/aicore/tool/handlers/StopTerminalCommandHandler.kt @@ -7,11 +7,11 @@ 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 sessions. + * server, with Ctrl-C, as the user would. The host only stops the agent's own commands. */ -class StopTerminalSessionHandler( +class StopTerminalCommandHandler( pluginContext: PluginContext, -) : TerminalSessionHandler(pluginContext, TOOL_NAME) { +) : TerminalCommandHandler(pluginContext, TOOL_NAME) { // Asked every time: unasked, Gemini stopped a ping the user had just started. override val requiresApproval = true @@ -19,21 +19,22 @@ class StopTerminalSessionHandler( // 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, sessionName: String) = - terminal.stopSession(sessionName) + override suspend fun callHost(terminal: IdeTerminalService, commandId: String) = + terminal.stopCommand(commandId) - override fun running(sessionName: String, running: TerminalCommandResult.Running) = ToolResult.failure( - "The command in \"$sessionName\" is still running; it did not stop on Ctrl-C", - "Tell the user to stop it in the Terminal. Its latest output:\n" + TerminalOutput.tailOf(running.output) + 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(sessionName: String, completed: TerminalCommandResult.Completed) = ToolResult.success( - message = "The command in \"$sessionName\" has stopped (exit code ${completed.exitCode})", + 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_session" + 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 index 3b285c08..a8b2f938 100644 --- 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 @@ -16,4 +16,11 @@ internal object TerminalOutput { 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/tool/handlers/TerminalSessionHandler.kt b/plugins/AI-Core/src/main/kotlin/com/itsaky/androidide/plugins/aicore/tool/handlers/TerminalSessionHandler.kt deleted file mode 100644 index 0bcb256d..00000000 --- a/plugins/AI-Core/src/main/kotlin/com/itsaky/androidide/plugins/aicore/tool/handlers/TerminalSessionHandler.kt +++ /dev/null @@ -1,82 +0,0 @@ -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.TerminalToolCall.Companion.TRACE_STAGE -import com.itsaky.androidide.plugins.services.IdeTerminalService -import com.itsaky.androidide.plugins.services.TerminalCommandResult - -/** - * A tool acting on one Terminal session [RunShellCommandHandler] reported: the session argument, - * the host call, tracing and the unknown-session result. Subclasses say what to call and report. - */ -abstract class TerminalSessionHandler( - pluginContext: PluginContext, - final override val toolName: String, -) : ToolHandler { - - final override val parametersSchema = ToolSchema.objectOf( - ARG_SESSION to ToolSchema.string(), - required = listOf(ARG_SESSION), - ) - - final override val argAliases = mapOf( - "session_name" to ARG_SESSION, - "name" to ARG_SESSION, - "terminal" to ARG_SESSION, - ) - - private val terminalCall = TerminalToolCall(pluginContext, toolName) - - /** Calls the host on [sessionName]; null when the host has no such session of the agent's. */ - internal abstract suspend fun callHost(terminal: IdeTerminalService, sessionName: String): TerminalCommandResult? - - /** The result for [sessionName] when the call leaves its command [running]. */ - internal abstract fun running(sessionName: String, running: TerminalCommandResult.Running): ToolResult - - /** The result for [sessionName] when the call finds its command [completed]. */ - internal abstract fun completed(sessionName: String, completed: TerminalCommandResult.Completed): ToolResult - - final override suspend fun validate(args: Map): Validation = - sessionOf(args)?.let { Validation.Accepted(args) } ?: Validation.Rejected(NO_SESSION) - - final override suspend fun execute(args: Map): ToolResult { - val sessionName = sessionOf(args) ?: return NO_SESSION - - return terminalCall.run { terminal -> - val state = callHost(terminal, sessionName) - AgentTrace.stage(TRACE_STAGE, "$toolName session=$sessionName state=${state?.let { it::class.simpleName }}") - resultFor(sessionName, state) - } - } - - /** The tool result for the [state] the call left session [sessionName] 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(sessionName: String, state: TerminalCommandResult?): ToolResult = when (state) { - null -> ToolResult.failure( - "No Terminal session named \"$sessionName\"", - "It was closed, or the name is wrong. Use the session name a " + - "${RunShellCommandHandler.TOOL_NAME} result reported." - ) - is TerminalCommandResult.Running -> running(sessionName, state) - is TerminalCommandResult.Completed -> completed(sessionName, state) - else -> ToolResult.failure("Session \"$sessionName\" is in an unknown state: $state") - } - - companion object { - const val ARG_SESSION = "session" - - private val NO_SESSION = ToolResult.failure( - "session is required", - "Give the Terminal session name a ${RunShellCommandHandler.TOOL_NAME} result reported." - ) - - private fun sessionOf(args: Map): String? = - args[ARG_SESSION]?.toString()?.trim()?.takeIf(String::isNotEmpty) - } -} diff --git a/plugins/AI-Core/src/main/kotlin/com/itsaky/androidide/plugins/aicore/tool/handlers/TerminalToolCall.kt b/plugins/AI-Core/src/main/kotlin/com/itsaky/androidide/plugins/aicore/tool/handlers/TerminalToolCall.kt deleted file mode 100644 index 315f48b3..00000000 --- a/plugins/AI-Core/src/main/kotlin/com/itsaky/androidide/plugins/aicore/tool/handlers/TerminalToolCall.kt +++ /dev/null @@ -1,50 +0,0 @@ -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.services.IdeTerminalService -import kotlinx.coroutines.CancellationException - -/** - * What the shell tools share around a call to the host's [IdeTerminalService]: finding it, and - * turning a host error into a result the model reads. Stop still cancels the call. - */ -internal class TerminalToolCall( - private val pluginContext: PluginContext, - private val toolName: String, -) { - /** Runs [block] with the terminal service, or fails when the host has none. */ - suspend fun run(block: suspend (IdeTerminalService) -> ToolResult): ToolResult = try { - pluginContext.services.get(IdeTerminalService::class.java) - ?.let { terminal -> block(terminal) } - ?: unavailable() - } catch (ce: CancellationException) { - // An Exception on the JVM, so the catches below would report Stop as a command failure. - throw ce - } catch (e: SecurityException) { - // A working directory outside the project, or the permission missing: the model can fix the first. - AgentTrace.refusal(TRACE_STAGE, "$toolName refused", e.toString()) - ToolResult.failure("Command refused: ${e.message ?: "not allowed"}") - } catch (e: Exception) { - AgentTrace.refusal(TRACE_STAGE, "$toolName failed", e.toString()) - pluginContext.logger.error("$toolName failed", e) - ToolResult.failure( - "Error: ${e.javaClass.simpleName}", - "${e.message ?: "Unknown error"}\n\n${e.stackTraceToString()}" - ) - } - - private fun unavailable(): ToolResult { - AgentTrace.refusal(TRACE_STAGE, "$toolName rejected", "IdeTerminalService not available") - return ToolResult.failure( - "Terminal service not available", - "The IDE terminal service is not available." - ) - } - - companion object { - /** The [AgentTrace] stage both shell tools log under. */ - const val TRACE_STAGE = "SHELL" - } -} 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 5ea139b2..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. * @@ -21,11 +23,12 @@ object AgentReplyRenderer { * badge bubble and every result another, which is what buried the answer. * * A turn carrying the terminal call keeps its bubble, as the only place the answer is rendered, - * unless it only recaps a prose answer already on screen; a failure's warning still shows. + * 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 answer. + * @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. */ @@ -38,7 +41,20 @@ object AgentReplyRenderer { if (toolCalls.isEmpty()) return false val answers = toolCalls.filter { isTerminalToolName(it.name, terminalTool) } if (answers.isEmpty()) return true - return recapsShownAnswer && !lastToolFailed && answers.size == toolCalls.size + 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/ChatViewModel.kt b/plugins/AI-Core/src/main/kotlin/com/itsaky/androidide/plugins/aicore/viewmodel/ChatViewModel.kt index b014859d..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 @@ -321,6 +321,10 @@ class ChatViewModel( @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. @@ -965,6 +969,7 @@ class ChatViewModel( // Reset per-run tool tracking. lastToolFailedThisRun = false askedToFinishAfterProse = false + proseTurnShowedAnswer = false activityMessageId = null runToolNames.clear() runToolLog.clear() @@ -1072,7 +1077,8 @@ class ChatViewModel( tools.router.getHandler(call.name)?.mutatesProject == true }, requiredTool = requiredTool, - events = AgentRunReporter(runNotices) { askedToFinishAfterProse = true }, + // 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) } @@ -1556,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/test/kotlin/com/itsaky/androidide/plugins/aicore/fragments/ApprovalKindTest.kt b/plugins/AI-Core/src/test/kotlin/com/itsaky/androidide/plugins/aicore/fragments/ApprovalKindTest.kt deleted file mode 100644 index ad9e0b6e..00000000 --- a/plugins/AI-Core/src/test/kotlin/com/itsaky/androidide/plugins/aicore/fragments/ApprovalKindTest.kt +++ /dev/null @@ -1,30 +0,0 @@ -package com.itsaky.androidide.plugins.aicore.fragments - -import com.itsaky.androidide.plugins.aicore.tool.ApprovalPreview -import org.junit.Assert.assertEquals -import org.junit.Test - -/** - * Unit tests for [ApprovalKind]: the dialog picks its presentation from the preview the handler - * declares, never from the tool's name. - */ -class ApprovalKindTest { - - @Test - fun givenEachPreview_whenItsKindIsPicked_thenTheMatchingKindComesBack() { - assertEquals(ApprovalKind.EDIT, ApprovalKind.of(ApprovalPreview.EDIT)) - assertEquals(ApprovalKind.SHELL_COMMAND, ApprovalKind.of(ApprovalPreview.SHELL_COMMAND)) - assertEquals(ApprovalKind.OTHER, ApprovalKind.of(ApprovalPreview.ARGS)) - } - - @Test - fun givenASavedKindName_whenRestored_thenTheSameKindComesBack() { - assertEquals(ApprovalKind.SHELL_COMMAND, ApprovalKind.named(ApprovalKind.SHELL_COMMAND.name)) - } - - @Test - fun givenAMissingOrUnknownName_whenRestored_thenItFallsBackToOther() { - assertEquals(ApprovalKind.OTHER, ApprovalKind.named(null)) - assertEquals(ApprovalKind.OTHER, ApprovalKind.named("is_edit")) - } -} 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 84f3ef90..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 @@ -168,4 +168,42 @@ class ApprovalTextFormatterTest { 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 69bdb0fa..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,7 +5,7 @@ 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.ReadTerminalSessionHandler +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 @@ -179,7 +179,7 @@ class ToolResultsPromptTest { @Test fun givenAStillRunningCommandWithFullOutput_whenRendering_thenItsLatestOutputIsNotCut() { val output = "x".repeat(TerminalOutput.MAX_CHARS * 2) + "listening on 3000" - val result = RunShellCommandHandler.resultFor(TerminalCommandResult.Running("AI Core 1", output)) + val result = RunShellCommandHandler.resultFor(TerminalCommandResult.Running("cmd-1", "AI Core 1", output)) val turn = render(listOf(ToolCall(RunShellCommandHandler.TOOL_NAME, emptyMap())), listOf(result)) @@ -198,11 +198,11 @@ class ToolResultsPromptTest { } @Test - fun givenASessionReadWithFullOutput_whenRendering_thenItsLatestOutputIsNotCut() { + fun givenACommandReadWithFullOutput_whenRendering_thenItsLatestOutputIsNotCut() { val output = "x".repeat(TerminalOutput.MAX_CHARS * 2) + "GET / 200" - val result = ReadTerminalSessionHandler(mockk(relaxed = true)).resultFor("AI Core 1", TerminalCommandResult.Running("AI Core 1", output)) + val result = ReadTerminalCommandHandler(mockk(relaxed = true)).resultFor("cmd-1", TerminalCommandResult.Running("cmd-1", "AI Core 1", output)) - val turn = render(listOf(ToolCall(ReadTerminalSessionHandler.TOOL_NAME, emptyMap())), listOf(result)) + 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/ToolCallProgressGuardTest.kt b/plugins/AI-Core/src/test/kotlin/com/itsaky/androidide/plugins/aicore/tool/ToolCallProgressGuardTest.kt index aece621f..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() @@ -233,9 +257,9 @@ class ToolCallProgressGuardTest { } @Test - fun givenATerminalSessionReadReissued_whenInspected_thenItPollsAgain() { + fun givenATerminalCommandReadReissued_whenInspected_thenItPollsAgain() { val guard = realGuard() - val read = listOf(ToolCall("read_terminal_session", mapOf("session" to "AI Core 1"))) + val read = listOf(ToolCall("read_terminal_command", mapOf("command_id" to "cmd-1"))) guard.inspect(read) guard.recordResults(ok) 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 0c4bebd8..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 @@ -25,7 +25,7 @@ class BuiltInHandlerApprovalTest { "gradle_sync", "run_gradle_task", "run_shell_command", - "stop_terminal_session", + "stop_terminal_command", "generate_from_template", ) @@ -48,7 +48,7 @@ class BuiltInHandlerApprovalTest { "read_app_logs", "read_ide_logs", // Output of a command the user already approved; the host reads only the agent's sessions. - "read_terminal_session", + "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/ReadTerminalSessionHandlerTest.kt b/plugins/AI-Core/src/test/kotlin/com/itsaky/androidide/plugins/aicore/tool/handlers/ReadTerminalCommandHandlerTest.kt similarity index 52% rename from plugins/AI-Core/src/test/kotlin/com/itsaky/androidide/plugins/aicore/tool/handlers/ReadTerminalSessionHandlerTest.kt rename to plugins/AI-Core/src/test/kotlin/com/itsaky/androidide/plugins/aicore/tool/handlers/ReadTerminalCommandHandlerTest.kt index aada287d..2d0dfae6 100644 --- a/plugins/AI-Core/src/test/kotlin/com/itsaky/androidide/plugins/aicore/tool/handlers/ReadTerminalSessionHandlerTest.kt +++ b/plugins/AI-Core/src/test/kotlin/com/itsaky/androidide/plugins/aicore/tool/handlers/ReadTerminalCommandHandlerTest.kt @@ -17,15 +17,15 @@ import org.junit.Before import org.junit.Test /** - * Unit tests for [ReadTerminalSessionHandler] — the tool that checks on a command + * Unit tests for [ReadTerminalCommandHandler] — the tool that checks on a command * run_shell_command left running, such as a dev server (ADFA-6339). */ -class ReadTerminalSessionHandlerTest { +class ReadTerminalCommandHandlerTest { private lateinit var context: PluginContext private lateinit var services: ServiceRegistry private lateinit var terminal: IdeTerminalService - private lateinit var handler: ReadTerminalSessionHandler + private lateinit var handler: ReadTerminalCommandHandler @Before fun setup() { @@ -35,51 +35,51 @@ class ReadTerminalSessionHandlerTest { every { context.services } returns services every { context.logger } returns mockk(relaxed = true) every { services.get(IdeTerminalService::class.java) } returns terminal - handler = ReadTerminalSessionHandler(context) + handler = ReadTerminalCommandHandler(context) } @Test fun givenNoTerminalService_whenReading_thenItFails() = runTest { every { services.get(IdeTerminalService::class.java) } returns null - val result = handler.execute(mapOf("session" to "AI Core 1")) + val result = handler.execute(mapOf("command_id" to "cmd-1")) assertFalse(result.success) assertTrue(result.message.contains("not available")) } @Test - fun givenNoSession_whenValidating_thenItIsRejected() = runTest { - assertTrue(handler.validate(mapOf("session" to " ")) is Validation.Rejected) + fun givenNoCommandId_whenValidating_thenItIsRejected() = runTest { + assertTrue(handler.validate(mapOf("command_id" to " ")) is Validation.Rejected) } @Test - fun givenASessionName_whenReading_thenTheHostGetsItTrimmed() = runTest { - coEvery { terminal.readSession(any()) } returns null + fun givenACommandId_whenReading_thenTheHostGetsItTrimmed() = runTest { + coEvery { terminal.readCommand(any()) } returns null - handler.execute(mapOf("session" to " AI Core 1 ")) + handler.execute(mapOf("command_id" to " cmd-1 ")) - coVerify { terminal.readSession("AI Core 1") } + coVerify { terminal.readCommand("cmd-1") } } @Test - fun givenARunningCommand_whenReading_thenItSaysSoWithTheOutput() = runTest { - coEvery { terminal.readSession(any()) } returns - TerminalCommandResult.Running("AI Core 1", "$ npm start\nlistening on 3000") + 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("session" to "AI Core 1")) + val result = handler.execute(mapOf("command_id" to "cmd-1")) assertTrue(result.success) - assertTrue(result.message.contains("still running")) + 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.readSession(any()) } returns + coEvery { terminal.readCommand(any()) } returns TerminalCommandResult.Completed(1, "$ npm start\nEADDRINUSE") - val result = handler.execute(mapOf("session" to "AI Core 1")) + val result = handler.execute(mapOf("command_id" to "cmd-1")) assertTrue(result.success) assertTrue(result.message.contains("exited with code 1")) @@ -88,9 +88,9 @@ class ReadTerminalSessionHandlerTest { @Test fun givenACommandThatExitedCleanly_whenReading_thenItSucceedsWithTheOutput() = runTest { - coEvery { terminal.readSession(any()) } returns TerminalCommandResult.Completed(0, "done") + coEvery { terminal.readCommand(any()) } returns TerminalCommandResult.Completed(0, "done") - val result = handler.execute(mapOf("session" to "AI Core 1")) + val result = handler.execute(mapOf("command_id" to "cmd-1")) assertTrue(result.success) assertTrue(result.message.contains("exited with code 0")) @@ -98,12 +98,23 @@ class ReadTerminalSessionHandlerTest { } @Test - fun givenAnUnknownSession_whenReading_thenItFailsNamingIt() = runTest { - coEvery { terminal.readSession(any()) } returns null + fun givenAnExitCodeTheTerminalCouldNotTell_whenReading_thenItDoesNotClaimMinusOne() = runTest { + coEvery { terminal.readCommand(any()) } returns TerminalCommandResult.Completed(-1, "done") - val result = handler.execute(mapOf("session" to "other 1")) + 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 Terminal session named \"other 1\"")) + 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 index 8fc23be7..059bb936 100644 --- 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 @@ -115,12 +115,13 @@ class RunShellCommandHandlerTest { @Test fun givenACommandStillRunning_whenRunning_thenItSucceedsAndSaysToLeaveItRunning() = runTest { - answerWith(TerminalCommandResult.Running("AI Core 1", "$ npm start\nlistening on 3000")) + 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")) diff --git a/plugins/AI-Core/src/test/kotlin/com/itsaky/androidide/plugins/aicore/tool/handlers/StopTerminalSessionHandlerTest.kt b/plugins/AI-Core/src/test/kotlin/com/itsaky/androidide/plugins/aicore/tool/handlers/StopTerminalCommandHandlerTest.kt similarity index 62% rename from plugins/AI-Core/src/test/kotlin/com/itsaky/androidide/plugins/aicore/tool/handlers/StopTerminalSessionHandlerTest.kt rename to plugins/AI-Core/src/test/kotlin/com/itsaky/androidide/plugins/aicore/tool/handlers/StopTerminalCommandHandlerTest.kt index 4dfa51a4..741253a5 100644 --- a/plugins/AI-Core/src/test/kotlin/com/itsaky/androidide/plugins/aicore/tool/handlers/StopTerminalSessionHandlerTest.kt +++ b/plugins/AI-Core/src/test/kotlin/com/itsaky/androidide/plugins/aicore/tool/handlers/StopTerminalCommandHandlerTest.kt @@ -16,15 +16,15 @@ import org.junit.Before import org.junit.Test /** - * Unit tests for [StopTerminalSessionHandler]: the tool that stops a command run_shell_command left + * 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 StopTerminalSessionHandlerTest { +class StopTerminalCommandHandlerTest { private lateinit var context: PluginContext private lateinit var services: ServiceRegistry private lateinit var terminal: IdeTerminalService - private lateinit var handler: StopTerminalSessionHandler + private lateinit var handler: StopTerminalCommandHandler @Before fun setup() { @@ -34,7 +34,7 @@ class StopTerminalSessionHandlerTest { every { context.services } returns services every { context.logger } returns mockk(relaxed = true) every { services.get(IdeTerminalService::class.java) } returns terminal - handler = StopTerminalSessionHandler(context) + handler = StopTerminalCommandHandler(context) } @Test @@ -44,45 +44,47 @@ class StopTerminalSessionHandlerTest { } @Test - fun givenNoSession_whenValidating_thenItIsRejected() = runTest { - assertTrue(handler.validate(mapOf("session" to " ")) is Validation.Rejected) + fun givenNoCommandId_whenValidating_thenItIsRejected() = runTest { + assertTrue(handler.validate(mapOf("command_id" to " ")) is Validation.Rejected) } @Test fun givenARunningCommand_whenStopped_thenItSucceedsEvenWithTheInterruptsExitCode() = runTest { - coEvery { terminal.stopSession("AI Core 1", any()) } returns + coEvery { terminal.stopCommand("cmd-1", any()) } returns TerminalCommandResult.Completed(130, "64 bytes from 1.1.1.1\n^C") - val result = handler.execute(mapOf("session" to "AI Core 1")) + val result = handler.execute(mapOf("command_id" to "cmd-1")) assertTrue(result.success) - assertTrue(result.message.contains("has stopped (exit code 130)")) + assertTrue(result.message.contains("has stopped: it exited with code 130")) assertTrue(result.data!!.contains("^C")) } @Test - fun givenTheReadTool_whenComparingArguments_thenStopTakesTheSameSessionNames() { - assertEquals(ReadTerminalSessionHandler(context).argAliases, handler.argAliases) + fun givenTheReadTool_whenComparingArguments_thenStopTakesTheSameCommandIds() { + assertEquals(ReadTerminalCommandHandler(context).argAliases, handler.argAliases) } @Test - fun givenACommandThatIgnoresCtrlC_whenStopped_thenItFailsAndSaysItStillRuns() = runTest { - coEvery { terminal.stopSession(any(), any()) } returns TerminalCommandResult.Running("AI Core 1", "still here") + 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("session" to "AI Core 1")) + 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 givenAnUnknownSession_whenStopped_thenItFailsNamingIt() = runTest { - coEvery { terminal.stopSession(any(), any()) } returns null + fun givenAnUnknownCommandId_whenStopped_thenItFailsNamingIt() = runTest { + coEvery { terminal.stopCommand(any(), any()) } returns null - val result = handler.execute(mapOf("session" to "AI Core 9")) + val result = handler.execute(mapOf("command_id" to "cmd-9")) assertFalse(result.success) - assertTrue(result.message.contains("\"AI Core 9\"")) + assertTrue(result.message.contains("\"cmd-9\"")) } } 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 9bb97a22..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 @@ -162,6 +162,29 @@ class AgentReplyRendererTest { 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.")