= listOf(
@@ -228,6 +235,104 @@ class AiAssistantPlugin : IPlugin, UIExtension, DocumentationExtension {
PluginTooltipButton(description = "AI Assistant guide", uri = "index.html", order = 0)
)
),
+ PluginTooltipEntry(
+ tag = TOOLTIP_TAG_APPROVAL_ACCEPT,
+ summary = "Apply the change shown above to the file, exactly as written.",
+ detail = """
+ Applies the proposed edit. The block above shows the change:
+ lines marked - are removed and lines marked
+ + are put in their place — read them before
+ accepting, because the agent proposed them, not you.
+ A very long snippet is cut so the dialog stays readable. When
+ that happens the block says so and how much is hidden — and the
+ hidden part is still written. Decline any edit whose whole
+ change you cannot see.
+ If the file is open in the editor the change goes into that
+ buffer, so Ctrl+Z undoes it and any unsaved work you had is
+ preserved. If it isn't open, the file is rewritten on disk.
+ Approval is asked for every single edit — there is no
+ "always allow" for editing, so one tap never grants access to the
+ rest of your project.
+ """.trimIndent(),
+ buttons = listOf(
+ PluginTooltipButton(description = "AI Assistant guide", uri = "index.html", order = 0)
+ )
+ ),
+ PluginTooltipEntry(
+ tag = TOOLTIP_TAG_APPROVAL_CORRECT,
+ summary = "Reject this attempt but tell the agent what to do instead, so it retries.",
+ detail = """
+ Use this when the edit is close but not right — the correct
+ change in the wrong place, or the right idea with a name you don't
+ want. It opens a box for a one-line instruction such as
+ "keep the original method name, only change the return type".
+ Nothing is written. Your instruction goes back to the agent as
+ the reason this call failed, so it can try again with that
+ guidance — which is cheaper than declining and re-typing your whole
+ request.
+ """.trimIndent(),
+ buttons = listOf(
+ PluginTooltipButton(description = "AI Assistant guide", uri = "index.html", order = 0)
+ )
+ ),
+ PluginTooltipEntry(
+ tag = TOOLTIP_TAG_APPROVAL_CORRECTION_INPUT,
+ summary = "Describe what to change about the proposed edit; the agent retries with this.",
+ detail = """
+ Write a short instruction in plain language — one sentence is
+ usually enough. It is handed to the agent verbatim as the reason
+ this edit was rejected.
+ Send returns the instruction and closes the approval
+ prompt; Back leaves the proposed change on screen so you can
+ still accept or decline it. Sending an empty box simply tells the
+ agent to revise the edit without saying how.
+ """.trimIndent(),
+ buttons = listOf(
+ PluginTooltipButton(description = "AI Assistant guide", uri = "index.html", order = 0)
+ )
+ ),
+ PluginTooltipEntry(
+ tag = TOOLTIP_TAG_APPROVAL_DECLINE,
+ summary = "Refuse this action; nothing is written and the agent is told you said no.",
+ detail = """
+ Rejects the action outright. No file is touched.
+ The agent is told the user denied the call, so it will normally
+ stop rather than retry the same thing. Prefer Correct if you
+ want it to keep working on the task with different details.
+ """.trimIndent(),
+ buttons = listOf(
+ PluginTooltipButton(description = "AI Assistant guide", uri = "index.html", order = 0)
+ )
+ ),
+ PluginTooltipEntry(
+ tag = TOOLTIP_TAG_APPROVAL_RUN_NOW,
+ summary = "Allow this one tool call to run now.",
+ detail = """
+ Runs the tool once, with the arguments shown above. Values are
+ 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.
+ """.trimIndent(),
+ buttons = listOf(
+ PluginTooltipButton(description = "AI Assistant guide", uri = "index.html", order = 0)
+ )
+ ),
+ PluginTooltipEntry(
+ tag = TOOLTIP_TAG_APPROVAL_ALWAYS_ALLOW,
+ summary = "Stop asking about this tool for the rest of this session.",
+ detail = """
+ Approves this tool for the current session, so the agent can use
+ it again without prompting. The grant is by tool name only —
+ it does not depend on the arguments — and it is forgotten when the
+ session ends.
+ It is deliberately unavailable for file edits: those are
+ re-confirmed every time, with the change on screen.
+ """.trimIndent(),
+ buttons = listOf(
+ PluginTooltipButton(description = "AI Assistant guide", uri = "index.html", order = 0)
+ )
+ ),
PluginTooltipEntry(
tag = TOOLTIP_TAG_MESSAGE_RETRY,
summary = "Send that message again after a failed reply.",
diff --git a/ai-assistant/src/main/kotlin/com/itsaky/androidide/plugins/aiassistant/fragments/ApprovalDialogFragment.kt b/ai-assistant/src/main/kotlin/com/itsaky/androidide/plugins/aiassistant/fragments/ApprovalDialogFragment.kt
index f904c6ce..3d27ac41 100644
--- a/ai-assistant/src/main/kotlin/com/itsaky/androidide/plugins/aiassistant/fragments/ApprovalDialogFragment.kt
+++ b/ai-assistant/src/main/kotlin/com/itsaky/androidide/plugins/aiassistant/fragments/ApprovalDialogFragment.kt
@@ -2,49 +2,79 @@ package com.itsaky.androidide.plugins.aiassistant.fragments
import android.app.Dialog
import android.os.Bundle
+import android.text.InputType
+import android.view.View
+import android.widget.EditText
+import android.widget.FrameLayout
+import androidx.appcompat.app.AlertDialog
import androidx.fragment.app.DialogFragment
import com.google.android.material.dialog.MaterialAlertDialogBuilder
+import com.itsaky.androidide.plugins.aiassistant.AiAssistantPlugin
import com.itsaky.androidide.plugins.aiassistant.R
import com.itsaky.androidide.plugins.aiassistant.tool.ApprovalRequest
import com.itsaky.androidide.plugins.aiassistant.tool.ApprovalResult
-import org.json.JSONObject
+import com.itsaky.androidide.plugins.aiassistant.tool.handlers.EditFileHandler
+import com.itsaky.androidide.plugins.base.PluginFragmentHelper
+import com.itsaky.androidide.plugins.services.IdeTooltipService
/**
- * Dialog for approving tool execution.
+ * 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.
*/
class ApprovalDialogFragment : DialogFragment() {
- private var onApprovalDecision: ((ApprovalResult) -> Unit)? = null
+ /**
+ * Receives this dialog's outcome. Implemented by the fragment that shows the dialog, which
+ * must be its **parent** fragment (show it with `childFragmentManager`).
+ */
+ interface Host {
+ /**
+ * @param result the user's choice.
+ * @param correction the revision instruction for [ApprovalResult.CORRECTED], else null.
+ */
+ fun onApprovalDecision(result: ApprovalResult, correction: String?)
+ }
+
+ /** The correction prompt, tracked so it can never outlive this dialog. */
+ private var correctionDialog: AlertDialog? = null
+
+ private val tooltipService: IdeTooltipService? by lazy {
+ try {
+ PluginFragmentHelper.getServiceRegistry(AiAssistantPlugin.PLUGIN_ID)
+ ?.get(IdeTooltipService::class.java)
+ } catch (e: Exception) {
+ // Tooltip help is optional; long-press simply shows nothing when it's unavailable.
+ AiAssistantPlugin.getContext()?.logger
+ ?.warn("ApprovalDialogFragment: tooltip service unavailable", e)
+ null
+ }
+ }
companion object {
private const val ARG_TOOL_NAME = "tool_name"
private const val ARG_DESCRIPTION = "description"
private const val ARG_ARGS = "args"
+ private const val ARG_IS_EDIT = "is_edit"
- fun newInstance(
- request: ApprovalRequest,
- onDecision: (ApprovalResult) -> Unit
- ): ApprovalDialogFragment {
+ /**
+ * 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.
+ */
+ fun newInstance(request: ApprovalRequest): ApprovalDialogFragment {
+ val isEdit = request.toolName == EditFileHandler.TOOL_NAME
return ApprovalDialogFragment().apply {
arguments = Bundle().apply {
putString(ARG_TOOL_NAME, request.toolName)
putString(ARG_DESCRIPTION, request.description)
- putString(ARG_ARGS, formatArgs(request.args))
+ putBoolean(ARG_IS_EDIT, isEdit)
+ putString(
+ ARG_ARGS,
+ if (isEdit) ApprovalTextFormatter.formatEdit(request.args)
+ else ApprovalTextFormatter.formatArgs(request.args)
+ )
}
- onApprovalDecision = onDecision
- }
- }
-
- private fun formatArgs(args: Map): String {
- if (args.isEmpty()) return "{}"
- return try {
- val json = JSONObject()
- args.forEach { (key, value) ->
- json.put(key, value)
- }
- json.toString(2)
- } catch (e: Exception) {
- args.toString()
}
}
}
@@ -53,37 +83,72 @@ class ApprovalDialogFragment : DialogFragment() {
val toolName = arguments?.getString(ARG_TOOL_NAME) ?: "unknown"
val description = arguments?.getString(ARG_DESCRIPTION) ?: ""
val argsText = arguments?.getString(ARG_ARGS) ?: "{}"
+ val isEdit = arguments?.getBoolean(ARG_IS_EDIT) == true
val message = buildString {
- append("🔒 Tool Approval Required\n\n")
+ append(getString(R.string.approval_header))
+ append("\n\n")
append(description)
append("\n\n")
- append(getString(R.string.approval_args))
+ append(getString(if (isEdit) R.string.approval_proposed_change else R.string.approval_args))
append("\n")
append(argsText)
- append("\n\n")
- append("Please choose an option below:")
}
- val dialog = MaterialAlertDialogBuilder(requireContext())
- .setTitle("⚠️ Confirm: $toolName")
+ val builder = MaterialAlertDialogBuilder(requireContext())
+ .setTitle(getString(R.string.approval_confirm_title, toolName))
.setMessage(message)
- .setPositiveButton("✓ Run Now") { _, _ ->
- onApprovalDecision?.invoke(ApprovalResult.APPROVED_ONCE)
+ .setNegativeButton(getString(R.string.approval_decline)) { _, _ ->
+ decide(ApprovalResult.DENIED)
dismiss()
}
- .setNeutralButton("✓ Always Allow") { _, _ ->
- onApprovalDecision?.invoke(ApprovalResult.APPROVED_FOR_SESSION)
+ .setOnCancelListener {
+ decide(ApprovalResult.DENIED)
+ }
+
+ if (isEdit) {
+ // No "Always Allow": keyed by tool name, it would cover every future file.
+ builder.setPositiveButton(getString(R.string.approval_accept)) { _, _ ->
+ decide(ApprovalResult.APPROVED_ONCE)
dismiss()
}
- .setNegativeButton("✗ Deny") { _, _ ->
- onApprovalDecision?.invoke(ApprovalResult.DENIED)
+ builder.setNeutralButton(getString(R.string.approval_correct), null)
+ } else {
+ builder.setPositiveButton(getString(R.string.approval_run_now)) { _, _ ->
+ decide(ApprovalResult.APPROVED_ONCE)
dismiss()
}
- .setOnCancelListener {
- onApprovalDecision?.invoke(ApprovalResult.DENIED)
+ builder.setNeutralButton(getString(R.string.approval_always_allow)) { _, _ ->
+ decide(ApprovalResult.APPROVED_FOR_SESSION)
+ dismiss()
}
- .create()
+ }
+
+ val dialog = builder.create()
+
+ // Bound after show(): buttons don't exist before it, and "Correct" must not auto-dismiss.
+ dialog.setOnShowListener {
+ if (isEdit) {
+ dialog.getButton(Dialog.BUTTON_NEUTRAL)?.setOnClickListener {
+ showCorrectionPrompt()
+ }
+ }
+ // Long-press help on the consent gate: which button actually writes to the project.
+ wireTooltip(
+ dialog.getButton(Dialog.BUTTON_POSITIVE),
+ if (isEdit) AiAssistantPlugin.TOOLTIP_TAG_APPROVAL_ACCEPT
+ else AiAssistantPlugin.TOOLTIP_TAG_APPROVAL_RUN_NOW,
+ )
+ wireTooltip(
+ dialog.getButton(Dialog.BUTTON_NEUTRAL),
+ if (isEdit) AiAssistantPlugin.TOOLTIP_TAG_APPROVAL_CORRECT
+ else AiAssistantPlugin.TOOLTIP_TAG_APPROVAL_ALWAYS_ALLOW,
+ )
+ wireTooltip(
+ dialog.getButton(Dialog.BUTTON_NEGATIVE),
+ AiAssistantPlugin.TOOLTIP_TAG_APPROVAL_DECLINE,
+ )
+ }
// Prevent accidental dismissal
dialog.setCancelable(false)
@@ -92,8 +157,74 @@ class ApprovalDialogFragment : DialogFragment() {
return dialog
}
+ /**
+ * Delivers the outcome to the host fragment.
+ *
+ * Resolved on each call rather than captured at construction, so it still works on the
+ * instance the framework recreated after a rotation.
+ * @param result the user's choice.
+ * @param correction the revision instruction, for [ApprovalResult.CORRECTED] only.
+ */
+ private fun decide(result: ApprovalResult, correction: String? = null) {
+ (parentFragment as? Host)?.onApprovalDecision(result, correction)
+ }
+
+ /** Shows this plugin's tooltip for [tag] when [view] is long-pressed (Tier 1/2 + guide). */
+ private fun wireTooltip(view: View?, tag: String) {
+ view?.setOnLongClickListener { anchor ->
+ val service = tooltipService ?: return@setOnLongClickListener false
+ service.showTooltip(anchor, AiAssistantPlugin.TOOLTIP_CATEGORY, tag)
+ true
+ }
+ }
+
+ /**
+ * Collects the revision instruction as an [ApprovalResult.CORRECTED] decision; cancelling leaves
+ * the approval dialog untouched. Held in [correctionDialog] and torn down in [onDestroyView],
+ * since a separate window would otherwise leak on rotation and outlive a stopped run.
+ */
+ private fun showCorrectionPrompt() {
+ val context = context ?: return
+ val input = EditText(context).apply {
+ hint = getString(R.string.approval_correction_hint)
+ inputType = InputType.TYPE_CLASS_TEXT or
+ InputType.TYPE_TEXT_FLAG_MULTI_LINE or
+ InputType.TYPE_TEXT_FLAG_CAP_SENTENCES
+ setSingleLine(false)
+ maxLines = 5
+ }
+ val padding = (24 * resources.displayMetrics.density).toInt()
+ val container = FrameLayout(context).apply {
+ setPadding(padding, padding / 2, padding, 0)
+ addView(input)
+ }
+
+ correctionDialog = MaterialAlertDialogBuilder(context)
+ .setTitle(getString(R.string.approval_correction_title))
+ .setView(container)
+ .setPositiveButton(getString(R.string.approval_correction_send)) { _, _ ->
+ decide(ApprovalResult.CORRECTED, input.text?.toString()?.trim().orEmpty())
+ dismiss()
+ }
+ .setNegativeButton(getString(R.string.approval_correction_cancel), null)
+ .setOnDismissListener { correctionDialog = null }
+ .create()
+ .apply {
+ setOnShowListener {
+ // On Send, not the EditText, where it would eat the paste menu on long-press.
+ wireTooltip(
+ getButton(Dialog.BUTTON_POSITIVE),
+ AiAssistantPlugin.TOOLTIP_TAG_APPROVAL_CORRECTION_INPUT,
+ )
+ }
+ show()
+ }
+ }
+
override fun onDestroyView() {
super.onDestroyView()
- onApprovalDecision = null
+ // Takes the correction prompt down with this dialog; see showCorrectionPrompt().
+ correctionDialog?.dismiss()
+ correctionDialog = null
}
}
diff --git a/ai-assistant/src/main/kotlin/com/itsaky/androidide/plugins/aiassistant/fragments/ApprovalTextFormatter.kt b/ai-assistant/src/main/kotlin/com/itsaky/androidide/plugins/aiassistant/fragments/ApprovalTextFormatter.kt
new file mode 100644
index 00000000..1fc6ca78
--- /dev/null
+++ b/ai-assistant/src/main/kotlin/com/itsaky/androidide/plugins/aiassistant/fragments/ApprovalTextFormatter.kt
@@ -0,0 +1,91 @@
+package com.itsaky.androidide.plugins.aiassistant.fragments
+
+import com.itsaky.androidide.plugins.aiassistant.tool.handlers.EditFileHandler
+import com.itsaky.androidide.plugins.aiassistant.utils.parseToolBoolean
+import org.json.JSONObject
+
+/**
+ * Renders a pending tool call as the text the approval dialog shows. Split from
+ * [ApprovalDialogFragment] because informed consent is decided here, and a pure function of the
+ * arguments is testable without a fragment harness. Values are truncated on purpose.
+ */
+object ApprovalTextFormatter {
+
+ /** Per-value cap in the generic argument dump. */
+ private const val MAX_VALUE_CHARS = 200
+
+ /** Per-side cap in the edit preview; long enough for a real hunk, short enough to read. */
+ private const val MAX_SNIPPET_CHARS = 600
+
+ /**
+ * Renders the proposed edit as a diff-style before/after block.
+ * @param args the `edit_file` call arguments.
+ * @return the preview text.
+ */
+ fun formatEdit(args: Map): String {
+ val path = args[EditFileHandler.ARG_PATH]?.toString().orEmpty()
+ val old = args[EditFileHandler.ARG_OLD]?.toString().orEmpty()
+ val new = args[EditFileHandler.ARG_NEW]?.toString().orEmpty()
+ // Shared with the handler's parsing, so the "every occurrence" warning cannot disagree.
+ val replaceAll = parseToolBoolean(args[EditFileHandler.ARG_REPLACE_ALL])
+
+ return buildString {
+ append(path).append("\n\n")
+ append("— Remove —\n")
+ append(diffBlock(old, "- "))
+ append("\n\n")
+ if (new.isEmpty()) {
+ append("+ (deleted)")
+ } else {
+ append("+ Add +\n")
+ append(diffBlock(new, "+ "))
+ }
+ if (replaceAll) {
+ append("\n\n")
+ append("⚠ Applies to every occurrence in the file.")
+ }
+ }
+ }
+
+ /**
+ * Renders any other tool call's arguments as pretty-printed JSON.
+ * @param args the call arguments.
+ * @return the argument dump, falling back to [Map.toString] if JSON rendering fails.
+ */
+ fun formatArgs(args: Map): String {
+ if (args.isEmpty()) return "{}"
+ return try {
+ val json = JSONObject()
+ args.forEach { (key, value) ->
+ json.put(key, truncate(value?.toString() ?: "", MAX_VALUE_CHARS))
+ }
+ json.toString(2)
+ } catch (e: Exception) {
+ args.toString()
+ }
+ }
+
+ /**
+ * Renders one side of the diff, stating any omission *outside* the prefixed lines: carrying a
+ * `- `/`+ ` marker it would read as part of the code being changed. The notice also says the
+ * hidden text is still written, because truncation is a display limit, not a limit on the edit.
+ * @param text the full snippet.
+ * @param prefix the diff marker for each shown line.
+ * @return the prefixed lines, followed by an omission notice when [text] was cut.
+ */
+ private fun diffBlock(text: String, prefix: String): String {
+ val shown = text.take(MAX_SNIPPET_CHARS)
+ val hidden = text.length - shown.length
+ val body = prefixLines(shown, prefix)
+ if (hidden == 0) return body
+ return body + "\n\n⚠ $hidden more characters are not shown here, " +
+ "but they WILL be written. Decline if you can't review the whole change."
+ }
+
+ private fun prefixLines(text: String, prefix: String): String =
+ text.lineSequence().joinToString("\n") { prefix + it }
+
+ private fun truncate(text: String, limit: Int): String =
+ if (text.length <= limit) text
+ else text.take(limit) + "\n…(${text.length - limit} more characters)"
+}
diff --git a/ai-assistant/src/main/kotlin/com/itsaky/androidide/plugins/aiassistant/fragments/ChatFragment.kt b/ai-assistant/src/main/kotlin/com/itsaky/androidide/plugins/aiassistant/fragments/ChatFragment.kt
index 06bffb10..334702e5 100644
--- a/ai-assistant/src/main/kotlin/com/itsaky/androidide/plugins/aiassistant/fragments/ChatFragment.kt
+++ b/ai-assistant/src/main/kotlin/com/itsaky/androidide/plugins/aiassistant/fragments/ChatFragment.kt
@@ -33,7 +33,13 @@ import java.io.File
* ChatFragment for Agent chat UI.
* Provides a full chat interface with LLM integration.
*/
-class ChatFragment : Fragment() {
+class ChatFragment : Fragment(), ApprovalDialogFragment.Host {
+
+ private companion object {
+ /** Tag the approval dialog is shown under, so it can be found again after recreation. */
+ const val APPROVAL_DIALOG_TAG = "approval_dialog"
+ }
+
private var _binding: FragmentChatBinding? = null
private val binding get() = _binding!!
@@ -63,10 +69,11 @@ class ChatFragment : Fragment() {
}
/**
- * Route inflation through the host so the plugin's views resolve against a Context whose
- * Configuration tracks the IDE's day/night setting — this is what lets values-night/ colors
- * and the DayNight PluginTheme take effect. Replaces the old cloneInContext(pluginContext),
- * which used the plugin's base Context and stayed pinned to light mode.
+ * Routes inflation through the host so views resolve against a Context whose Configuration
+ * tracks the IDE's day/night setting, which is what lets values-night/ colors and the DayNight
+ * PluginTheme take effect. The old cloneInContext() stayed pinned to light mode.
+ * @param savedInstanceState forwarded to the superclass inflater.
+ * @return the theme-aware inflater the plugin's layouts must be inflated with.
*/
override fun onGetLayoutInflater(savedInstanceState: Bundle?): LayoutInflater {
val inflater = super.onGetLayoutInflater(savedInstanceState)
@@ -108,9 +115,7 @@ class ChatFragment : Fragment() {
setupBackendIndicator()
observeViewModel()
- // The settings screen is a DialogFragment, so this fragment's onResume does not fire
- // when it closes. Listen for its dismissal to re-resolve the selected backend (routing +
- // availability) and refresh the indicator label.
+ // Settings is a DialogFragment, so onResume does not fire when it closes.
parentFragmentManager.setFragmentResultListener(
AiSettingsFragment.RESULT_SETTINGS_CLOSED, viewLifecycleOwner
) { _, _ ->
@@ -123,11 +128,9 @@ class ChatFragment : Fragment() {
}
/**
- * Check for test prompt from broadcast receiver and auto-send if present.
- * Uses SharedPreferences set by TestBroadcastReceiver for reliable communication.
- *
- * Debug builds only. This path auto-drives the agent — which owns file-mutating
- * tools — without any user gesture, so it must not exist in a released plugin.
+ * Auto-sends a test prompt left in SharedPreferences by TestBroadcastReceiver. Debug builds
+ * only: this drives the agent, and its file-mutating tools, with no user gesture, so it must
+ * not exist in a released plugin.
*/
private fun injectPendingTestPrompt() {
if (!BuildConfig.DEBUG) return
@@ -172,16 +175,14 @@ class ChatFragment : Fragment() {
override fun onResume() {
super.onResume()
- // Check backend availability when fragment becomes visible
- // This ensures we check after all plugins have loaded
+ // On becoming visible, so the check runs after every plugin has loaded.
viewModel.checkBackendAvailability()
// Reflect the currently selected backend (updates after returning from settings).
viewModel.refreshBackendLabel()
}
private fun initializeViewModel() {
- // Pass plugin context getter instead of service directly
- // This allows ViewModel to get service lazily
+ // A context getter, not the service, so the ViewModel resolves it lazily.
viewModel = ViewModelProvider(
this,
ChatViewModelFactory { getPluginContext() }
@@ -194,8 +195,7 @@ class ChatFragment : Fragment() {
}
private fun setupRecyclerView() {
- // The adapter inflates item views from parent.context (the RecyclerView's theme-aware
- // Context), so it no longer needs a Context passed in.
+ // Item views inflate from parent.context, so no Context needs passing in.
chatAdapter = ChatAdapter(markwon, ::wireTooltip) { action, message ->
onMessageAction(action, message)
}
@@ -209,8 +209,7 @@ class ChatFragment : Fragment() {
private fun setupToolbar() {
binding.btnOverflowMenu.setOnClickListener { view ->
- // Anchor's Context is the theme-aware plugin Context (inflated via getPluginInflater),
- // so the menu resource resolves and follows the IDE day/night theme.
+ // The anchor's Context is theme-aware, so the menu follows the IDE day/night theme.
val popup = android.widget.PopupMenu(view.context, view)
popup.menuInflater.inflate(com.itsaky.androidide.plugins.aiassistant.R.menu.chat_overflow_menu, popup.menu)
@@ -345,27 +344,42 @@ class ChatFragment : Fragment() {
}
}
+ /**
+ * The approval dialog currently on screen, looked up by tag rather than held in a field:
+ * after a configuration change this fragment is a new instance, and a field would be null
+ * while the framework-recreated dialog is still up — leaving it un-dismissable.
+ */
+ private fun currentApprovalDialog(): ApprovalDialogFragment? =
+ childFragmentManager.findFragmentByTag(APPROVAL_DIALOG_TAG) as? ApprovalDialogFragment
+
private suspend fun observePendingApprovalRequest() {
viewModel.pendingApprovalRequest.collect { request ->
if (request != null) {
showApprovalDialog(request)
+ } else {
+ // Withdrawn by Stop or Clear Chat: nothing awaits it, so don't strand the dialog.
+ currentApprovalDialog()?.dismissAllowingStateLoss()
}
}
}
private fun showApprovalDialog(request: com.itsaky.androidide.plugins.aiassistant.tool.ApprovalRequest) {
- val dialog = ApprovalDialogFragment.newInstance(request) { result ->
- viewModel.submitApproval(result)
- }
- dialog.show(parentFragmentManager, "approval_dialog")
+ // childFragmentManager makes this fragment the parent, which is how Host is resolved.
+ if (currentApprovalDialog() != null) return
+ ApprovalDialogFragment.newInstance(request).show(childFragmentManager, APPROVAL_DIALOG_TAG)
+ }
+
+ override fun onApprovalDecision(
+ result: com.itsaky.androidide.plugins.aiassistant.tool.ApprovalResult,
+ correction: String?,
+ ) {
+ viewModel.submitApproval(result, correction)
}
/**
- * Opens the file picker rooted at the open project. The host's [IdeProjectService] is the
- * only source of truth for that root — PathGuard's `System.getProperty` fallback resolves
- * to "/" in the IDE process, which would root the picker at the device filesystem instead
- * of the project. With no project open there is nothing to confine the picker to, so this
- * fails closed and says so rather than opening an unconfined browser.
+ * Opens the file picker rooted at the open project. [IdeProjectService] is the only source of
+ * truth for that root; PathGuard's property fallback resolves to "/" here, which would root the
+ * picker at the whole device. With no project open this fails closed and says so.
*/
private fun showFilePicker() {
val projectService = getPluginContext()?.services?.get(IdeProjectService::class.java)
@@ -434,10 +448,10 @@ class ChatFragment : Fragment() {
}
/**
- * Surface an [AgentState.Error] as a transient, actionable Snackbar with a shortcut into
- * settings. Snackbar (not Toast) is mandatory here: a Toast built from the plugin's Context
- * crashes the IDE with a SecurityException because the plugin package isn't a real installed
- * UID; Snackbar attaches to the existing host view hierarchy instead.
+ * Surfaces an [AgentState.Error] as a Snackbar with a shortcut into settings. Snackbar, never
+ * Toast: a Toast built from the plugin's Context crashes the IDE with a SecurityException,
+ * since the plugin package is not a real installed UID.
+ * @param message the error text to show.
*/
private fun showErrorSnackbar(message: String) {
val binding = _binding ?: return
diff --git a/ai-assistant/src/main/kotlin/com/itsaky/androidide/plugins/aiassistant/tool/AgentLoop.kt b/ai-assistant/src/main/kotlin/com/itsaky/androidide/plugins/aiassistant/tool/AgentLoop.kt
index 627874e9..ca713ca1 100644
--- a/ai-assistant/src/main/kotlin/com/itsaky/androidide/plugins/aiassistant/tool/AgentLoop.kt
+++ b/ai-assistant/src/main/kotlin/com/itsaky/androidide/plugins/aiassistant/tool/AgentLoop.kt
@@ -19,8 +19,12 @@ class AgentLoop(
) {
companion object {
- /** Max model turns per user message; a backstop against a model that never stops calling tools. */
- const val DEFAULT_MAX_ITERATIONS = 8
+ /**
+ * Max model turns per user message, as a backstop against a model that never stops calling
+ * tools. At 8 a two-line rename ran out mid-way and left the file half-edited; the
+ * repeated-call guard and per-write approval are the tighter limits.
+ */
+ const val DEFAULT_MAX_ITERATIONS = 16
/** Per-tool-result cap fed back into the prompt, so big outputs don't blow a local model's context. */
const val DEFAULT_TOOL_OUTPUT_CHAR_LIMIT = 4000
@@ -118,7 +122,7 @@ class AgentLoop(
terminalTool?.let { tt ->
val terminal = calls.firstOrNull { it.name == tt }
if (terminal != null && realCalls.isEmpty()) {
- events.onFinalAnswer(turn, terminal.args["message"]?.toString().orEmpty())
+ events.onFinalAnswer(turn, respondMessageOf(terminal.args).orEmpty())
return Result(turn, StopReason.COMPLETED)
}
}
@@ -159,14 +163,9 @@ class AgentLoop(
}
/**
- * Flattens the transcript into one prompt string, with no trailing "Assistant:" cue
- * (the backend appends its own; a doubled cue makes local models repeat).
- *
- * For backends whose transport carries only a single string. A backend that renders real
- * conversation turns must be given the [ChatMessage] list instead — flattening a multi-turn
- * run into one string leaves the assistant's tool calls and the tool results sitting inside
- * whatever single turn the backend wraps this in.
- *
+ * Flattens the transcript into one prompt string, with no trailing "Assistant:" cue, which the
+ * backend appends itself. Only for backends whose transport carries a single string; one that
+ * renders real conversation turns must be handed the [ChatMessage] list instead.
* @param history the conversation so far.
* @return the rendered prompt.
*/
@@ -183,14 +182,9 @@ class AgentLoop(
}
/**
- * Renders tool results for feeding back into the next prompt, capping each body.
- *
- * Each result is wrapped in `` tags. Chat-tuned models are trained to read
- * tool output inside that delimiter, and it reads as a single token once the backend
- * tokenizes with special tokens enabled. Handed the same content as bare prose, a small
- * model tends not to register that the call already ran and re-issues it, which the
- * [maxConsecutiveRepeats] guard then has to abort.
- *
+ * Renders tool results for the next prompt, capping each body and wrapping it in
+ * `` tags that chat-tuned models are trained to read. Handed the same content as
+ * bare prose, a small model tends to re-issue the call it already ran.
* @param calls the tool calls that ran.
* @param results their results, positionally aligned with [calls].
* @return the formatted results block.
diff --git a/ai-assistant/src/main/kotlin/com/itsaky/androidide/plugins/aiassistant/tool/Executor.kt b/ai-assistant/src/main/kotlin/com/itsaky/androidide/plugins/aiassistant/tool/Executor.kt
index f2516770..68fc942a 100644
--- a/ai-assistant/src/main/kotlin/com/itsaky/androidide/plugins/aiassistant/tool/Executor.kt
+++ b/ai-assistant/src/main/kotlin/com/itsaky/androidide/plugins/aiassistant/tool/Executor.kt
@@ -3,7 +3,9 @@ package com.itsaky.androidide.plugins.aiassistant.tool
import android.util.Log
import com.itsaky.androidide.plugins.aiassistant.models.ToolResult
import com.itsaky.androidide.plugins.aiassistant.tool.handlers.PathGuard
+import com.itsaky.androidide.plugins.aiassistant.utils.AgentTrace
import com.itsaky.androidide.plugins.aiassistant.utils.ToolExecutionTracker
+import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.async
import kotlinx.coroutines.awaitAll
import kotlinx.coroutines.coroutineScope
@@ -28,7 +30,9 @@ class Executor(
)
/**
- * Get required arguments for a tool.
+ * Arguments a tool cannot run without.
+ * @param toolName the tool being dispatched.
+ * @return the required argument names, empty when the tool has none.
*/
fun requiredArgsForTool(toolName: String): List {
return when (toolName) {
@@ -38,40 +42,68 @@ class Executor(
"search_project" -> listOf("query")
"create_file" -> listOf("file_path", "content")
"update_file" -> listOf("file_path", "content")
+ // new_string omitted: empty is a legal edit (deletion), so EditFileHandler checks it.
+ "edit_file" -> listOf("file_path", "old_string")
else -> emptyList()
}
}
+
+ /**
+ * Required args holding verbatim file text, where whitespace is content: checked for
+ * emptiness rather than blankness, since `old_string = " "` is a legal edit and
+ * rejecting it would contradict `EditFileHandler.validate`.
+ */
+ private val VERBATIM_TEXT_ARGS = setOf("old_string")
+
+ /**
+ * Whether [value] fails the required-argument check for [key].
+ * @param key the required argument's name.
+ * @param value the supplied value, or null when absent.
+ * @return true when the argument must be reported missing.
+ */
+ private fun isMissing(key: String, value: Any?): Boolean {
+ val text = value?.toString()
+ return if (key in VERBATIM_TEXT_ARGS) text.isNullOrEmpty() else text?.trim().isNullOrEmpty()
+ }
}
/**
- * Execute a list of tool calls.
- * Read-only tools are executed in parallel, write tools sequentially.
+ * Executes a batch of tool calls, treating its order as a dependency graph: consecutive
+ * read-only calls ([PARALLEL_SAFE_TOOLS]) run concurrently, every write runs alone between such
+ * runs. Hoisting all reads broke `create_file` + `read_file`; mixing broke `search` + `edit`.
+ * @param toolCalls the calls to run, in the order the model emitted them.
+ * @return one result per call, positionally aligned with [toolCalls].
*/
suspend fun execute(toolCalls: List): List = coroutineScope {
Log.i(TAG, "Executing ${toolCalls.size} tool call(s)...")
val results = arrayOfNulls(toolCalls.size)
- // Read-only tools run concurrently.
- val parallelJobs = toolCalls.mapIndexedNotNull { index, call ->
- if (call.name in PARALLEL_SAFE_TOOLS) {
- async { results[index] = executeCall(call, "Parallel") }
- } else null
- }
-
- // Write tools run one at a time, in input order.
- toolCalls.forEachIndexed { index, call ->
- if (call.name !in PARALLEL_SAFE_TOOLS) {
- results[index] = executeCall(call, "Sequential")
+ var index = 0
+ while (index < toolCalls.size) {
+ if (toolCalls[index].name !in PARALLEL_SAFE_TOOLS) {
+ results[index] = executeCall(toolCalls[index], "Sequential")
+ index++
+ continue
}
+ // Extend over every read-only call that follows, stopping at the first write.
+ var end = index
+ while (end < toolCalls.size && toolCalls[end].name in PARALLEL_SAFE_TOOLS) end++
+ (index until end).map { i ->
+ async { results[i] = executeCall(toolCalls[i], "Parallel") }
+ }.awaitAll()
+ index = end
}
- parallelJobs.awaitAll()
results.requireNoNulls().toList()
}
/**
- * Execute a single tool call.
+ * Runs one call end to end: alias remapping, required-argument and containment checks,
+ * pre-approval validation, the approval gate, then dispatch.
+ * @param call the tool call to run.
+ * @param executionMode "Parallel"/"Sequential", for logging.
+ * @return the tool's result, or the failure that stopped it short of running.
*/
private suspend fun executeCall(call: ToolCall, executionMode: String): ToolResult {
val toolName = call.name
@@ -99,39 +131,92 @@ class Executor(
}
}
+ // Handler-declared aliases; a canonical key the model did supply always wins.
+ handler.argAliases.forEach { (alias, canonical) ->
+ if (!normalizedArgs.containsKey(canonical) && normalizedArgs.containsKey(alias)) {
+ normalizedArgs[canonical] = normalizedArgs[alias]
+ Log.d(TAG, "($executionMode): Remapped '$alias' → '$canonical' for $toolName tool")
+ AgentTrace.detail("ARGS", "$toolName remapped $alias→$canonical")
+ }
+ }
+
// Check required arguments
val missingArgs = requiredArgsForTool(toolName).filter { key ->
- val value = normalizedArgs[key]?.toString()?.trim().orEmpty()
- value.isBlank()
+ isMissing(key, normalizedArgs[key])
}
if (missingArgs.isNotEmpty()) {
val message = "Missing required argument(s): ${missingArgs.joinToString(", ")}"
Log.i(TAG, "($executionMode): Tool '$toolName' missing args: $missingArgs")
+ AgentTrace.refusal("ARGS", "$toolName mode=$executionMode", message)
return ToolResult.failure(message)
}
- pathContainmentFailure(toolName, handler, normalizedArgs, executionMode)?.let { return it }
+ pathContainmentFailure(toolName, handler, normalizedArgs, executionMode)?.let {
+ AgentTrace.refusal("GUARD", "$toolName containment", it.message)
+ return it
+ }
+
+ // Rejects a doomed call before it costs a dialog; guarded since validate() can throw.
+ val validation = try {
+ handler.validate(normalizedArgs)
+ } catch (ce: CancellationException) {
+ throw ce
+ } catch (e: Exception) {
+ Log.e(TAG, "($executionMode): Pre-approval validation of '$toolName' threw", e)
+ AgentTrace.refusal(
+ "PRECHECK",
+ "$toolName validation threw",
+ e.message ?: e.javaClass.simpleName,
+ )
+ return ToolResult.failure("Error validating $toolName: ${e.message}", e.stackTraceToString())
+ }
+
+ val validatedArgs = when (validation) {
+ is Validation.Rejected -> {
+ val failure = validation.result
+ Log.i(TAG, "($executionMode): Tool '$toolName' failed pre-approval validation: ${failure.message}")
+ AgentTrace.refusal("PRECHECK", "$toolName rejected before approval", failure.message)
+ return failure
+ }
+ is Validation.Accepted -> {
+ // Model-supplied keys only: handler bookkeeping is not an "args corrected" event.
+ if (normalizedArgs.any { (key, value) -> validation.args[key] != value }) {
+ AgentTrace.stage("PRECHECK", "$toolName args corrected", AgentTrace.previewArgs(validation.args))
+ }
+ validation.args
+ }
+ }
// Check approval
- val approvalResponse = approvalManager.ensureApproved(toolName, handler, normalizedArgs)
+ val approvalStart = System.currentTimeMillis()
+ val approvalResponse = approvalManager.ensureApproved(toolName, handler, validatedArgs)
+ val approvalMs = System.currentTimeMillis() - approvalStart
if (!approvalResponse.approved) {
val message = approvalResponse.denialMessage ?: "Action denied by user."
Log.i(TAG, "($executionMode): Tool '$toolName' denied. $message")
+ AgentTrace.stage("APPROVAL", "$toolName approved=false waitedMs=$approvalMs", AgentTrace.preview(message))
return ToolResult.failure(message)
}
+ AgentTrace.stage("APPROVAL", "$toolName approved=true waitedMs=$approvalMs")
- Log.d(TAG, "($executionMode): Dispatching '$toolName' with args: $normalizedArgs")
+ Log.d(TAG, "($executionMode): Dispatching '$toolName' with args: $validatedArgs")
+ AgentTrace.stage("EXEC", "$toolName mode=$executionMode", AgentTrace.previewArgs(validatedArgs))
// Before tool execution
val toolStartTime = System.currentTimeMillis()
- val result = toolRouter.dispatch(toolName, normalizedArgs)
+ val result = toolRouter.dispatch(toolName, validatedArgs)
// After tool execution
val toolDuration = System.currentTimeMillis() - toolStartTime
toolExecutionTracker?.logToolCall(toolName, toolDuration)
Log.i(TAG, "($executionMode): Result: ${result.toResultMap()}")
+ AgentTrace.stage(
+ "EXEC",
+ "$toolName done success=${result.success} tookMs=$toolDuration",
+ AgentTrace.preview(result.message),
+ )
return result
}
diff --git a/ai-assistant/src/main/kotlin/com/itsaky/androidide/plugins/aiassistant/tool/RespondArgs.kt b/ai-assistant/src/main/kotlin/com/itsaky/androidide/plugins/aiassistant/tool/RespondArgs.kt
new file mode 100644
index 00000000..61fbca2f
--- /dev/null
+++ b/ai-assistant/src/main/kotlin/com/itsaky/androidide/plugins/aiassistant/tool/RespondArgs.kt
@@ -0,0 +1,20 @@
+package com.itsaky.androidide.plugins.aiassistant.tool
+
+/**
+ * Keys a model uses for the `respond` payload, in preference order. `message` is what both
+ * system prompts document; the rest are what models substitute anyway, and a `respond` call
+ * whose text is under the wrong key is a finished answer that would otherwise be discarded.
+ */
+private val RESPOND_MESSAGE_KEYS = listOf("message", "text", "response", "answer", "content")
+
+/**
+ * Reads the user-facing answer out of a `respond` call's arguments, tolerating the keys a model
+ * substitutes for `message`. The same tolerance the handlers get from `ToolHandler.argAliases`,
+ * which `respond` never had because it has no handler.
+ * @param args the `respond` call's arguments.
+ * @return the answer, or null when no key carries usable text.
+ */
+fun respondMessageOf(args: Map): String? =
+ RESPOND_MESSAGE_KEYS.firstNotNullOfOrNull { key ->
+ args[key]?.toString()?.takeIf { it.isNotBlank() }
+ }
diff --git a/ai-assistant/src/main/kotlin/com/itsaky/androidide/plugins/aiassistant/tool/ToolApprovalManager.kt b/ai-assistant/src/main/kotlin/com/itsaky/androidide/plugins/aiassistant/tool/ToolApprovalManager.kt
index caf6cef8..de994331 100644
--- a/ai-assistant/src/main/kotlin/com/itsaky/androidide/plugins/aiassistant/tool/ToolApprovalManager.kt
+++ b/ai-assistant/src/main/kotlin/com/itsaky/androidide/plugins/aiassistant/tool/ToolApprovalManager.kt
@@ -1,11 +1,15 @@
package com.itsaky.androidide.plugins.aiassistant.tool
import android.util.Log
+import com.itsaky.androidide.plugins.aiassistant.utils.AgentTrace
import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
+import kotlinx.coroutines.sync.Mutex
+import kotlinx.coroutines.sync.withLock
import kotlinx.coroutines.withTimeoutOrNull
+import java.util.concurrent.ConcurrentHashMap
/**
* Manages user approval for tool execution.
@@ -29,11 +33,26 @@ class ToolApprovalManager {
"get_current_datetime"
)
- // Tools approved for this session
- private val sessionApprovedTools = mutableSetOf()
+ /**
+ * Tools that can never be blanket-approved for the session, however the user answers. Session
+ * approval is keyed by tool name alone, so one tap would hand a small model unreviewed write
+ * access to the whole project; a destructive edit is re-confirmed every time.
+ */
+ private val neverSessionApproved = setOf("edit_file")
- // Pending approval request
- private var pendingApproval: CompletableDeferred? = null
+ // Concurrent: written from the dialog's coroutine, read from the next tool call's thread.
+ private val sessionApprovedTools: MutableSet = ConcurrentHashMap.newKeySet()
+
+ /**
+ * Serialises the request-and-wait section of [ensureApproved]. There is one [pendingApproval]
+ * slot and one dialog, so a second concurrent request would overwrite the first and strand its
+ * caller until the timeout — and this class offers an API that looks safe to call anywhere.
+ */
+ private val requestLock = Mutex()
+
+ // Volatile: completed from the main thread, published and cleared from a background coroutine.
+ @Volatile
+ private var pendingApproval: CompletableDeferred? = null
private val _currentApprovalRequest = MutableStateFlow(null)
@@ -55,45 +74,75 @@ class ToolApprovalManager {
): ApprovalResponse {
// Check if tool doesn't require approval
if (!handler.requiresApproval || autoApprovedTools.contains(toolName)) {
+ AgentTrace.detail("APPROVAL", "$toolName skipped=auto-approved")
return ApprovalResponse(approved = true)
}
// Check if already approved for this session
if (sessionApprovedTools.contains(toolName)) {
+ AgentTrace.detail("APPROVAL", "$toolName skipped=session-approved")
return ApprovalResponse(approved = true)
}
- // Request user approval
- val request = ApprovalRequest(
- toolName = toolName,
- args = args,
- description = handler.description
- )
-
- _currentApprovalRequest.value = request
- pendingApproval = CompletableDeferred()
-
- Log.d(TAG, "Requesting approval for $toolName (timeout: ${APPROVAL_TIMEOUT_MS}ms)")
+ // Locked from publishing to clearing, so a second caller waits instead of replacing it.
+ val result = requestLock.withLock {
+ if (sessionApprovedTools.contains(toolName)) {
+ AgentTrace.detail("APPROVAL", "$toolName skipped=session-approved while queued")
+ return ApprovalResponse(approved = true)
+ }
- // Wait for user decision with timeout
- val result = withTimeoutOrNull(APPROVAL_TIMEOUT_MS) {
- pendingApproval!!.await()
+ val request = ApprovalRequest(
+ toolName = toolName,
+ args = args,
+ description = handler.description
+ )
+
+ val deferred = CompletableDeferred()
+ pendingApproval = deferred
+ _currentApprovalRequest.value = request
+
+ Log.d(TAG, "Requesting approval for $toolName (timeout: ${APPROVAL_TIMEOUT_MS}ms)")
+ AgentTrace.stage("APPROVAL", "$toolName dialog=shown", AgentTrace.previewArgs(args))
+
+ try {
+ // Wait for user decision with timeout
+ withTimeoutOrNull(APPROVAL_TIMEOUT_MS) { deferred.await() }
+ } finally {
+ _currentApprovalRequest.value = null
+ pendingApproval = null
+ }
}
-
- _currentApprovalRequest.value = null
- pendingApproval = null
+ AgentTrace.stage("APPROVAL", "$toolName choice=${result?.result ?: "TIMEOUT"}")
// Handle timeout or decision
- return when (result) {
+ return when (result?.result) {
ApprovalResult.APPROVED_ONCE -> {
Log.d(TAG, "Approval granted (once) for $toolName")
ApprovalResponse(approved = true)
}
ApprovalResult.APPROVED_FOR_SESSION -> {
- Log.d(TAG, "Approval granted (session) for $toolName")
- sessionApprovedTools.add(toolName)
+ if (toolName in neverSessionApproved) {
+ Log.d(TAG, "Approval granted (once; $toolName is never session-approved)")
+ } else {
+ Log.d(TAG, "Approval granted (session) for $toolName")
+ sessionApprovedTools.add(toolName)
+ }
ApprovalResponse(approved = true)
}
+ ApprovalResult.CORRECTED -> {
+ // Only this attempt is denied; a tool failure is the channel the loop re-feeds.
+ val correction = result.correction?.trim().orEmpty()
+ Log.d(TAG, "User requested a correction for $toolName")
+ ApprovalResponse(
+ approved = false,
+ denialMessage = if (correction.isEmpty()) {
+ "User rejected this $toolName call and asked you to revise it."
+ } else {
+ "User rejected this $toolName call and asked you to revise it: " +
+ "\"$correction\". Apply that instruction and try again."
+ }
+ )
+ }
ApprovalResult.DENIED -> {
Log.d(TAG, "Approval denied for $toolName")
ApprovalResponse(
@@ -114,26 +163,26 @@ class ToolApprovalManager {
/**
* Submit user's approval decision.
+ * @param result what the user chose.
+ * @param correction for [ApprovalResult.CORRECTED], the instruction to relay to the model.
*/
- fun submitApproval(result: ApprovalResult) {
+ fun submitApproval(result: ApprovalResult, correction: String? = null) {
if (pendingApproval?.isActive == true) {
- pendingApproval?.complete(result)
- // Clear here as well as in ensureApproved(): completing the deferred only resumes
- // that coroutine on the next dispatch, and the dialog must dismiss immediately.
+ pendingApproval?.complete(ApprovalDecision(result, correction))
+ // Also cleared here: the deferred resumes only on the next dispatch, the dialog now.
_currentApprovalRequest.value = null
Log.d(TAG, "Approval decision submitted: $result")
}
}
- /**
- * Cancel the pending approval request.
- * Useful when user wants to stop waiting for approval.
- */
+ /** Cancels the pending approval request, for a user who stops waiting on it. */
fun cancelPendingApproval() {
if (pendingApproval?.isActive == true) {
- pendingApproval?.complete(ApprovalResult.DENIED)
+ pendingApproval?.complete(ApprovalDecision(ApprovalResult.DENIED))
Log.d(TAG, "Pending approval cancelled by user")
}
+ // Cleared here too: a cancelled run may never resume to do it, stranding the dialog.
+ _currentApprovalRequest.value = null
}
/**
@@ -175,5 +224,21 @@ data class ApprovalRequest(
enum class ApprovalResult {
APPROVED_ONCE,
APPROVED_FOR_SESSION,
+
+ /**
+ * The user rejected this attempt but described what to do instead; the instruction rides
+ * back to the model as a tool failure so it can retry.
+ */
+ CORRECTED,
DENIED
}
+
+/**
+ * A decision plus the free text that only [ApprovalResult.CORRECTED] carries.
+ * @property result what the user chose.
+ * @property correction the user's instruction, when correcting.
+ */
+data class ApprovalDecision(
+ val result: ApprovalResult,
+ val correction: String? = null
+)
diff --git a/ai-assistant/src/main/kotlin/com/itsaky/androidide/plugins/aiassistant/tool/ToolCallExtractor.kt b/ai-assistant/src/main/kotlin/com/itsaky/androidide/plugins/aiassistant/tool/ToolCallExtractor.kt
index d8b7f6e9..0af61a23 100644
--- a/ai-assistant/src/main/kotlin/com/itsaky/androidide/plugins/aiassistant/tool/ToolCallExtractor.kt
+++ b/ai-assistant/src/main/kotlin/com/itsaky/androidide/plugins/aiassistant/tool/ToolCallExtractor.kt
@@ -4,18 +4,37 @@ import android.util.Log
import org.json.JSONObject
/**
- * Extracts tool calls from LLM responses using multiple strategies:
- * 1. Explicit XML tags: {"tool":"name",...}
- * 2. JSON blocks: {"tool":"name",...}
- *
- * Works with both cloud (Gemini) and local LLMs.
+ * Extracts tool calls from an LLM reply, by explicit `` envelope first and bare
+ * `{"tool":...}` JSON second. Works for both the cloud and local backends.
*/
class ToolCallExtractor {
companion object {
private const val TAG = "ToolCallExtractor"
+ /** The `{…}` envelope both system prompts ask for. */
+ private val TOOL_CALL_REGEX =
+ Regex("""\s*(.+?)\s*""", RegexOption.DOT_MATCHES_ALL)
+
+ /**
+ * The prose left once the tool-call envelopes are removed. Worth showing when a `respond`
+ * call carries no message, which usually means the model wrote the answer as prose and
+ * emitted an empty envelope beside it.
+ * @param text the model's raw reply.
+ * @return the prose, or null when there is none, or when what remains is a bare
+ * (untagged) tool call rather than something meant for the user to read.
+ */
+ fun proseOutsideToolCalls(text: String): String? {
+ val remainder = TOOL_CALL_REGEX.replace(text, "\n").trim()
+ if (remainder.isEmpty()) return null
+ // A leftover `"tool"` key is an unenveloped call; raw JSON is worse than nothing.
+ if (remainder.contains("\"tool\"")) return null
+ return remainder
+ }
+
/**
- * Extract all tool calls from response text using multiple strategies.
+ * Extracts every tool call from [text], trying each strategy in turn.
+ * @param text the model's raw reply.
+ * @return the calls found, in the order they appear; empty when there are none.
*/
fun extractToolCalls(text: String): List {
val toolCalls = mutableListOf()
@@ -48,8 +67,7 @@ class ToolCallExtractor {
*/
private fun extractFromXmlTags(text: String): List {
val toolCalls = mutableListOf()
- val regex = Regex("""\s*(.+?)\s*""", RegexOption.DOT_MATCHES_ALL)
- val matches = regex.findAll(text)
+ val matches = TOOL_CALL_REGEX.findAll(text)
Log.d(TAG, "Strategy 1 (XML tags): Found ${matches.count()} matches")
diff --git a/ai-assistant/src/main/kotlin/com/itsaky/androidide/plugins/aiassistant/tool/ToolHandler.kt b/ai-assistant/src/main/kotlin/com/itsaky/androidide/plugins/aiassistant/tool/ToolHandler.kt
index 1c53676d..b8f3820f 100644
--- a/ai-assistant/src/main/kotlin/com/itsaky/androidide/plugins/aiassistant/tool/ToolHandler.kt
+++ b/ai-assistant/src/main/kotlin/com/itsaky/androidide/plugins/aiassistant/tool/ToolHandler.kt
@@ -40,4 +40,34 @@ interface ToolHandler {
val resolvesPathsInternally: Boolean
get() = false
+
+ /**
+ * Alternative argument names, alias → canonical. Small models reliably invent near-miss keys
+ * ("old" for "old_string") and the grammar constrains none of them, so the choice is remapping
+ * or burning a turn. Applied before the required-argument check; a supplied canonical key wins.
+ */
+ val argAliases: Map
+ get() = emptyMap()
+
+ /**
+ * Cheap, side-effect-free check run **before** the user is asked to approve the call, so no
+ * dialog is spent on an edit that cannot succeed — which wastes the one moment of attention the
+ * safety design rests on. Must not mutate: the call may still be denied, and [execute] re-checks.
+ * @param args the normalized call arguments.
+ * @return acceptance, carrying the arguments to run, or the failure to report instead.
+ */
+ suspend fun validate(args: Map): Validation = Validation.Accepted(args)
+}
+
+/** Outcome of [ToolHandler.validate]. */
+sealed interface Validation {
+ /**
+ * The call is applicable and should be approved and run.
+ * @property args the arguments to actually use; a handler may return a corrected copy, and these
+ * are what the approval dialog shows, so the user reviews what will run.
+ */
+ data class Accepted(val args: Map) : Validation
+
+ /** The call cannot succeed; [result] goes back to the model and the user is never prompted. */
+ data class Rejected(val result: ToolResult) : Validation
}
diff --git a/ai-assistant/src/main/kotlin/com/itsaky/androidide/plugins/aiassistant/tool/ToolRouter.kt b/ai-assistant/src/main/kotlin/com/itsaky/androidide/plugins/aiassistant/tool/ToolRouter.kt
index 2d8a5c19..3e69ab37 100644
--- a/ai-assistant/src/main/kotlin/com/itsaky/androidide/plugins/aiassistant/tool/ToolRouter.kt
+++ b/ai-assistant/src/main/kotlin/com/itsaky/androidide/plugins/aiassistant/tool/ToolRouter.kt
@@ -32,6 +32,13 @@ class ToolRouter(
return try {
Log.d(TAG, "Dispatching $toolName with args: $args")
handler.execute(args)
+ } catch (ce: kotlinx.coroutines.CancellationException) {
+ // It is an Exception on the JVM, so the catch below would report Stop as a failure.
+ Log.i(TAG, "Tool $toolName cancelled")
+ // Traced, or the run appears to hang mid-tool with no EXEC-done line.
+ com.itsaky.androidide.plugins.aiassistant.utils.AgentTrace
+ .refusal("EXEC", "$toolName cancelled", "run stopped before the tool finished")
+ throw ce
} catch (e: Exception) {
Log.e(TAG, "Error executing tool $toolName", e)
ToolResult.failure("Error executing $toolName: ${e.message}", e.stackTraceToString())
diff --git a/ai-assistant/src/main/kotlin/com/itsaky/androidide/plugins/aiassistant/tool/handlers/EditFileHandler.kt b/ai-assistant/src/main/kotlin/com/itsaky/androidide/plugins/aiassistant/tool/handlers/EditFileHandler.kt
new file mode 100644
index 00000000..2962c64f
--- /dev/null
+++ b/ai-assistant/src/main/kotlin/com/itsaky/androidide/plugins/aiassistant/tool/handlers/EditFileHandler.kt
@@ -0,0 +1,481 @@
+package com.itsaky.androidide.plugins.aiassistant.tool.handlers
+
+import com.itsaky.androidide.plugins.PluginContext
+import com.itsaky.androidide.plugins.aiassistant.models.ToolResult
+import com.itsaky.androidide.plugins.aiassistant.tool.ToolHandler
+import com.itsaky.androidide.plugins.aiassistant.tool.Validation
+import com.itsaky.androidide.plugins.aiassistant.tool.handlers.edit.AtomicFileWriter
+import com.itsaky.androidide.plugins.aiassistant.tool.handlers.edit.EditTargetResolver
+import com.itsaky.androidide.plugins.aiassistant.tool.handlers.edit.EditorBufferApplier
+import com.itsaky.androidide.plugins.aiassistant.tool.handlers.edit.FileTextMatcher
+import com.itsaky.androidide.plugins.aiassistant.utils.AgentTrace
+import com.itsaky.androidide.plugins.aiassistant.utils.parseToolBoolean
+import com.itsaky.androidide.plugins.services.IdeEditorService
+import kotlinx.coroutines.CancellationException
+import kotlinx.coroutines.CoroutineDispatcher
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.currentCoroutineContext
+import kotlinx.coroutines.ensureActive
+import kotlinx.coroutines.withContext
+import java.io.File
+import java.nio.ByteBuffer
+import java.nio.charset.CharacterCodingException
+import java.nio.charset.CodingErrorAction
+import java.nio.charset.StandardCharsets
+
+/**
+ * Surgical find/replace edit of a project file: the model supplies the exact snippet ([ARG_OLD])
+ * and its replacement ([ARG_NEW]), which survives a local model's reply budget where a whole-file
+ * rewrite does not. Decides *what* the edit is; applying it belongs to `handlers/edit`.
+ * @param pluginContext host services (editor access).
+ * @param mainDispatcher dispatcher for editor-UI calls; overridden in unit tests.
+ */
+class EditFileHandler(
+ private val pluginContext: PluginContext,
+ private val mainDispatcher: CoroutineDispatcher = Dispatchers.Main
+) : ToolHandler {
+
+ override val toolName = TOOL_NAME
+ override val description =
+ "Edit an existing file by replacing an exact snippet: give file_path, old_string " +
+ "(text to find, copied exactly including indentation) and new_string (its " +
+ "replacement; empty deletes it). old_string must match exactly once unless " +
+ "replace_all is true. Prefer this over update_file for changing a file."
+ override val requiresApproval = true
+ override val pathArgs = listOf(ARG_PATH)
+ override val argAliases = mapOf(
+ "old" to ARG_OLD,
+ "old_text" to ARG_OLD,
+ "search" to ARG_OLD,
+ "new" to ARG_NEW,
+ "new_text" to ARG_NEW,
+ "replace" to ARG_NEW,
+ "content" to ARG_NEW,
+ )
+
+ companion object {
+ const val TOOL_NAME = "edit_file"
+
+ const val ARG_PATH = "file_path"
+ const val ARG_OLD = "old_string"
+ const val ARG_NEW = "new_string"
+ const val ARG_REPLACE_ALL = "replace_all"
+
+ /**
+ * Fingerprint of the text [validate] matched, so [execute] can tell whether the file changed
+ * while the approval dialog was open. Written by [validate] only; a call without it skips
+ * the check. Underscored so it cannot collide with a model-supplied key.
+ */
+ const val ARG_REVIEWED_FINGERPRINT = "__reviewed_content"
+
+ /**
+ * Largest file this tool will read. Editing holds the file plus the edited copy in memory,
+ * a project tree legitimately contains jars, `.gguf` models and APKs, and an
+ * `OutOfMemoryError` on one of those is not catchable here — it kills the IDE process.
+ */
+ const val MAX_EDIT_BYTES = 1L * 1024 * 1024
+
+ /** Cap on each model-supplied snippet; the tool-call grammar bounds neither. */
+ const val MAX_ARG_CHARS = 64 * 1024
+
+ /** Bytes sampled when deciding whether a file is binary. */
+ private const val BINARY_SNIFF_BYTES = 8 * 1024
+
+ /** Longest [ARG_OLD] still treated as a bare name rather than a code region. */
+ private const val MAX_IDENTIFIER_CHARS = 64
+
+ /**
+ * A request phrased as an instruction ("_bind with _binding", "foo -> bar"), which small
+ * models paste into [ARG_OLD] *and* [ARG_NEW] unchanged. Recognising it turns a rejection the
+ * model just repeats verbatim — three times, then the agent loop gives up — into one that
+ * hands it the two values it should have sent.
+ */
+ private val INSTRUCTION_PAIR =
+ Regex("""^(\S+)\s+(?:with|to|into|by|for|->|=>)\s+(\S+)$""", RegexOption.IGNORE_CASE)
+ }
+
+ /**
+ * Pre-approval check: everything [execute] would reject anyway, without touching a byte, so the
+ * user is never shown a dialog for an edit that cannot apply. Small models produce those
+ * constantly — an identical old/new pair, a hallucinated path — and each one cost a dialog.
+ * @param args the normalized call arguments.
+ * @return acceptance carrying the resolved path, or the failure to report instead.
+ */
+ override suspend fun validate(args: Map): Validation =
+ when (val analysis = analyze(args, tracing = false)) {
+ is Analysis.Rejected -> Validation.Rejected(analysis.result)
+ // The resolved path so the user reviews the real file, and the fingerprint for execute().
+ is Analysis.Ready -> Validation.Accepted(
+ args + mapOf(
+ ARG_PATH to analysis.filePath,
+ ARG_REVIEWED_FINGERPRINT to fingerprintOf(analysis.original),
+ )
+ )
+ }
+
+ /**
+ * Change-detection fingerprint of the text an edit was reviewed against. Length plus
+ * [String.hashCode] — specified exactly by the JVM, so stable across processes — catches a file
+ * rewritten between review and application. Detects accidents, not a crafted collision.
+ * @param text the matched content — the editor buffer when the file is open, else the disk copy.
+ * @return an opaque fingerprint, comparable only against another value from this function.
+ */
+ private fun fingerprintOf(text: String): String = "${text.length}:${text.hashCode()}"
+
+ override suspend fun execute(args: Map): ToolResult {
+ // Re-analyzed, not reused: the file may have changed while the dialog was open.
+ val analysis = try {
+ analyze(args, tracing = true)
+ } catch (ce: CancellationException) {
+ throw ce
+ } catch (e: Exception) {
+ pluginContext.logger.error("edit_file: failed to read ${args[ARG_PATH]} for editing", e)
+ return ToolResult.failure("Error editing file: ${e.message}", e.stackTraceToString())
+ }
+
+ val ready = when (analysis) {
+ is Analysis.Rejected -> return analysis.result
+ is Analysis.Ready -> analysis
+ }
+
+ // A rewritten file can still match old_string once, in surroundings nobody reviewed.
+ val reviewed = args[ARG_REVIEWED_FINGERPRINT]?.toString()
+ if (reviewed != null && reviewed != fingerprintOf(ready.original)) {
+ AgentTrace.refusal(
+ "EDIT",
+ "apply path=${ready.file.name}",
+ "content changed after approval; not applying a diff the user never saw",
+ )
+ return ToolResult.failure(
+ "${ready.filePath} changed after this edit was approved — read the file again " +
+ "and redo the edit against its current text"
+ )
+ }
+
+ return try {
+ // The snippets FileTextMatcher resolved, which may differ from the model's line endings.
+ val updated = ready.original.replace(ready.oldString, ready.newString)
+
+ // Nothing has been mutated yet; a Stop here must leave the file untouched.
+ currentCoroutineContext().ensureActive()
+
+ val summary = "replaced ${ready.occurrences} occurrence(s) of " +
+ "${ready.oldString.length} chars with ${ready.newString.length} chars"
+
+ if (ready.buffer != null && ready.editorService != null) {
+ applyToEditor(ready, updated, summary)
+ } else {
+ applyToDisk(ready, updated, summary)
+ }
+ } catch (ce: CancellationException) {
+ // Before catch(Exception): it is an Exception on the JVM, so a broad catch eats Stop.
+ throw ce
+ } catch (e: Exception) {
+ pluginContext.logger.error("edit_file: failed to apply the edit to ${ready.filePath}", e)
+ ToolResult.failure("Error editing file: ${e.message}", e.stackTraceToString())
+ }
+ }
+
+ /** Outcome of [analyze]: an edit ready to apply, or the failure to report instead. */
+ private sealed interface Analysis {
+ /**
+ * Everything needed to apply the edit, already validated.
+ * @property original the text that was matched — the editor buffer when [buffer] is
+ * non-null, otherwise the on-disk contents.
+ * @property oldString the snippet **as it appears in [original]**, which for a CRLF file
+ * is not necessarily the string the model supplied.
+ */
+ data class Ready(
+ val file: File,
+ val filePath: String,
+ val oldString: String,
+ val newString: String,
+ val original: String,
+ val buffer: String?,
+ val editorService: IdeEditorService?,
+ val occurrences: Int,
+ ) : Analysis
+
+ data class Rejected(val result: ToolResult) : Analysis
+ }
+
+ /**
+ * Works out what the edit would do, without changing anything. Shared by [validate] (before
+ * approval) and [execute] (after it) so the two can never disagree about whether an edit
+ * applies; a check living only in `execute` would surprise a user who already approved.
+ * @param args the normalized call arguments.
+ * @param tracing whether to emit trace lines; off for the pre-approval pass, which would
+ * otherwise log every analysis twice.
+ * @return the applicable edit, or the failure to return.
+ */
+ private suspend fun analyze(args: Map, tracing: Boolean): Analysis {
+ val filePath = args[ARG_PATH]?.toString()?.trim()
+ if (filePath.isNullOrBlank()) {
+ return reject("$ARG_PATH is required")
+ }
+
+ val oldString = args[ARG_OLD]?.toString()
+ if (oldString.isNullOrEmpty()) {
+ return reject("$ARG_OLD is required — the exact text to replace, copied from the file")
+ }
+ // Presence, not blankness: an empty new_string is a deletion, which is a legal edit.
+ if (!args.containsKey(ARG_NEW)) {
+ return reject("$ARG_NEW is required — use an empty string to delete $ARG_OLD")
+ }
+ val newString = args[ARG_NEW]?.toString() ?: ""
+ val replaceAll = parseToolBoolean(args[ARG_REPLACE_ALL])
+
+ if (oldString.length > MAX_ARG_CHARS || newString.length > MAX_ARG_CHARS) {
+ return reject(
+ "$ARG_OLD/$ARG_NEW must be under $MAX_ARG_CHARS characters — edit a smaller region"
+ )
+ }
+ if (oldString == newString) {
+ return reject(identicalArgsRejection(oldString))
+ }
+
+ val target = when (val resolution = EditTargetResolver.resolve(filePath)) {
+ is EditTargetResolver.Target.Rejected -> {
+ if (tracing) AgentTrace.refusal("EDIT", "target path=$filePath", resolution.reason)
+ return reject(resolution.reason)
+ }
+ is EditTargetResolver.Target.Resolved -> resolution
+ }
+ if (tracing && target.correctedFrom != null) {
+ AgentTrace.stage("EDIT", "path corrected", "${target.correctedFrom} → ${target.displayPath}")
+ }
+ val file = target.file
+ val resolvedPath = target.displayPath
+
+ // Explicitly nullable: a platform type, but genuinely absent with no editor host.
+ val editorService: IdeEditorService? = pluginContext.services.get(IdeEditorService::class.java)
+ // Non-null only when the file is open; then it, not the stale disk copy, is what to match.
+ val buffer = editorService?.let { withContext(mainDispatcher) { it.getFileContent(file) } }
+
+ // Capped like the disk path: the edited copy is a second allocation of the same size.
+ if (buffer != null && buffer.length > MAX_EDIT_BYTES) {
+ return reject(
+ "File is too large to edit (${buffer.length} characters, limit $MAX_EDIT_BYTES): $resolvedPath"
+ )
+ }
+
+ val original = buffer ?: when (val read = readFromDisk(file, resolvedPath)) {
+ is DiskRead.Failed -> {
+ if (tracing) AgentTrace.refusal("EDIT", "read path=${file.name}", read.result.message)
+ return Analysis.Rejected(read.result)
+ }
+ is DiskRead.Text -> read.content
+ }
+ if (tracing) {
+ // Which copy was matched is the first thing to check for an edit on the wrong version.
+ AgentTrace.stage(
+ "EDIT",
+ "source=${if (buffer != null) "editor-buffer" else "disk"} " +
+ "path=${file.name} chars=${original.length} replaceAll=$replaceAll",
+ )
+ }
+
+ val match = FileTextMatcher.match(original, oldString, newString)
+ if (match is FileTextMatcher.Match.NotFound) {
+ // The dominant local-model failure: whitespace or escaping drift from the real file.
+ if (tracing) {
+ AgentTrace.refusal(
+ "EDIT",
+ "match=0 path=${file.name} oldChars=${oldString.length}",
+ "old_string not found — ${AgentTrace.preview(oldString, 80)}",
+ )
+ }
+ return reject(
+ "$ARG_OLD not found in $resolvedPath — read the file first and copy the target " +
+ "text exactly, including indentation and line breaks"
+ )
+ }
+ val found = match as FileTextMatcher.Match.Found
+ if (found.occurrences > 1 && !replaceAll) {
+ if (tracing) {
+ AgentTrace.refusal(
+ "EDIT",
+ "match=${found.occurrences} path=${file.name}",
+ "ambiguous old_string — ${AgentTrace.preview(oldString, 80)}",
+ )
+ }
+ return reject(ambiguousRejection(oldString, found.occurrences, resolvedPath))
+ }
+ if (tracing) {
+ AgentTrace.detail(
+ "EDIT",
+ "match=${found.occurrences} path=${file.name} oldChars=${found.oldString.length} " +
+ "newChars=${found.newString.length} crlfAdapted=${found.lineEndingsAdapted}",
+ )
+ }
+
+ return Analysis.Ready(
+ file = file,
+ filePath = resolvedPath,
+ oldString = found.oldString,
+ newString = found.newString,
+ original = original,
+ buffer = buffer,
+ editorService = editorService,
+ occurrences = found.occurrences,
+ )
+ }
+
+ private fun reject(message: String): Analysis = Analysis.Rejected(ToolResult.failure(message))
+
+ /**
+ * Rejection for an edit whose old and new text are the same, naming the corrected pair when the
+ * model pasted the user's instruction instead of the file's text.
+ * @param text the value sent as both [ARG_OLD] and [ARG_NEW].
+ * @return the message to return to the model.
+ */
+ private fun identicalArgsRejection(text: String): String {
+ val base = "$ARG_NEW is identical to $ARG_OLD — nothing to change. Put the text as it " +
+ "appears in the file in $ARG_OLD, and what it should become in $ARG_NEW."
+ val (from, to) = INSTRUCTION_PAIR.find(text.trim())?.destructured ?: return base
+ if (from == to) return base
+ return "$base You sent the request itself, not the file's text: retry with " +
+ "$ARG_OLD=\"$from\" and $ARG_NEW=\"$to\"."
+ }
+
+ /**
+ * Rejection for an [ARG_OLD] matching more than once. Which fix leads matters: for a bare name
+ * the user almost always meant every occurrence, and a model told to "add surrounding lines"
+ * first answers with one call per line instead of a single [ARG_REPLACE_ALL] edit.
+ * @param oldString the snippet that matched.
+ * @param occurrences how many times it matched.
+ * @param path the resolved file path, for the message.
+ * @return the message to return to the model.
+ */
+ private fun ambiguousRejection(oldString: String, occurrences: Int, path: String): String {
+ val head = "$ARG_OLD matched $occurrences times in $path — "
+ val looksLikeAName =
+ oldString.length <= MAX_IDENTIFIER_CHARS && oldString.none { it.isWhitespace() }
+ return if (looksLikeAName) {
+ head + "it looks like a name used throughout the file. Retry the SAME call with " +
+ "\"$ARG_REPLACE_ALL\":\"true\" to change all $occurrences in ONE edit — do not make " +
+ "one call per occurrence. Only if you meant a single one, add the surrounding lines " +
+ "to $ARG_OLD instead."
+ } else {
+ head + "add surrounding lines to make it unique, or set $ARG_REPLACE_ALL to true to " +
+ "change all of them"
+ }
+ }
+
+ /** Outcome of reading the on-disk copy: either usable text or the failure to report. */
+ private sealed interface DiskRead {
+ data class Text(val content: String) : DiskRead
+ data class Failed(val result: ToolResult) : DiskRead
+ }
+
+ /**
+ * Reads [file] as UTF-8 text, refusing anything too large to hold in memory twice or
+ * that isn't really text.
+ * @param file the resolved target.
+ * @param filePath the model-supplied path, for messages.
+ * @return the decoded text, or the failure to return to the model.
+ */
+ private fun readFromDisk(file: File, filePath: String): DiskRead {
+ if (!file.canRead()) {
+ return DiskRead.Failed(ToolResult.failure("Cannot read file: $filePath"))
+ }
+ val length = file.length()
+ if (length > MAX_EDIT_BYTES) {
+ return DiskRead.Failed(
+ ToolResult.failure(
+ "File is too large to edit ($length bytes, limit $MAX_EDIT_BYTES): $filePath"
+ )
+ )
+ }
+
+ val bytes = file.readBytes()
+ if (looksBinary(bytes)) {
+ return DiskRead.Failed(
+ ToolResult.failure(
+ "$filePath is not a UTF-8 text file — editing it would corrupt its contents"
+ )
+ )
+ }
+ return DiskRead.Text(String(bytes, StandardCharsets.UTF_8))
+ }
+
+ /**
+ * Hands the edit to the open editor's buffer.
+ * @return the result to hand back to the model.
+ */
+ private suspend fun applyToEditor(
+ ready: Analysis.Ready,
+ updated: String,
+ summary: String,
+ ): ToolResult {
+ val applier = EditorBufferApplier(requireNotNull(ready.editorService), mainDispatcher)
+ return when (
+ val outcome = applier.apply(
+ file = ready.file,
+ displayPath = ready.filePath,
+ matched = ready.original,
+ updated = updated,
+ oldString = ready.oldString,
+ newString = ready.newString,
+ occurrences = ready.occurrences,
+ )
+ ) {
+ is EditorBufferApplier.Outcome.Failed -> ToolResult.failure(outcome.reason)
+ is EditorBufferApplier.Outcome.Applied -> {
+ val savedNote =
+ if (outcome.saved) "saved" else "left unsaved in the editor — save it to persist"
+ AgentTrace.stage(
+ "EDIT",
+ "apply=editor ok saved=${outcome.saved} path=${ready.file.name} | $summary",
+ )
+ ToolResult.success(
+ message = "Edited ${ready.filePath} in the editor ($summary); $savedNote. " +
+ "The change can be undone with Ctrl+Z.",
+ data = ready.filePath
+ )
+ }
+ }
+ }
+
+ /**
+ * Writes the edit straight to disk, for a file no editor tab holds.
+ * @return the result to hand back to the model.
+ */
+ private fun applyToDisk(ready: Analysis.Ready, updated: String, summary: String): ToolResult {
+ val bytes = updated.toByteArray(StandardCharsets.UTF_8)
+ return when (val outcome = AtomicFileWriter.replace(ready.file, ready.filePath, bytes)) {
+ is AtomicFileWriter.Outcome.Failed -> ToolResult.failure(outcome.reason)
+ AtomicFileWriter.Outcome.Written -> {
+ AgentTrace.stage(
+ "EDIT",
+ "apply=disk ok bytes=${bytes.size} path=${ready.file.name} | $summary",
+ )
+ ToolResult.success(
+ message = "Edited ${ready.filePath} ($summary)",
+ data = ready.filePath
+ )
+ }
+ }
+ }
+
+ /**
+ * Whether [bytes] should be treated as binary. A NUL byte is decisive; otherwise the sample is
+ * decoded with errors REPORTed, not replaced — a lenient decode turns each bad byte into U+FFFD
+ * and writes it back as real UTF-8, corrupting the file while reporting success.
+ * @param bytes the file contents, of which only the leading sample is examined.
+ * @return true when the file must not be treated as editable text.
+ */
+ private fun looksBinary(bytes: ByteArray): Boolean {
+ val sampleSize = minOf(bytes.size, BINARY_SNIFF_BYTES)
+ for (i in 0 until sampleSize) if (bytes[i] == 0.toByte()) return true
+ return try {
+ StandardCharsets.UTF_8.newDecoder()
+ .onMalformedInput(CodingErrorAction.REPORT)
+ .onUnmappableCharacter(CodingErrorAction.REPORT)
+ .decode(ByteBuffer.wrap(bytes))
+ false
+ } catch (e: CharacterCodingException) {
+ true
+ }
+ }
+}
diff --git a/ai-assistant/src/main/kotlin/com/itsaky/androidide/plugins/aiassistant/tool/handlers/PathGuard.kt b/ai-assistant/src/main/kotlin/com/itsaky/androidide/plugins/aiassistant/tool/handlers/PathGuard.kt
index c62c4b7b..7c5883ec 100644
--- a/ai-assistant/src/main/kotlin/com/itsaky/androidide/plugins/aiassistant/tool/handlers/PathGuard.kt
+++ b/ai-assistant/src/main/kotlin/com/itsaky/androidide/plugins/aiassistant/tool/handlers/PathGuard.kt
@@ -86,6 +86,51 @@ object PathGuard {
/** Directories skipped when searching for a file by name — large/generated/noise. */
private val SKIP_DIRS = setOf("build", ".git", ".gradle", ".idea", "node_modules", ".cxx")
+ /**
+ * Directory names no tool may write into. [SKIP_DIRS] only constrains the basename
+ * *search*; containment alone happily resolves ".git/config" or "build/output.txt",
+ * and corrupting the git database is unrecoverable for a user with no other checkout.
+ */
+ private val WRITE_DENY_DIRS = SKIP_DIRS
+
+ /** Exact filenames no tool may write to — build/signing configuration and secrets. */
+ private val WRITE_DENY_NAMES = setOf(
+ "local.properties",
+ "gradle-wrapper.jar",
+ "gradle-wrapper.properties",
+ )
+
+ /** Extensions no tool may write to — keystores and other credential material. */
+ private val WRITE_DENY_EXTENSIONS = setOf("jks", "keystore", "p12", "pfx", "pem")
+
+ /**
+ * Explains why [file] is off-limits to writes, for a path already known to be in-root.
+ * Containment is necessary but not sufficient: generated trees, the git database, and
+ * signing material are all inside the project yet must never be machine-edited.
+ * @param file an in-root file (as returned by [resolveWithin]).
+ * @return a human/model-readable reason, or null when the file is a legal write target.
+ */
+ fun writeDenialReason(file: File): String? {
+ val root = File(projectRoot())
+ val relative = try {
+ file.canonicalFile.relativeToOrNull(root.canonicalFile)?.path
+ } catch (e: Exception) {
+ null
+ } ?: file.name
+
+ val segments = relative.split(File.separatorChar).filter { it.isNotEmpty() }
+ segments.dropLast(1).firstOrNull { it in WRITE_DENY_DIRS }?.let { dir ->
+ return "'$relative' is inside '$dir/', which is generated or internal and must not be edited"
+ }
+ if (file.name in WRITE_DENY_NAMES) {
+ return "'${file.name}' is build configuration and must not be edited by a tool"
+ }
+ if (file.extension.lowercase() in WRITE_DENY_EXTENSIONS) {
+ return "'${file.name}' looks like signing/credential material and must not be edited"
+ }
+ return null
+ }
+
/**
* Finds in-root files whose name equals [fileName] (case-insensitive), skipping
* generated/hidden dirs and symlinks, so a bare name resolves to a real path.
@@ -96,18 +141,73 @@ object PathGuard {
fun findByName(fileName: String, limit: Int = 20): List {
val target = baseNameOf(fileName).trim()
if (target.isEmpty()) return emptyList()
+ return walkProject(limit) { it.name.equals(target, ignoreCase = true) }
+ }
+ /**
+ * Candidates for a path that doesn't exist, in two tiers because they are not equally
+ * trustworthy. Callers may act on a lone [exact] match; anything else is a guess only the model
+ * or the user can settle.
+ * @property exact files whose name is exactly the one asked for, in a different folder.
+ * @property byStem files with the same name but a different extension — the "right class, wrong
+ * language" miss, which an exact-name search never finds.
+ */
+ data class PathSuggestions(val exact: List, val byStem: List) {
+ /** Every candidate, exact matches first. */
+ val all: List get() = exact + byStem
+
+ /**
+ * The one candidate worth correcting to without asking: a single exact-name match (even when
+ * same-stem files also exist — the extension the model asked for settles it), or a single
+ * same-stem match when nothing carries that name. Null when there is a choice to make;
+ * within a tier the order is filesystem-walk order, so picking the first of several would
+ * silently edit an arbitrary file.
+ */
+ val unambiguous: String?
+ get() = exact.singleOrNull() ?: byStem.singleOrNull()?.takeIf { exact.isEmpty() }
+ }
+
+ /**
+ * Suggests real project files for a path that doesn't exist, so a wrong guess is corrected in
+ * one turn.
+ * @param path the path the model asked for.
+ * @param limit maximum suggestions across both tiers.
+ * @return the candidates, possibly empty.
+ */
+ fun suggestPathsFor(path: String, limit: Int = 3): PathSuggestions {
+ val empty = PathSuggestions(emptyList(), emptyList())
+ val target = baseNameOf(path).trim()
+ if (target.isEmpty()) return empty
+ val stem = target.substringBeforeLast('.').lowercase()
+ if (stem.isEmpty()) return empty
+
+ val root = File(projectRoot())
+ val exact = findByName(target, limit)
+ val byStem = if (exact.size >= limit) emptyList() else {
+ walkProject(limit - exact.size) { it.nameWithoutExtension.lowercase() == stem && it !in exact }
+ }
+ fun relative(files: List) = files.map { it.relativeToOrSelf(root).path }
+ return PathSuggestions(relative(exact), relative(byStem))
+ }
+
+ /**
+ * Walks the project for files matching [predicate], skipping generated/hidden trees and
+ * refusing to descend symlinks (which could otherwise walk outside the root).
+ * @param limit maximum results.
+ * @param predicate the file test.
+ * @return matching in-root files.
+ */
+ private fun walkProject(limit: Int, predicate: (File) -> Boolean): List {
val root = File(projectRoot())
if (!isValidRoot(root)) return emptyList()
val rootWithSep = root.canonicalPath.let { if (it.endsWith(File.separator)) it else it + File.separator }
return root.walkTopDown()
- // Don't descend symlinked dirs; walkTopDown matches by name and could escape the root.
.onEnter { dir ->
(dir == root || (dir.name !in SKIP_DIRS && !dir.name.startsWith("."))) &&
(dir == root || !Files.isSymbolicLink(dir.toPath()))
}
- .filter { it.isFile && it.name.equals(target, ignoreCase = true) }
+ .filter { it.isFile && predicate(it) }
// Re-verify containment so a symlinked file resolving outside the root is dropped.
.filter { it.canonicalPath.startsWith(rootWithSep) }
.take(limit)
diff --git a/ai-assistant/src/main/kotlin/com/itsaky/androidide/plugins/aiassistant/tool/handlers/edit/AtomicFileWriter.kt b/ai-assistant/src/main/kotlin/com/itsaky/androidide/plugins/aiassistant/tool/handlers/edit/AtomicFileWriter.kt
new file mode 100644
index 00000000..2b5878a2
--- /dev/null
+++ b/ai-assistant/src/main/kotlin/com/itsaky/androidide/plugins/aiassistant/tool/handlers/edit/AtomicFileWriter.kt
@@ -0,0 +1,110 @@
+package com.itsaky.androidide.plugins.aiassistant.tool.handlers.edit
+
+import android.util.Log
+import com.itsaky.androidide.plugins.aiassistant.utils.AgentTrace
+import java.io.File
+import java.io.FileOutputStream
+import java.io.IOException
+import java.nio.file.AtomicMoveNotSupportedException
+import java.nio.file.Files
+import java.nio.file.StandardCopyOption
+
+/**
+ * Replaces a file's contents without ever leaving it half-written: the new bytes are fsync'd to a
+ * temp file in the **same directory**, then moved over the target. That move is atomic, so an
+ * interrupted write cannot truncate the original — unlike write-then-restore.
+ */
+object AtomicFileWriter {
+
+ private const val TAG = "AtomicFileWriter"
+
+ /** Suffix of the staging file; visible in a directory listing only while a write is running. */
+ private const val TEMP_SUFFIX = ".aiedit"
+
+ /** Outcome of [replace]. */
+ sealed interface Outcome {
+ /** The target now holds the new bytes. */
+ object Written : Outcome
+
+ /**
+ * Nothing was changed.
+ * @property reason a model-readable explanation.
+ */
+ data class Failed(val reason: String) : Outcome
+ }
+
+ /**
+ * Atomically replaces [file]'s contents with [bytes].
+ * @param file the target, which must already exist and be writable.
+ * @param displayPath the project-relative path to name in messages.
+ * @param bytes the complete new contents.
+ * @return [Outcome.Written], or [Outcome.Failed] with the original file untouched.
+ */
+ fun replace(file: File, displayPath: String, bytes: ByteArray): Outcome {
+ if (!file.canWrite()) {
+ return Outcome.Failed("File is not writable: $displayPath")
+ }
+ val dir = file.parentFile
+ ?: return Outcome.Failed("Cannot resolve the directory of $displayPath")
+ // Room for the temp copy alongside the original, before anything is touched.
+ if (dir.usableSpace in 1 until bytes.size.toLong() * 2) {
+ return Outcome.Failed("Not enough free space to safely write $displayPath")
+ }
+
+ // A move adopts the temp mode (0600), so carry the original's over; best effort on FAT.
+ val permissions = runCatching { Files.getPosixFilePermissions(file.toPath()) }.getOrNull()
+
+ val temp = File.createTempFile(".${file.name}.", TEMP_SUFFIX, dir)
+ return try {
+ FileOutputStream(temp).use { out ->
+ out.write(bytes)
+ out.flush()
+ out.fd.sync()
+ }
+ permissions?.let { runCatching { Files.setPosixFilePermissions(temp.toPath(), it) } }
+ AgentTrace.detail("EDIT", "apply=disk staged bytes=${bytes.size} temp=${temp.name}")
+ if (!moveIntoPlace(temp, file)) {
+ AgentTrace.refusal("EDIT", "apply=disk path=${file.name}", "move failed; original untouched")
+ return Outcome.Failed("Could not replace $displayPath — the original is unchanged")
+ }
+ Outcome.Written
+ } finally {
+ // A move consumes the temp file, so this only fires on paths that never got there.
+ if (temp.exists()) temp.delete()
+ }
+ }
+
+ /**
+ * Moves [temp] over [file], degrading through the strategies Android's volumes support. NIO goes
+ * first for its exception messages, where `renameTo` reports a bare `false`; both fallbacks
+ * matter, as `ATOMIC_MOVE` is refused on some FUSE-backed emulated storage.
+ * @param temp the staged file holding the new bytes.
+ * @param file the destination, which always already exists.
+ * @return true when [file] now holds the staged bytes.
+ */
+ private fun moveIntoPlace(temp: File, file: File): Boolean {
+ try {
+ Files.move(
+ temp.toPath(),
+ file.toPath(),
+ StandardCopyOption.REPLACE_EXISTING,
+ StandardCopyOption.ATOMIC_MOVE,
+ )
+ return true
+ } catch (e: AtomicMoveNotSupportedException) {
+ Log.d(TAG, "Atomic move unsupported for ${file.name}; retrying as a plain replace", e)
+ } catch (e: IOException) {
+ Log.w(TAG, "NIO move of ${file.name} failed; falling back", e)
+ } catch (e: UnsupportedOperationException) {
+ Log.w(TAG, "NIO move of ${file.name} unsupported on this volume; falling back", e)
+ }
+
+ return try {
+ Files.move(temp.toPath(), file.toPath(), StandardCopyOption.REPLACE_EXISTING)
+ true
+ } catch (e: Exception) {
+ Log.w(TAG, "Plain NIO move of ${file.name} failed; trying platform rename", e)
+ temp.renameTo(file)
+ }
+ }
+}
diff --git a/ai-assistant/src/main/kotlin/com/itsaky/androidide/plugins/aiassistant/tool/handlers/edit/EditTargetResolver.kt b/ai-assistant/src/main/kotlin/com/itsaky/androidide/plugins/aiassistant/tool/handlers/edit/EditTargetResolver.kt
new file mode 100644
index 00000000..002975a5
--- /dev/null
+++ b/ai-assistant/src/main/kotlin/com/itsaky/androidide/plugins/aiassistant/tool/handlers/edit/EditTargetResolver.kt
@@ -0,0 +1,83 @@
+package com.itsaky.androidide.plugins.aiassistant.tool.handlers.edit
+
+import android.util.Log
+import com.itsaky.androidide.plugins.aiassistant.tool.handlers.PathGuard
+import java.io.File
+
+/**
+ * Turns the path a model asked for into the file an edit may touch. Containment is strict, but an
+ * invented path with exactly ONE plausible candidate is corrected rather than bounced back, which
+ * costs 4–9s per local turn. Not silent: the corrected path is what the approval dialog shows.
+ */
+object EditTargetResolver {
+
+ private const val TAG = "EditTargetResolver"
+
+ /** Outcome of [resolve]. */
+ sealed interface Target {
+ /**
+ * An existing, editable in-root file.
+ * @property file the resolved file.
+ * @property displayPath the path to show and report — the corrected one when the
+ * model's guess was wrong.
+ * @property correctedFrom the model's original guess when it was corrected, else null.
+ */
+ data class Resolved(
+ val file: File,
+ val displayPath: String,
+ val correctedFrom: String?,
+ ) : Target
+
+ /**
+ * No file may be edited for this path.
+ * @property reason a model-readable explanation, phrased to tell it what to do next.
+ */
+ data class Rejected(val reason: String) : Target
+ }
+
+ /**
+ * Resolves [filePath] to an editable project file.
+ * @param filePath the model-supplied path.
+ * @return the target, or the rejection to report.
+ */
+ fun resolve(filePath: String): Target {
+ val requested = PathGuard.resolveWithin(filePath)
+ ?: return Target.Rejected("File path must be within project directory")
+
+ var displayPath = filePath
+ var correctedFrom: String? = null
+ val file = if (requested.exists()) requested else {
+ val suggestions = PathGuard.suggestPathsFor(filePath)
+ val candidate = suggestions.unambiguous
+ when {
+ candidate != null -> {
+ val corrected = PathGuard.resolveWithin(candidate)
+ ?: return Target.Rejected("File does not exist: $filePath")
+ Log.i(TAG, "Resolved guessed path '$filePath' to '$candidate'")
+ correctedFrom = filePath
+ displayPath = candidate
+ corrected
+ }
+ suggestions.all.isEmpty() -> return Target.Rejected(
+ "File does not exist: $filePath — locate the real path with " +
+ "search_project first, or use create_file to make a new file"
+ )
+ else -> return Target.Rejected(
+ "File does not exist: $filePath — did you mean " +
+ suggestions.all.joinToString(" or ") { "\"$it\"" } +
+ "? Retry with that exact path."
+ )
+ }
+ }
+
+ if (!file.isFile) return Target.Rejected("Path is not a file: $displayPath")
+
+ // In-root is not enough: build trees, .git and keystores must never be machine-edited.
+ PathGuard.writeDenialReason(file)?.let { reason ->
+ Log.w(TAG, "Refusing edit of protected path: ${file.path}")
+ return Target.Rejected("Cannot edit $reason")
+ }
+
+ return Target.Resolved(file, displayPath, correctedFrom)
+ }
+}
diff --git a/ai-assistant/src/main/kotlin/com/itsaky/androidide/plugins/aiassistant/tool/handlers/edit/EditorBufferApplier.kt b/ai-assistant/src/main/kotlin/com/itsaky/androidide/plugins/aiassistant/tool/handlers/edit/EditorBufferApplier.kt
new file mode 100644
index 00000000..dec612b6
--- /dev/null
+++ b/ai-assistant/src/main/kotlin/com/itsaky/androidide/plugins/aiassistant/tool/handlers/edit/EditorBufferApplier.kt
@@ -0,0 +1,159 @@
+package com.itsaky.androidide.plugins.aiassistant.tool.handlers.edit
+
+import android.util.Log
+import com.itsaky.androidide.plugins.aiassistant.utils.AgentTrace
+import com.itsaky.androidide.plugins.services.IdeEditorService
+import com.itsaky.androidide.plugins.services.SelectionRange
+import kotlinx.coroutines.CoroutineDispatcher
+import kotlinx.coroutines.withContext
+import java.io.File
+
+/**
+ * Applies an edit to a file **open in the editor** through the buffer, not behind it: unsaved work is
+ * what changes, the change is one Ctrl+Z away, and no stale buffer can overwrite it. Owns the
+ * coordinate conversion, since [IdeEditorService.replaceRange] takes 0-based (line, column) pairs.
+ * @param editorService the host editor.
+ * @param mainDispatcher dispatcher for editor-UI calls; overridden in unit tests.
+ */
+class EditorBufferApplier(
+ private val editorService: IdeEditorService,
+ private val mainDispatcher: CoroutineDispatcher,
+) {
+
+ private companion object {
+ const val TAG = "EditorBufferApplier"
+ }
+
+ /** Outcome of [apply]. */
+ sealed interface Outcome {
+ /**
+ * The buffer now holds the edit.
+ * @property saved whether it also reached disk.
+ */
+ data class Applied(val saved: Boolean) : Outcome
+
+ /**
+ * Nothing was changed.
+ * @property reason a model-readable explanation.
+ */
+ data class Failed(val reason: String) : Outcome
+ }
+
+ /**
+ * Replaces [oldString] with [newString] in [file]'s open buffer, then saves it. A single match is
+ * replaced in place, keeping undo history; `replace_all` and CRLF swap the whole buffer instead.
+ * Re-read and written in **one** main-thread block, so no offset can land on concurrent typing.
+ * @param file the open file.
+ * @param displayPath the project-relative path to name in messages.
+ * @param matched the buffer text the edit was computed against.
+ * @param updated [matched] with the replacement already applied.
+ * @param oldString the snippet as it appears in [matched].
+ * @param newString its replacement.
+ * @param occurrences how many times [oldString] appears in [matched].
+ * @return the outcome to report to the model.
+ */
+ suspend fun apply(
+ file: File,
+ displayPath: String,
+ matched: String,
+ updated: String,
+ oldString: String,
+ newString: String,
+ occurrences: Int,
+ ): Outcome {
+ // In-place for one match with no CR to confuse the line model; else swap the buffer.
+ val inPlace = occurrences == 1 && !matched.contains('\r') && !oldString.contains('\r')
+ val range = if (inPlace) {
+ rangeOf(matched, matched.indexOf(oldString), oldString)
+ } else {
+ wholeBufferRange(matched)
+ }
+ val replacement = if (inPlace) newString else updated
+
+ AgentTrace.stage(
+ "EDIT",
+ "apply=editor mode=${if (inPlace) "in-place" else "whole-buffer"} " +
+ "range=${range.startLine}:${range.startColumn}-${range.endLine}:${range.endColumn}",
+ )
+
+ val outcome = withContext(mainDispatcher) {
+ val current = editorService.getFileContent(file)
+ if (current != matched) {
+ AgentTrace.refusal(
+ "EDIT", "apply=editor path=${file.name}",
+ "buffer changed after analysis (was ${matched.length} chars, " +
+ "now ${current?.length ?: -1}); not applying stale offsets",
+ )
+ return@withContext Outcome.Failed(
+ "The editor buffer for $displayPath changed while this edit was waiting for " +
+ "approval — read the file again and redo the edit against its current text"
+ )
+ }
+
+ if (!editorService.replaceRange(file, range, replacement)) {
+ AgentTrace.refusal("EDIT", "apply=editor path=${file.name}", "replaceRange returned false")
+ return@withContext Outcome.Failed(
+ "Could not apply the edit to the open editor for $displayPath — close the file and retry"
+ )
+ }
+
+ // saveCurrentFile() saves the FOCUSED tab, so saving unfocused persists another file.
+ val focused = editorService.openFile(file)
+ if (!focused) {
+ AgentTrace.refusal(
+ "EDIT", "apply=editor path=${file.name}",
+ "openFile returned false; not saving, to avoid saving a different tab",
+ )
+ }
+ Outcome.Applied(saved = focused && editorService.saveCurrentFile())
+ }
+
+ if (outcome is Outcome.Applied) {
+ Log.d(TAG, "Edited $displayPath in the editor buffer (saved=${outcome.saved})")
+ }
+ return outcome
+ }
+
+ /**
+ * Maps a character offset span onto the editor's 0-based (line, column) coordinates.
+ * @param text the buffer contents the offsets refer to.
+ * @param start offset of the first character to replace.
+ * @param match the matched text, whose length gives the end offset.
+ */
+ private fun rangeOf(text: String, start: Int, match: String): SelectionRange {
+ val end = start + match.length
+ val startLine = text.countNewlinesBefore(start)
+ val endLine = text.countNewlinesBefore(end)
+ return SelectionRange(
+ startLine,
+ start - text.lineStartOffset(startLine),
+ endLine,
+ end - text.lineStartOffset(endLine),
+ )
+ }
+
+ /** The span covering the entire buffer, for a whole-content replacement. */
+ private fun wholeBufferRange(text: String): SelectionRange {
+ val lastLine = text.count { it == '\n' }
+ return SelectionRange(0, 0, lastLine, text.length - text.lineStartOffset(lastLine))
+ }
+
+ private fun String.countNewlinesBefore(offset: Int): Int {
+ var count = 0
+ for (i in 0 until offset) if (this[i] == '\n') count++
+ return count
+ }
+
+ /** Character offset at which 0-based [line] starts. */
+ private fun String.lineStartOffset(line: Int): Int {
+ if (line == 0) return 0
+ var seen = 0
+ for (i in indices) {
+ if (this[i] == '\n') {
+ seen++
+ if (seen == line) return i + 1
+ }
+ }
+ return length
+ }
+}
diff --git a/ai-assistant/src/main/kotlin/com/itsaky/androidide/plugins/aiassistant/tool/handlers/edit/FileTextMatcher.kt b/ai-assistant/src/main/kotlin/com/itsaky/androidide/plugins/aiassistant/tool/handlers/edit/FileTextMatcher.kt
new file mode 100644
index 00000000..48c457d5
--- /dev/null
+++ b/ai-assistant/src/main/kotlin/com/itsaky/androidide/plugins/aiassistant/tool/handlers/edit/FileTextMatcher.kt
@@ -0,0 +1,92 @@
+package com.itsaky.androidide.plugins.aiassistant.tool.handlers.edit
+
+/**
+ * Locates the model's snippet literally, never by regex: a model-supplied pattern would throw on an
+ * unbalanced bracket and can backtrack catastrophically in-process. A miss is retried in the file's
+ * own line-ending convention, adapting the snippet and never the file's other lines.
+ */
+object FileTextMatcher {
+
+ private const val CRLF = "\r\n"
+ private const val LF = "\n"
+
+ /** Outcome of [match]. */
+ sealed interface Match {
+ /**
+ * The snippet was found.
+ * @property oldString the snippet as it actually appears in the text — the original, or
+ * its line-ending-adapted form. Callers must replace with *this*, not their input.
+ * @property newString the replacement, adapted the same way, so an edit cannot leave
+ * mixed line endings behind.
+ * @property occurrences how many non-overlapping times [oldString] appears.
+ * @property lineEndingsAdapted whether adaptation was needed (for tracing).
+ */
+ data class Found(
+ val oldString: String,
+ val newString: String,
+ val occurrences: Int,
+ val lineEndingsAdapted: Boolean,
+ ) : Match
+
+ /** The snippet is absent, in any line-ending convention. */
+ object NotFound : Match
+ }
+
+ /**
+ * Finds [oldString] in [text], retrying in the text's line-ending convention if needed.
+ * @param text the file contents (an editor buffer or the on-disk copy).
+ * @param oldString the snippet the model wants replaced.
+ * @param newString what to put in its place (may be empty — a deletion).
+ * @return the match, with both snippets in the text's own convention.
+ */
+ fun match(text: String, oldString: String, newString: String): Match {
+ val direct = countOccurrences(text, oldString)
+ if (direct > 0) {
+ return Match.Found(oldString, newString, direct, lineEndingsAdapted = false)
+ }
+ // Single-line snippets have no line ending to get wrong, so there is nothing to retry.
+ if (!oldString.contains(LF)) return Match.NotFound
+
+ val adaptedOld = adaptTo(text, oldString) ?: return Match.NotFound
+ val occurrences = countOccurrences(text, adaptedOld)
+ if (occurrences == 0) return Match.NotFound
+ return Match.Found(adaptedOld, adaptTo(text, newString) ?: newString, occurrences, true)
+ }
+
+ /**
+ * Rewrites [snippet]'s line endings to the convention [text] uses.
+ * @param text the file contents, whose convention wins.
+ * @param snippet the snippet to convert.
+ * @return the converted snippet, or null when [text]'s convention is already the snippet's
+ * (nothing to try) or is mixed (no single convention to convert to).
+ */
+ private fun adaptTo(text: String, snippet: String): String? {
+ val textHasCrlf = text.contains(CRLF)
+ // A lone LF outside a CRLF pair means mixed endings, where either conversion is a guess.
+ val textHasBareLf = text.replace(CRLF, "").contains(LF)
+ return when {
+ textHasCrlf && !textHasBareLf && !snippet.contains('\r') -> toCrlf(snippet)
+ !textHasCrlf && snippet.contains(CRLF) -> snippet.replace(CRLF, LF)
+ else -> null
+ }
+ }
+
+ private fun toCrlf(snippet: String): String = snippet.replace(CRLF, LF).replace(LF, CRLF)
+
+ /**
+ * Non-overlapping occurrence count of [needle] in [haystack].
+ * @param haystack the text to search.
+ * @param needle the text to look for.
+ * @return how many times the snippet appears; 0 for an empty needle.
+ */
+ fun countOccurrences(haystack: String, needle: String): Int {
+ if (needle.isEmpty()) return 0
+ var count = 0
+ var index = haystack.indexOf(needle)
+ while (index >= 0) {
+ count++
+ index = haystack.indexOf(needle, index + needle.length)
+ }
+ return count
+ }
+}
diff --git a/ai-assistant/src/main/kotlin/com/itsaky/androidide/plugins/aiassistant/utils/AgentTrace.kt b/ai-assistant/src/main/kotlin/com/itsaky/androidide/plugins/aiassistant/utils/AgentTrace.kt
new file mode 100644
index 00000000..d1f79157
--- /dev/null
+++ b/ai-assistant/src/main/kotlin/com/itsaky/androidide/plugins/aiassistant/utils/AgentTrace.kt
@@ -0,0 +1,122 @@
+package com.itsaky.androidide.plugins.aiassistant.utils
+
+import android.util.Log
+import com.itsaky.androidide.plugins.aiassistant.BuildConfig
+import java.util.concurrent.atomic.AtomicInteger
+
+/**
+ * One log stream for a whole agent run: prompt, model turns, parsing, guards, approval, result.
+ * Every line shares one tag, a per-run id, elapsed ms and a sequence number (`adb logcat -s
+ * AIAgentTrace:*`). Runs never overlap, so the current run is object state, not a parameter.
+ */
+object AgentTrace {
+
+ const val TAG = "AIAgentTrace"
+
+ /** Cap on any previewed value; enough to recognise a snippet, too short to be a dump. */
+ const val PREVIEW_CHARS = 120
+
+ /**
+ * Whether previewed content (prompt, code snippets, model replies) reaches logcat: debug only.
+ * The structured head of each line is what a trace is read for and always logs; a release build
+ * has no reason to write the user's source into a log it does not own.
+ */
+ private val CONTENT_LOGGING = BuildConfig.DEBUG
+
+ @Volatile
+ private var runId: String = "-"
+
+ @Volatile
+ private var runStartMs: Long = 0L
+
+ private val runCounter = AtomicInteger()
+ private val sequence = AtomicInteger()
+
+ /**
+ * Starts a new traced run and logs the prompt that opened it.
+ * @param backend the backend id serving this run.
+ * @param prompt the user's message.
+ * @param contextFiles how many context files were attached.
+ * @return the new run id (also used implicitly by every later call).
+ */
+ fun beginRun(backend: String, prompt: String, contextFiles: Int): String {
+ runId = "r${runCounter.incrementAndGet()}"
+ runStartMs = System.currentTimeMillis()
+ sequence.set(0)
+ stage(
+ "PROMPT",
+ "backend=$backend chars=${prompt.length} contextFiles=$contextFiles",
+ preview(prompt),
+ )
+ return runId
+ }
+
+ /**
+ * Closes the current run.
+ * @param outcome how it ended (a loop stop reason, "cancelled", or "error").
+ * @param turns model turns executed, when known.
+ */
+ fun endRun(outcome: String, turns: Int? = null) {
+ stage("DONE", "outcome=$outcome" + (turns?.let { " turns=$it" } ?: ""))
+ runId = "-"
+ }
+
+ /**
+ * Logs a milestone in the run at INFO — the lines you want when following the flow.
+ * @param stage short uppercase phase name (PROMPT, LLM, TOOL, APPROVAL, EXEC, …).
+ * @param detail structured `key=value` facts.
+ * @param preview optional free text, already previewed by the caller.
+ */
+ fun stage(stage: String, detail: String, preview: String? = null) {
+ Log.i(TAG, line(stage, detail, preview))
+ }
+
+ /**
+ * Logs a supporting fact at DEBUG — filtered out of a normal `-s AIAgentTrace:I` read.
+ * @param stage short uppercase phase name.
+ * @param detail structured `key=value` facts.
+ * @param preview optional free text, already previewed by the caller.
+ */
+ fun detail(stage: String, detail: String, preview: String? = null) {
+ Log.d(TAG, line(stage, detail, preview))
+ }
+
+ /**
+ * Logs a rejected or failed step at WARN. A guard refusing to act is normal operation, not an
+ * error, but it is the thing you go looking for when a tool "did nothing".
+ * @param stage short uppercase phase name.
+ * @param detail structured `key=value` facts.
+ * @param reason the refusal or failure message.
+ */
+ fun refusal(stage: String, detail: String, reason: String) {
+ Log.w(TAG, line(stage, detail, preview(reason)))
+ }
+
+ /**
+ * Flattens and truncates a value for logging.
+ * @param value any argument, prompt, or response text.
+ * @param limit maximum characters to keep.
+ * @return a single-line, length-capped rendering, quoted, or `null` for a null value.
+ */
+ fun preview(value: Any?, limit: Int = PREVIEW_CHARS): String {
+ if (value == null) return "null"
+ val flat = value.toString().replace("\n", "⏎").replace("\r", "")
+ return if (flat.length <= limit) "\"$flat\""
+ else "\"${flat.take(limit)}…\"(${flat.length} chars)"
+ }
+
+ /**
+ * Renders tool-call arguments as `key=preview` pairs, so a call is readable in one line
+ * without its snippets swamping the log.
+ * @param args the call arguments.
+ * @return the rendered argument list.
+ */
+ fun previewArgs(args: Map): String =
+ args.entries.joinToString(" ") { "${it.key}=${preview(it.value, 60)}" }
+
+ private fun line(stage: String, detail: String, preview: String?): String {
+ val elapsed = if (runStartMs == 0L) 0 else System.currentTimeMillis() - runStartMs
+ val head = "[$runId +${elapsed}ms #${sequence.incrementAndGet()}] $stage | $detail"
+ return if (preview == null || !CONTENT_LOGGING) head else "$head | $preview"
+ }
+}
diff --git a/ai-assistant/src/main/kotlin/com/itsaky/androidide/plugins/aiassistant/utils/ToolArgs.kt b/ai-assistant/src/main/kotlin/com/itsaky/androidide/plugins/aiassistant/utils/ToolArgs.kt
new file mode 100644
index 00000000..ea10f9c9
--- /dev/null
+++ b/ai-assistant/src/main/kotlin/com/itsaky/androidide/plugins/aiassistant/utils/ToolArgs.kt
@@ -0,0 +1,17 @@
+package com.itsaky.androidide.plugins.aiassistant.utils
+
+/** Argument values a model uses to mean "true"; the tool-call grammar has no boolean type. */
+private val TRUE_WORDS = setOf("true", "yes", "1")
+
+/**
+ * Reads a boolean out of a tool-call argument, tolerating the strings a model emits instead
+ * (`"true"`, `"yes"`, `"1"`, any casing). Shared so the dialog and the handler cannot disagree about
+ * `replace_all`; the no-argument [String.lowercase] keeps `"TRUE"` matching under tr-TR.
+ * @param value the raw argument value, or null when absent.
+ * @return true only for an explicit affirmative; false for null and anything else.
+ */
+fun parseToolBoolean(value: Any?): Boolean = when (value) {
+ null -> false
+ is Boolean -> value
+ else -> value.toString().trim().lowercase() in TRUE_WORDS
+}
diff --git a/ai-assistant/src/main/kotlin/com/itsaky/androidide/plugins/aiassistant/viewmodel/AgentReplyRenderer.kt b/ai-assistant/src/main/kotlin/com/itsaky/androidide/plugins/aiassistant/viewmodel/AgentReplyRenderer.kt
new file mode 100644
index 00000000..d48b2214
--- /dev/null
+++ b/ai-assistant/src/main/kotlin/com/itsaky/androidide/plugins/aiassistant/viewmodel/AgentReplyRenderer.kt
@@ -0,0 +1,46 @@
+package com.itsaky.androidide.plugins.aiassistant.viewmodel
+
+import com.itsaky.androidide.plugins.aiassistant.tool.ToolCall
+import com.itsaky.androidide.plugins.aiassistant.tool.ToolCallExtractor
+import com.itsaky.androidide.plugins.aiassistant.tool.respondMessageOf
+
+/**
+ * Decides what a model turn looks like in the transcript. Pure and string-injected so the precedence
+ * is testable: it exists to stop a finished answer becoming "(no response)" because the model filed
+ * it under an odd key, or wrote it as prose beside an empty `respond` envelope.
+ */
+object AgentReplyRenderer {
+
+ /**
+ * Renders one model turn.
+ * @param rawText the model's raw reply.
+ * @param toolCalls the calls parsed out of it.
+ * @param terminalTool the name of the answer-carrying pseudo-tool (`respond`).
+ * @param lastToolFailed whether this run's most recent tool call failed.
+ * @param actionFailedText what to show when the model claims success after a failed tool.
+ * @param noResponseText last-resort text when the turn carries nothing to show.
+ * @param renderToolCall renders one tool call as a badge line.
+ * @return the text to display for this turn.
+ */
+ fun render(
+ rawText: String,
+ toolCalls: List,
+ terminalTool: String,
+ lastToolFailed: Boolean,
+ actionFailedText: String,
+ noResponseText: String,
+ renderToolCall: (ToolCall) -> String,
+ ): String {
+ val respondCall = toolCalls.firstOrNull { it.name == terminalTool }
+ return when {
+ respondCall != null && lastToolFailed -> actionFailedText
+ // The answer wherever the model put it, then the prose beside an empty envelope.
+ respondCall != null ->
+ respondMessageOf(respondCall.args)
+ ?: ToolCallExtractor.proseOutsideToolCalls(rawText)
+ ?: noResponseText
+ toolCalls.isNotEmpty() -> toolCalls.joinToString("\n", transform = renderToolCall)
+ else -> rawText.ifBlank { noResponseText }
+ }
+ }
+}
diff --git a/ai-assistant/src/main/kotlin/com/itsaky/androidide/plugins/aiassistant/viewmodel/ChatViewModel.kt b/ai-assistant/src/main/kotlin/com/itsaky/androidide/plugins/aiassistant/viewmodel/ChatViewModel.kt
index e0ebd8f6..2ba88ce5 100644
--- a/ai-assistant/src/main/kotlin/com/itsaky/androidide/plugins/aiassistant/viewmodel/ChatViewModel.kt
+++ b/ai-assistant/src/main/kotlin/com/itsaky/androidide/plugins/aiassistant/viewmodel/ChatViewModel.kt
@@ -20,6 +20,8 @@ import com.itsaky.androidide.plugins.aiassistant.tool.ApprovalRequest
import com.itsaky.androidide.plugins.aiassistant.tool.ApprovalResult
import com.itsaky.androidide.plugins.aiassistant.tool.handlers.AddDependencyHandler
import com.itsaky.androidide.plugins.aiassistant.tool.handlers.CreateFileHandler
+import com.itsaky.androidide.plugins.aiassistant.tool.handlers.EditFileHandler
+import com.itsaky.androidide.plugins.aiassistant.tool.handlers.PathGuard
import com.itsaky.androidide.plugins.aiassistant.tool.handlers.GenerateFromTemplateHandler
import com.itsaky.androidide.plugins.aiassistant.tool.handlers.GradleSyncHandler
import com.itsaky.androidide.plugins.aiassistant.tool.handlers.ListFilesHandler
@@ -29,6 +31,8 @@ import com.itsaky.androidide.plugins.aiassistant.tool.handlers.ReadFileHandler
import com.itsaky.androidide.plugins.aiassistant.tool.handlers.SearchProjectHandler
import com.itsaky.androidide.plugins.aiassistant.tool.handlers.UpdateFileHandler
import com.itsaky.androidide.plugins.aiassistant.data.ChatStorageManager
+import com.itsaky.androidide.plugins.aiassistant.utils.AgentTrace
+import com.itsaky.androidide.plugins.services.IdeEditorService
import com.itsaky.androidide.plugins.aiassistant.utils.ToolExecutionTracker
import com.itsaky.androidide.plugins.services.LlmInferenceService
import com.itsaky.androidide.plugins.services.SharedServices
@@ -61,8 +65,29 @@ class ChatViewModel(
/** Terminal tool: shared by [agentLoop] (stops on it) and [runModelTurn] (renders its message). */
const val RESPOND_TOOL = "respond"
- /** [LlmConfig.extraParams] key for the local-backend GBNF; must match ai-core's `LocalLlmBackend.EXTRA_PARAM_GRAMMAR`. */
+ /**
+ * [LlmConfig.extraParams] key for the local-backend GBNF; must match ai-core's
+ * `LocalLlmBackend.EXTRA_PARAM_GRAMMAR`.
+ */
private const val EXTRA_PARAM_GRAMMAR = "grammar"
+
+ /** Per-argument cap in the tool badge shown in the transcript. */
+ private const val TOOL_BADGE_ARG_LIMIT = 80
+
+ /** Near-greedy sampling for local models, whose tool arguments must be copied, not invented. */
+ private const val LOCAL_TEMPERATURE = 0.15f
+
+ /** Max open files named in the prompt's IDE-context block. */
+ private const val MAX_CONTEXT_OPEN_FILES = 8
+
+ /**
+ * Path used in the tool-call examples when the IDE has nothing open, so there is no real one
+ * to show. A concrete path is what a small model needs to copy the *shape* from — a
+ * placeholder like "path/to/File.ext" measurably degrades its calls — so this is the
+ * dominant CoGo project layout rather than a language-neutral token. Whenever a file *is*
+ * open, [IdeSnapshot.exampleFilePath] uses that instead and this is never seen.
+ */
+ private const val FALLBACK_EXAMPLE_PATH = "app/src/main/java/com/example/MainActivity.kt"
}
/**
@@ -123,10 +148,9 @@ class ChatViewModel(
private var currentBackendId: String = "local" // Default to local backend
/**
- * Human-readable label for the backend the user has *selected* in settings, shown under the
- * chat input. This tracks the selection (the `ai_backend_preference`), NOT the
- * availability-resolved backend — selecting Gemini must read "Gemini API" even before its
- * API key check runs, otherwise it would always fall back to "Local LLM".
+ * Label for the backend the user *selected* in settings, shown under the chat input. Tracks the
+ * selection, not the availability-resolved backend: picking Gemini must read "Gemini API" before
+ * its key check runs, or it would always show "Local LLM".
*/
private val _activeBackendLabel = MutableStateFlow(selectedBackendLabel())
val activeBackendLabel: StateFlow = _activeBackendLabel.asStateFlow()
@@ -192,6 +216,7 @@ class ChatViewModel(
// Write tools
CreateFileHandler(context),
UpdateFileHandler(context),
+ EditFileHandler(context),
AddDependencyHandler(context),
// Build tools
com.itsaky.androidide.plugins.aiassistant.tool.handlers.RunAppHandler(context),
@@ -236,8 +261,8 @@ class ChatViewModel(
/**
* Submit user's approval decision.
*/
- fun submitApproval(result: ApprovalResult) {
- approvalManager.submitApproval(result)
+ fun submitApproval(result: ApprovalResult, correction: String? = null) {
+ approvalManager.submitApproval(result, correction)
}
/**
@@ -268,10 +293,10 @@ class ChatViewModel(
}
/**
- * Surface a configuration/setup problem to the user both ways: a persistent SYSTEM error
- * bubble in the transcript, and [AgentState.Error] so the fragment can show a transient,
- * actionable Snackbar. Used by the [sendMessage] pre-flight guards, which reject the request
- * before any backend runs — so the downstream `onError`/UserFeedback feedback never fires.
+ * Surfaces a setup problem both ways: a persistent SYSTEM bubble and [AgentState.Error] for the
+ * fragment's Snackbar. Used by [sendMessage]'s pre-flight guards, which reject before any backend
+ * runs, so the downstream `onError`/UserFeedback path never fires.
+ * @param text the error text to show.
*/
private fun emitSystemError(text: String) {
val errorMessage = ChatMessage(
@@ -313,18 +338,89 @@ class ChatViewModel(
/**
* Build appropriate system prompt based on LLM backend.
*/
- private fun buildSystemPrompt(): String {
- return if (currentBackendId == "gemini") {
- buildSystemPromptGemini()
+ private suspend fun buildSystemPrompt(): String {
+ // One editor read serves both the IDE CONTEXT block and the paths in the examples.
+ val ide = readIdeSnapshot()
+ val examplePath = ide.exampleFilePath()
+ val base = if (currentBackendId == "gemini") {
+ buildSystemPromptGemini(examplePath)
} else {
- buildSystemPromptLocal()
+ buildSystemPromptLocal(examplePath)
+ }
+ return base + ide.contextBlock()
+ }
+
+ /**
+ * What the IDE has open, project-relative, read once per prompt.
+ * @property currentFile the focused file, or null when nothing is open.
+ * @property otherFiles other open tabs, capped at [MAX_CONTEXT_OPEN_FILES].
+ */
+ private data class IdeSnapshot(val currentFile: String?, val otherFiles: List)
+
+ /**
+ * Reads the open-file state from the editor service.
+ * @return the snapshot; empty when there is no editor service or the call fails.
+ */
+ private suspend fun readIdeSnapshot(): IdeSnapshot {
+ val editor = getContext()?.services?.get(IdeEditorService::class.java)
+ ?: return IdeSnapshot(null, emptyList())
+ val root = File(PathGuard.projectRoot())
+
+ // Editor state is read on the main thread, like every other editor-service call here.
+ val (current, open) = withContext(Dispatchers.Main) {
+ runCatching { editor.getCurrentFile() to editor.getOpenFiles() }
+ .getOrDefault(null to emptyList())
+ }
+
+ fun relative(file: File): String = runCatching { file.relativeToOrSelf(root).path }
+ .getOrDefault(file.name)
+
+ return IdeSnapshot(
+ currentFile = current?.let(::relative),
+ otherFiles = open.orEmpty()
+ .filter { it != current }
+ .take(MAX_CONTEXT_OPEN_FILES)
+ .map(::relative),
+ )
+ }
+
+ /**
+ * The path the tool-call examples should use: a file the IDE really has open, so the examples
+ * carry this project's own language and layout instead of teaching an Android/Java one. Falls
+ * back to [FALLBACK_EXAMPLE_PATH] only when nothing is open.
+ * @return a project-relative path.
+ */
+ private fun IdeSnapshot.exampleFilePath(): String =
+ currentFile ?: otherFiles.firstOrNull() ?: FALLBACK_EXAMPLE_PATH
+
+ /**
+ * Describes what the user is looking at: the focused file and other open tabs, project-relative.
+ * Most requests are about the file on screen and the IDE knows that path exactly; without it the
+ * model reconstructs one, which is where invented `.java` paths for Kotlin files came from.
+ * @return a prompt block, or empty when nothing is open.
+ */
+ private fun IdeSnapshot.contextBlock(): String {
+ if (currentFile == null && otherFiles.isEmpty()) return ""
+
+ return buildString {
+ append("\n\nIDE CONTEXT (real paths — use these verbatim, do not rewrite them):\n")
+ currentFile?.let { append("- File the user is viewing: ").append(it).append("\n") }
+ if (otherFiles.isNotEmpty()) {
+ append("- Other open files: ").append(otherFiles.joinToString(", ")).append("\n")
+ }
+ append(
+ "If the user names a file that appears above, use that exact path and do not " +
+ "guess a different folder or extension."
+ )
}
}
/**
* System prompt for Gemini (high autonomy, structured tool calling via native functions).
+ * @param examplePath path shown in the tool-call examples — this project's own open file when
+ * there is one, so the examples never imply a language or layout the project doesn't have.
*/
- private fun buildSystemPromptGemini(): String {
+ private fun buildSystemPromptGemini(examplePath: String): String {
val toolDescriptions = toolRouter.getAllHandlers().joinToString("\n") { handler ->
"- ${handler.toolName}: ${handler.description}"
}
@@ -334,6 +430,8 @@ class ChatViewModel(
AVAILABLE TOOLS:
$toolDescriptions
+ - respond: Send the user your reply or final answer. It MUST carry a "message" holding the
+ text itself — a respond call with no "message" shows the user nothing.
BEHAVIOR:
- Create complete, production-ready code
@@ -343,23 +441,34 @@ class ChatViewModel(
- Generate apps that actually run and work as described
RULES:
+ - Emit ONE tool call per reply, then stop and wait. Do NOT plan a batch: a tool whose arguments depend on another tool's result (editing a file you just searched for) cannot use a result you have not received yet.
+ - To locate a file, call search_project ONCE with its name — it searches the whole project. Never walk the tree with repeated list_files calls; you have a limited number of turns and each level wastes one.
+ - Renaming a symbol everywhere in a file is ONE edit_file with replace_all set to true and old_string set to just the symbol — not one edit per line.
+ - To change an existing file, use edit_file (find/replace an exact snippet), not update_file — a whole-file rewrite gets truncated before it reaches disk.
+ - Before edit_file, read the exact file you are about to edit with read_file, and copy old_string byte-for-byte from that output, including indentation. Never edit a path you have not confirmed exists.
+ - old_string must be the text currently in the file and new_string what it should become. If they are identical the edit is rejected.
- Never fabricate tool output. Emit a tool call, then wait for the real result before continuing.
- - Never write "User:", "Assistant:", or a block — the system supplies those.
+ - Never write "User:", "Assistant:", a block, or a ```tool_response fence — the system supplies real results. Any tool output you write yourself is a hallucination and will be ignored.
- Paths are relative to the project root and must be complete. If you don't know a file's exact path, find it with search_project or list_files first, then act on the real path — don't guess.
- - For plain chat (e.g. "Hi"), just reply briefly with no tool call. When the task is done, give a short summary with no tool call.
+ - For plain chat (e.g. "Hi"), just reply briefly with no tool call. When the task is done, either give a short summary with no tool call, or end with a single respond call carrying that summary in its "message" — never an empty respond.
TOOL CALL FORMAT — to run a tool, emit a single line in EXACTLY this format and nothing after it:
{"tool":"TOOL_NAME","args":{"arg":"value"}}
Do NOT describe the action in prose (e.g. "Okay, I'll open the file…") — narrating does nothing.
The tool only runs when you emit the line itself.
- FORMAT EXAMPLES (the tool call is the entire reply):
+ FORMAT EXAMPLES (the tool call is the entire reply; the paths are this project's — reuse a path
+ only when it is the file you actually mean):
+ Report the finished task (the summary goes in "message"):
+ {"tool":"respond","args":{"message":"Renamed count to itemCount."}}
Open a file once you know its path:
- {"tool":"open_file","args":{"file_path":"app/src/main/java/com/example/app/MainActivity.java"}}
+ {"tool":"open_file","args":{"file_path":"$examplePath"}}
Find a file by name:
- {"tool":"search_project","args":{"query":"MainActivity"}}
- List a directory:
- {"tool":"list_files","args":{"directory":"app/src/main"}}
+ {"tool":"search_project","args":{"query":"${exampleFileStem(examplePath)}"}}
+ List the project's top-level files (an empty directory means the project root):
+ {"tool":"list_files","args":{"directory":""}}
+ Change part of a file (line breaks inside a value MUST be written as \n):
+ {"tool":"edit_file","args":{"file_path":"$examplePath","old_string":"count = 0","new_string":"count = 1"}}
WORKFLOW:
1. Understand the user's request
@@ -375,10 +484,20 @@ class ChatViewModel(
return prompt
}
+ /**
+ * File name without its extension, for a `search_project` example that matches [examplePath].
+ * @param examplePath the example path.
+ * @return the bare stem (e.g. "MainActivity").
+ */
+ private fun exampleFileStem(examplePath: String): String =
+ examplePath.substringAfterLast('/').substringBeforeLast('.')
+
/**
* System prompt for local LLMs (guided step-by-step with text-based tool calling).
+ * @param examplePath path shown in the tool-call examples — this project's own open file when
+ * there is one, so the examples never imply a language or layout the project doesn't have.
*/
- private fun buildSystemPromptLocal(): String {
+ private fun buildSystemPromptLocal(examplePath: String): String {
val toolDescriptions = toolRouter.getAllHandlers().joinToString("\n") { handler ->
"- ${handler.toolName}: ${handler.description}"
}
@@ -391,17 +510,30 @@ class ChatViewModel(
- Use a file/project tool only when the user asks about files, code, or the project; for a greeting, small talk, or a question you can answer, use "respond".
- Never invent tool output or claim an action you didn't perform via a tool. After a tool call, stop; the real result returns next turn.
- "respond" must carry a "message" — your reply or final answer.
- - File arguments accept a bare name (e.g. "MainActivity.java"); the project is searched. Don't invent deep paths.
+ - read_file and open_file accept a bare file name (the project is searched for it). Never invent deep paths.
+ - To change a file, use edit_file, not update_file. Call read_file FIRST, then copy the text to replace into "old_string" EXACTLY as it appears in that output (same spelling, same indentation). It must appear only once — include the line above or below if it doesn't.
+ - "old_string" is the text that is in the file NOW; "new_string" is what it should become. They must differ. To rename x to y: old_string has x, new_string has y.
+ - Never put a real line break inside an argument value: write it as \n. Keep old_string/new_string to a few lines; make several small edits rather than one big one.
+ - edit_file needs a real path, not a bare name, and never a path you invented. If you don't know it, call search_project with the file name FIRST and use the path it returns — don't guess the folders, and don't guess the extension (.kt vs .java).
+ - To rename something everywhere in a file, make ONE edit_file call with old_string set to just the old name and "replace_all":"true".
Tools:
$toolDescriptions
- respond: Send the user a message or your final answer.
- Examples (pick the tool that matches; don't copy verbatim):
+ Examples (pick the tool that matches; copy the FORMAT, not the values):
Greeting / question you can answer -> respond:
{"tool":"respond","args":{"message":"Hi! What would you like to build?"}}
- Open a file -> open_file:
- {"tool":"open_file","args":{"file_path":"MainActivity.java"}}
+ Open a file (a bare name is fine here) -> open_file:
+ {"tool":"open_file","args":{"file_path":"${examplePath.substringAfterLast('/')}"}}
+ Change one line of a file -> edit_file:
+ {"tool":"edit_file","args":{"file_path":"$examplePath","old_string":"setTitle(\"Old\")","new_string":"setTitle(\"New\")"}}
+ Change two lines (note the \n, never a real line break) -> edit_file:
+ {"tool":"edit_file","args":{"file_path":"$examplePath","old_string":"a = 1\nb = 2","new_string":"a = 10\nb = 20"}}
+ Rename every use of one name in a file -> ONE edit_file with replace_all (NOT one call per line):
+ {"tool":"edit_file","args":{"file_path":"$examplePath","old_string":"oldName","new_string":"newName","replace_all":"true"}}
+ Find where a file actually lives before editing it -> search_project:
+ {"tool":"search_project","args":{"query":"${exampleFileStem(examplePath)}"}}
""".trimIndent()
android.util.Log.d("ChatViewModel", "Using Local LLM system prompt (guided mode) with ${toolRouter.getAllHandlers().size} tools")
@@ -533,7 +665,7 @@ class ChatViewModel(
* Send a user message and get agent response.
*/
fun sendMessage(userMessage: String) {
- android.util.Log.d("ChatViewModel", "sendMessage called with: '$userMessage'")
+ android.util.Log.d("ChatViewModel", "sendMessage called")
val llmService = getLlmService()
if (llmService == null) {
android.util.Log.d("ChatViewModel", "sendMessage: LLM service not available")
@@ -561,7 +693,7 @@ class ChatViewModel(
return
}
- android.util.Log.d("ChatViewModel", "sendMessage: Starting message processing")
+ AgentTrace.beginRun(currentBackendId, userMessage, contextFiles.size)
// Reset per-run tool tracking.
lastToolFailedThisRun = false
lastSucceededCalls = null
@@ -582,7 +714,8 @@ class ChatViewModel(
}
val config = LlmInferenceService.LlmConfig(currentBackendId).apply {
- temperature = 0.7f
+ // The grammar shapes a local tool call but not its values, so paths get sampled.
+ temperature = if (currentBackendId == "gemini") 0.7f else LOCAL_TEMPERATURE
maxTokens = 4096 // headroom for complete tool calls
systemPrompt = buildSystemPrompt()
// Local backend constrains generation to this grammar; cloud ignores it.
@@ -602,7 +735,7 @@ class ChatViewModel(
)
try {
- agentLoop.run(
+ val loopResult = agentLoop.run(
history = history,
generate = { turns ->
withContext(Dispatchers.Main) {
@@ -612,7 +745,31 @@ class ChatViewModel(
},
executeTools = { calls -> executeToolCalls(calls) },
events = object : AgentLoop.Events {
+ override suspend fun onToolResults(
+ turn: Int,
+ calls: List,
+ results: List,
+ ) {
+ calls.forEachIndexed { index, call ->
+ val result = results.getOrNull(index)
+ AgentTrace.stage(
+ "RESULT",
+ "turn=$turn ${call.name} success=${result?.success}",
+ AgentTrace.preview(result?.message),
+ )
+ }
+ }
+
+ override suspend fun onFinalAnswer(turn: Int, message: String) {
+ AgentTrace.stage(
+ "ANSWER",
+ "turn=$turn chars=${message.length}",
+ AgentTrace.preview(message),
+ )
+ }
+
override suspend fun onMaxIterationsReached(turns: Int) {
+ AgentTrace.refusal("LOOP", "turns=$turns", "iteration cap reached")
addSystemMessage(
str(R.string.agent_max_steps_reached, turns),
MessageStatus.SENT
@@ -620,6 +777,7 @@ class ChatViewModel(
}
override suspend fun onRepeatedToolCalls(turns: Int) {
+ AgentTrace.refusal("LOOP", "turns=$turns", "identical tool calls repeated")
addSystemMessage(
str(R.string.agent_repeated_calls),
MessageStatus.SENT
@@ -627,6 +785,7 @@ class ChatViewModel(
}
}
)
+ AgentTrace.endRun(loopResult.reason.name, loopResult.turns)
} finally {
// Persist history only if this run wasn't superseded (epoch bumped).
if (generationEpoch.get() == epoch) {
@@ -637,10 +796,12 @@ class ChatViewModel(
withContext(Dispatchers.Main) { _agentState.value = AgentState.Idle }
} catch (ce: CancellationException) {
+ AgentTrace.endRun("cancelled")
stopStateTimer()
throw ce
} catch (e: Exception) {
android.util.Log.e("ChatViewModel", "sendMessage failed", e)
+ AgentTrace.endRun("error: ${e.message}")
stopStateTimer()
_agentState.value = AgentState.Error(str(R.string.state_error, e.message))
addSystemMessage(str(R.string.state_error, e.message), MessageStatus.ERROR)
@@ -652,12 +813,9 @@ class ChatViewModel(
}
/**
- * Runs one streaming model turn: creates an agent bubble, streams tokens into it,
- * and suspends until completion. Throws on backend error.
- *
- * Sends [turns] structurally to the local backend, which renders one chat turn per message.
- * Gemini's transport carries a single string, so it keeps the flattened transcript.
- *
+ * Runs one streaming model turn: creates an agent bubble, streams tokens into it and suspends
+ * until completion, throwing on backend error. Sends [turns] structurally to the local backend;
+ * Gemini's transport carries one string, so it keeps the flattened transcript.
* @param llmService the inference service.
* @param turns the conversation so far; the last entry is the current user turn.
* @param config the generation config.
@@ -715,10 +873,23 @@ class ChatViewModel(
return
}
val durationMs = System.currentTimeMillis() - startTime
+ // Not from onModelTurn, which fires later and would order the trace wrongly.
+ AgentTrace.stage(
+ "LLM",
+ "chars=${response.text.length} generateMs=$durationMs",
+ AgentTrace.preview(response.text),
+ )
val toolCalls = ToolCallExtractor.extractToolCalls(response.text)
- val respondCall = toolCalls.firstOrNull { it.name == RESPOND_TOOL }
- val respondMessage = respondCall?.args?.get("message")?.toString()
-
+ // Where a mis-escaped generation quietly becomes "the model said nothing".
+ if (toolCalls.isEmpty()) {
+ AgentTrace.detail("PARSE", "calls=0 generateMs=$durationMs (plain reply or unparsable)")
+ } else {
+ AgentTrace.stage(
+ "PARSE",
+ "calls=${toolCalls.size} generateMs=$durationMs",
+ toolCalls.joinToString("; ") { "${it.name}(${AgentTrace.previewArgs(it.args)})" },
+ )
+ }
// Per-run flag (set by executeToolCalls), not a session-wide scan.
val lastToolFailed = lastToolFailedThisRun
@@ -731,18 +902,16 @@ class ChatViewModel(
return
}
- val displayText = when {
- respondCall != null && lastToolFailed ->
- str(R.string.agent_action_failed)
- // Render the "respond" message to the user, not a tool badge.
- respondCall != null ->
- respondMessage?.takeIf { it.isNotBlank() } ?: str(R.string.agent_no_response)
- toolCalls.isNotEmpty() -> toolCalls.joinToString("\n") { c ->
- "🔧 ${c.name}(${c.args.entries.joinToString(", ") { "${it.key}=${it.value}" }})"
- }
- else -> response.text.ifBlank {
- str(R.string.agent_no_response)
- }
+ val displayText = AgentReplyRenderer.render(
+ rawText = response.text,
+ toolCalls = toolCalls,
+ terminalTool = RESPOND_TOOL,
+ lastToolFailed = lastToolFailed,
+ actionFailedText = str(R.string.agent_action_failed),
+ noResponseText = str(R.string.agent_no_response),
+ ) { c ->
+ // Capped: edit_file snippets would turn the badge into a wall of source.
+ "🔧 ${c.name}(${c.args.entries.joinToString(", ") { "${it.key}=${abbreviate(it.value)}" }})"
}
viewModelScope.launch(Dispatchers.Main) {
if (isStale()) return@launch
@@ -830,6 +999,18 @@ class ChatViewModel(
private fun str(resId: Int, vararg args: Any?): String =
getContext()?.androidContext?.getString(resId, *args).orEmpty()
+ /**
+ * Renders a tool argument for the one-line tool badge: line breaks flattened and the
+ * value capped, so a code-carrying argument stays a badge instead of a source dump.
+ * @param value the raw argument value.
+ * @return a single-line, length-capped rendering.
+ */
+ private fun abbreviate(value: Any?): String {
+ val text = value?.toString().orEmpty().replace("\n", "⏎")
+ return if (text.length <= TOOL_BADGE_ARG_LIMIT) text
+ else text.take(TOOL_BADGE_ARG_LIMIT) + "…"
+ }
+
/**
* Appends a SYSTEM message to the chat (on the main thread).
* @param text the message text.
@@ -854,6 +1035,7 @@ class ChatViewModel(
fun clearMessages() {
// Clear Chat must also stop any in-flight run, not just wipe the list.
generationEpoch.incrementAndGet()
+ approvalManager.cancelPendingApproval()
generationJob?.cancel()
generationJob = null
getLlmService()?.cancelGeneration()
@@ -907,6 +1089,8 @@ class ChatViewModel(
fun stopProcessing() {
generationEpoch.incrementAndGet()
_agentState.value = AgentState.Cancelling
+ // Cancelling the job alone would strand an open approval dialog with nothing awaiting it.
+ approvalManager.cancelPendingApproval()
generationJob?.cancel()
generationJob = null
getLlmService()?.cancelGeneration()
diff --git a/ai-assistant/src/main/res/values/strings.xml b/ai-assistant/src/main/res/values/strings.xml
index fea862b9..2f839441 100644
--- a/ai-assistant/src/main/res/values/strings.xml
+++ b/ai-assistant/src/main/res/values/strings.xml
@@ -65,6 +65,18 @@
Approve Once
Approve for Session
Deny
+ 🔒 Tool Approval Required
+ ⚠️ Confirm: %s
+ ✓ Run Now
+ ✓ Always Allow
+ Proposed change:
+ ✓ Accept
+ ✎ Correct
+ ✗ Decline
+ What should the AI change?
+ e.g. keep the original name, only change the return type
+ Send
+ Back
Tokens: %d%%
diff --git a/ai-assistant/src/test/kotlin/com/itsaky/androidide/plugins/aiassistant/fragments/ApprovalTextFormatterTest.kt b/ai-assistant/src/test/kotlin/com/itsaky/androidide/plugins/aiassistant/fragments/ApprovalTextFormatterTest.kt
new file mode 100644
index 00000000..220986bf
--- /dev/null
+++ b/ai-assistant/src/test/kotlin/com/itsaky/androidide/plugins/aiassistant/fragments/ApprovalTextFormatterTest.kt
@@ -0,0 +1,144 @@
+package com.itsaky.androidide.plugins.aiassistant.fragments
+
+import com.itsaky.androidide.plugins.aiassistant.tool.handlers.EditFileHandler
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertFalse
+import org.junit.Assert.assertTrue
+import org.junit.Test
+
+/**
+ * Unit tests for [ApprovalTextFormatter]. This is the text a user reads before authorising a change
+ * to their own source, so the tests are about *informed* consent: the path is named, both sides are
+ * shown, and an edit that hits every occurrence says so.
+ */
+class ApprovalTextFormatterTest {
+
+ private fun editArgs(vararg pairs: Pair) = mapOf(*pairs)
+
+ @Test
+ fun givenAnEdit_whenFormatted_thenItNamesThePathAndBothSidesOfTheChange() {
+ val text = ApprovalTextFormatter.formatEdit(
+ editArgs(
+ EditFileHandler.ARG_PATH to "app/src/Main.kt",
+ EditFileHandler.ARG_OLD to "val a = 1",
+ EditFileHandler.ARG_NEW to "val a = 2",
+ )
+ )
+
+ assertTrue(text.contains("app/src/Main.kt"))
+ assertTrue(text.contains("- val a = 1"))
+ assertTrue(text.contains("+ val a = 2"))
+ }
+
+ @Test
+ fun givenAnEmptyNewString_whenFormatted_thenItReadsAsADeletionRatherThanAnEmptyAddition() {
+ val text = ApprovalTextFormatter.formatEdit(
+ editArgs(
+ EditFileHandler.ARG_PATH to "Main.kt",
+ EditFileHandler.ARG_OLD to "val unused = 1",
+ EditFileHandler.ARG_NEW to "",
+ )
+ )
+
+ assertTrue(text.contains("(deleted)"))
+ }
+
+ @Test
+ fun givenReplaceAllAsARealBoolean_whenFormatted_thenTheWarningIsShown() {
+ val text = ApprovalTextFormatter.formatEdit(
+ editArgs(
+ EditFileHandler.ARG_PATH to "Main.kt",
+ EditFileHandler.ARG_OLD to "a",
+ EditFileHandler.ARG_NEW to "b",
+ EditFileHandler.ARG_REPLACE_ALL to true,
+ )
+ )
+
+ assertTrue("a whole-file edit must announce itself", text.contains("every occurrence"))
+ }
+
+ @Test
+ fun givenReplaceAllAsAModelSuppliedString_whenFormatted_thenTheWarningIsStillShown() {
+ // Parsed differently by the dialog, an every-occurrence edit was approved without saying so.
+ listOf("true", " TRUE ", "yes", "1").forEach { raw ->
+ val text = ApprovalTextFormatter.formatEdit(
+ editArgs(
+ EditFileHandler.ARG_PATH to "Main.kt",
+ EditFileHandler.ARG_OLD to "a",
+ EditFileHandler.ARG_NEW to "b",
+ EditFileHandler.ARG_REPLACE_ALL to raw,
+ )
+ )
+
+ assertTrue("'$raw' must warn", text.contains("every occurrence"))
+ }
+ }
+
+ @Test
+ fun givenNoReplaceAll_whenFormatted_thenNoWarningIsShown() {
+ val text = ApprovalTextFormatter.formatEdit(
+ editArgs(
+ EditFileHandler.ARG_PATH to "Main.kt",
+ EditFileHandler.ARG_OLD to "a",
+ EditFileHandler.ARG_NEW to "b",
+ )
+ )
+
+ assertFalse(text.contains("every occurrence"))
+ }
+
+ @Test
+ fun givenAHugeSnippet_whenFormatted_thenItIsTruncatedWithTheOmissionStated() {
+ // A wall of unreadable code is not consent, but nor is pretending it showed everything.
+ val text = ApprovalTextFormatter.formatEdit(
+ editArgs(
+ EditFileHandler.ARG_PATH to "Main.kt",
+ EditFileHandler.ARG_OLD to "x".repeat(5_000),
+ EditFileHandler.ARG_NEW to "y",
+ )
+ )
+
+ assertTrue(text.length < 2_000)
+ assertTrue(text.contains("more characters"))
+ }
+
+ @Test
+ fun givenATruncatedSnippet_whenFormatted_thenTheOmissionIsNotDisguisedAsRemovedCode() {
+ // Prefixed the notice reads as changed code, and it must say the hidden text is written.
+ val text = ApprovalTextFormatter.formatEdit(
+ editArgs(
+ EditFileHandler.ARG_PATH to "Main.kt",
+ EditFileHandler.ARG_OLD to "x".repeat(5_000),
+ EditFileHandler.ARG_NEW to "y",
+ )
+ )
+
+ val notice = text.lines().first { it.contains("more characters") }
+ assertFalse("the omission notice must not look like a diff line: $notice", notice.startsWith("- "))
+ assertFalse("the omission notice must not look like a diff line: $notice", notice.startsWith("+ "))
+ assertTrue("it must say the hidden text is still applied: $notice", notice.contains("WILL be written"))
+ }
+
+ @Test
+ fun givenNoArguments_whenFormatted_thenItRendersAsAnEmptyObject() {
+ assertEquals("{}", ApprovalTextFormatter.formatArgs(emptyMap()))
+ }
+
+ @Test
+ fun givenAGenericToolCall_whenFormatted_thenEachArgumentIsNamedAndCapped() {
+ val text = ApprovalTextFormatter.formatArgs(
+ mapOf("file_path" to "Main.kt", "content" to "z".repeat(1_000))
+ )
+
+ assertTrue(text.contains("file_path"))
+ assertTrue(text.contains("Main.kt"))
+ assertTrue(text.contains("more characters"))
+ }
+
+ @Test
+ fun givenANullArgumentValue_whenFormatted_thenItDoesNotBlowUp() {
+ val text = ApprovalTextFormatter.formatArgs(mapOf("directory" to null))
+
+ assertTrue(text.contains("directory"))
+ }
+}
diff --git a/ai-assistant/src/test/kotlin/com/itsaky/androidide/plugins/aiassistant/tool/AgentLoopTest.kt b/ai-assistant/src/test/kotlin/com/itsaky/androidide/plugins/aiassistant/tool/AgentLoopTest.kt
index 3ef3542c..396cea61 100644
--- a/ai-assistant/src/test/kotlin/com/itsaky/androidide/plugins/aiassistant/tool/AgentLoopTest.kt
+++ b/ai-assistant/src/test/kotlin/com/itsaky/androidide/plugins/aiassistant/tool/AgentLoopTest.kt
@@ -81,6 +81,28 @@ class AgentLoopTest {
assertEquals("All set!", finalMessage)
}
+ @Test
+ fun givenATerminalCallUnderAnAlternateKey_whenTheLoopRuns_thenTheAnswerStillReachesOnFinalAnswer() = runTest {
+ // Models substitute "text" for "message", which dropped the finished answer entirely.
+ val model = ScriptedModel(
+ listOf("""{"tool":"respond","args":{"text":"All set!"}}""")
+ )
+ val history = mutableListOf(ChatMessage(Role.USER, "hi"))
+ var finalMessage: String? = null
+
+ val result = AgentLoop(terminalTool = "respond").run(
+ history = history,
+ generate = model::generate,
+ executeTools = { emptyList() },
+ events = object : AgentLoop.Events {
+ override suspend fun onFinalAnswer(turn: Int, message: String) { finalMessage = message }
+ }
+ )
+
+ assertTrue(result.completed)
+ assertEquals("All set!", finalMessage)
+ }
+
@Test
fun givenAModelThatCallsAToolThenAnswers_whenTheLoopRuns_thenItChainsTheToolAndFinishes() = runTest {
// Turn 1: model calls a tool. Turn 2: sees results, gives final answer.
@@ -108,8 +130,7 @@ class AgentLoopTest {
assertEquals(1, executed.size)
assertEquals("open_file", executed[0][0].name)
- // The 2nd turn must receive the fed-back tool results (the whole point), and receive
- // them as their own USER turn rather than folded into the preceding one.
+ // Turn 2 must get the fed-back results as their own USER turn, not folded into turn 1.
val secondTurnInput = model.turns[1]
assertEquals(3, secondTurnInput.size)
assertEquals(Role.ASSISTANT, secondTurnInput[1].role)
diff --git a/ai-assistant/src/test/kotlin/com/itsaky/androidide/plugins/aiassistant/tool/ExecutorTest.kt b/ai-assistant/src/test/kotlin/com/itsaky/androidide/plugins/aiassistant/tool/ExecutorTest.kt
index 163c6384..41da4904 100644
--- a/ai-assistant/src/test/kotlin/com/itsaky/androidide/plugins/aiassistant/tool/ExecutorTest.kt
+++ b/ai-assistant/src/test/kotlin/com/itsaky/androidide/plugins/aiassistant/tool/ExecutorTest.kt
@@ -2,6 +2,7 @@ package com.itsaky.androidide.plugins.aiassistant.tool
import com.itsaky.androidide.plugins.aiassistant.models.ToolResult
import com.itsaky.androidide.plugins.aiassistant.tool.handlers.PathGuard
+import kotlinx.coroutines.delay
import kotlinx.coroutines.runBlocking
import org.junit.After
import org.junit.Assert.assertEquals
@@ -14,9 +15,8 @@ import java.nio.file.Files
/**
* Unit tests for the [Executor] path-containment pre-guard, in particular the
- * [ToolHandler.resolvesPathsInternally] opt-out that lets read-only handlers
- * rescue odd paths (e.g. a model-supplied "/.gitignore") instead of the Executor
- * rejecting them outright, while write tools stay guarded.
+ * [ToolHandler.resolvesPathsInternally] opt-out that lets read-only handlers rescue odd paths such as
+ * a model-supplied "/.gitignore", while write tools stay guarded.
*/
class ExecutorTest {
@@ -39,6 +39,22 @@ class ExecutorTest {
}
}
+ /** Stands in for [handlers.EditFileHandler]: same tool name, aliases and path args. */
+ private class AliasingHandler : ToolHandler {
+ override val toolName = "edit_file"
+ override val description = "fake edit"
+ override val requiresApproval = false
+ override val pathArgs = listOf("file_path")
+ override val argAliases = mapOf("old" to "old_string", "new" to "new_string")
+ var seenArgs: Map? = null
+ private set
+
+ override suspend fun execute(args: Map): ToolResult {
+ seenArgs = args
+ return ToolResult.success("edited")
+ }
+ }
+
@Before
fun setup() {
projectRoot = Files.createTempDirectory("executor-project").toFile().canonicalFile
@@ -58,8 +74,7 @@ class ExecutorTest {
val handler = FakeHandler("fake_internal", resolvesPathsInternally = true)
val executor = executorFor(handler)
- // "/escape.txt" resolves outside the project root; the guard would reject
- // it, but an internally-resolving handler must still be dispatched.
+ // "/escape.txt" is out of root, but an internally-resolving handler still gets dispatched.
val results = executor.execute(listOf(ToolCall("fake_internal", mapOf("file_path" to "/escape.txt"))))
assertTrue("handler should have been dispatched", handler.dispatched)
@@ -80,8 +95,7 @@ class ExecutorTest {
@Test
fun givenOpenFileWithAPathAlias_whenExecuting_thenPathIsRemappedToFilePathAndItRuns() = runBlocking {
- // open_file requires file_path (like read_file); a model emitting
- // {"path":"..."} must be remapped, not rejected for a missing file_path.
+ // open_file requires file_path, so a model emitting {"path":...} is remapped, not rejected.
val handler = object : ToolHandler {
override val toolName = "open_file"
override val description = "fake open"
@@ -102,6 +116,305 @@ class ExecutorTest {
assertEquals("MainActivity.java", handler.seenArgs?.get("file_path"))
}
+ @Test
+ fun givenEditFileWithAliasedSnippetArgs_whenExecuting_thenTheyAreRemappedToTheCanonicalKeys() = runBlocking {
+ // Small models reach for "old"/"new" as often as the real names; rejecting costs a turn.
+ val handler = AliasingHandler()
+ val executor = executorFor(handler)
+
+ val results = executor.execute(
+ listOf(
+ ToolCall(
+ "edit_file",
+ mapOf("file_path" to "Main.kt", "old" to "a = 1", "new" to "a = 2"),
+ )
+ )
+ )
+
+ assertTrue("aliased edit_file should run", results.single().success)
+ assertEquals("a = 1", handler.seenArgs?.get("old_string"))
+ assertEquals("a = 2", handler.seenArgs?.get("new_string"))
+ }
+
+ @Test
+ fun givenBothAnAliasAndItsCanonicalKey_whenExecuting_thenTheCanonicalValueWins() = runBlocking {
+ val handler = AliasingHandler()
+ val executor = executorFor(handler)
+
+ executor.execute(
+ listOf(
+ ToolCall(
+ "edit_file",
+ mapOf(
+ "file_path" to "Main.kt",
+ "old_string" to "canonical",
+ "old" to "alias",
+ "new_string" to "x",
+ ),
+ )
+ )
+ )
+
+ assertEquals("canonical", handler.seenArgs?.get("old_string"))
+ }
+
+ @Test
+ fun givenEditFileMissingOldString_whenExecuting_thenItIsRejectedBeforeTheHandlerRuns() = runBlocking {
+ val handler = AliasingHandler()
+ val executor = executorFor(handler)
+
+ val results = executor.execute(
+ listOf(ToolCall("edit_file", mapOf("file_path" to "Main.kt", "new_string" to "x")))
+ )
+
+ assertFalse(results.single().success)
+ assertTrue(results.single().message.contains("old_string"))
+ assertEquals(null, handler.seenArgs)
+ }
+
+ @Test
+ fun givenEditFileWithABlankNewString_whenExecuting_thenItStillRuns() = runBlocking {
+ // A blank new_string is a deletion, so the empty-value check must not catch it.
+ val handler = AliasingHandler()
+ val executor = executorFor(handler)
+
+ val results = executor.execute(
+ listOf(
+ ToolCall(
+ "edit_file",
+ mapOf("file_path" to "Main.kt", "old_string" to "gone", "new_string" to ""),
+ )
+ )
+ )
+
+ assertTrue("a deletion must reach the handler", results.single().success)
+ }
+
+ @Test
+ fun givenAWhitespaceOnlyOldString_whenExecuting_thenItReachesTheHandler() = runBlocking {
+ // Re-indenting is a legal edit whose old_string is whitespace, not a missing argument.
+ val handler = AliasingHandler()
+ val executor = executorFor(handler)
+
+ val results = executor.execute(
+ listOf(
+ ToolCall(
+ "edit_file",
+ mapOf("file_path" to "Main.kt", "old_string" to "\n\n\n", "new_string" to "\n"),
+ )
+ )
+ )
+
+ assertTrue("whitespace is content, not a missing argument: ${results.single().message}", results.single().success)
+ assertEquals("\n\n\n", handler.seenArgs?.get("old_string"))
+ }
+
+ @Test
+ fun givenValidationThatThrows_whenExecuting_thenItBecomesAToolFailureRatherThanKillingTheRun() = runBlocking {
+ // Unguarded, validate()'s SecurityException escaped execute() and aborted the whole run.
+ var executed = false
+ val handler = object : ToolHandler {
+ override val toolName = "edit_file"
+ override val description = "fake edit"
+ override val requiresApproval = true
+ override val pathArgs = listOf("file_path")
+ override suspend fun validate(args: Map): Validation =
+ throw SecurityException("Plugin does not have access to file: Main.kt")
+
+ override suspend fun execute(args: Map): ToolResult {
+ executed = true
+ return ToolResult.success("edited")
+ }
+ }
+ val approvalManager = ToolApprovalManager()
+ val executor = Executor(ToolRouter(listOf(handler)), approvalManager)
+
+ val results = executor.execute(
+ listOf(
+ ToolCall(
+ "edit_file",
+ mapOf("file_path" to "Main.kt", "old_string" to "a", "new_string" to "b"),
+ )
+ )
+ )
+
+ assertFalse(results.single().success)
+ assertTrue(
+ "the failure must name the cause; got: ${results.single().message}",
+ results.single().message.contains("does not have access")
+ )
+ assertFalse("the handler must not run", executed)
+ assertFalse("no approval may be requested", approvalManager.hasPendingApproval())
+ }
+
+ @Test
+ fun givenAReadAndAWriteInOneBatch_whenExecuting_thenTheReadFinishesFirst() = runBlocking {
+ // search_project + edit_file states a dependency the old parallel-writes schedule broke.
+ val order = mutableListOf()
+ val read = object : ToolHandler {
+ override val toolName = "search_project"
+ override val description = "fake search"
+ override val pathArgs = emptyList()
+ override suspend fun execute(args: Map): ToolResult {
+ delay(50)
+ synchronized(order) { order.add("read-done") }
+ return ToolResult.success("found")
+ }
+ }
+ val write = object : ToolHandler {
+ override val toolName = "edit_file"
+ override val description = "fake edit"
+ override val pathArgs = emptyList()
+ override suspend fun execute(args: Map): ToolResult {
+ synchronized(order) { order.add("write-start") }
+ return ToolResult.success("edited")
+ }
+ }
+ val executor = Executor(ToolRouter(listOf(read, write)), ToolApprovalManager())
+
+ executor.execute(
+ listOf(
+ ToolCall("search_project", mapOf("query" to "MainActivity")),
+ ToolCall("edit_file", mapOf("file_path" to "Main.kt", "old_string" to "a", "new_string" to "b")),
+ )
+ )
+
+ assertEquals(listOf("read-done", "write-start"), order)
+ }
+
+ @Test
+ fun givenAWriteThenARead_whenExecuting_thenTheWriteFinishesFirst() = runBlocking {
+ // The mirror image: hoisting the read of create_file + read_file failed as "not found".
+ val order = mutableListOf()
+ val write = object : ToolHandler {
+ override val toolName = "create_file"
+ override val description = "fake create"
+ override val pathArgs = emptyList()
+ override suspend fun execute(args: Map): ToolResult {
+ delay(50)
+ synchronized(order) { order.add("write-done") }
+ return ToolResult.success("created")
+ }
+ }
+ val read = object : ToolHandler {
+ override val toolName = "read_file"
+ override val description = "fake read"
+ override val pathArgs = emptyList()
+ override suspend fun execute(args: Map): ToolResult {
+ synchronized(order) { order.add("read-start") }
+ return ToolResult.success("contents")
+ }
+ }
+ val executor = Executor(ToolRouter(listOf(write, read)), ToolApprovalManager())
+
+ executor.execute(
+ listOf(
+ ToolCall("create_file", mapOf("file_path" to "Foo.kt", "content" to "x")),
+ ToolCall("read_file", mapOf("file_path" to "Foo.kt")),
+ )
+ )
+
+ assertEquals(listOf("write-done", "read-start"), order)
+ }
+
+ @Test
+ fun givenConsecutiveReads_whenExecuting_thenTheyStillRunConcurrently() = runBlocking {
+ // Segmenting must not cost concurrency: two adjacent reads have no dependency.
+ val started = mutableListOf()
+ val handler = object : ToolHandler {
+ override val toolName = "read_file"
+ override val description = "fake read"
+ override val pathArgs = emptyList()
+ override suspend fun execute(args: Map): ToolResult {
+ synchronized(started) { started.add(args["file_path"].toString()) }
+ // Both calls must sit inside this delay at once; serialised, the second would not.
+ delay(100)
+ synchronized(started) { started.add("done:${args["file_path"]}") }
+ return ToolResult.success("contents")
+ }
+ }
+ val executor = Executor(ToolRouter(listOf(handler)), ToolApprovalManager())
+
+ executor.execute(
+ listOf(
+ ToolCall("read_file", mapOf("file_path" to "A.kt")),
+ ToolCall("read_file", mapOf("file_path" to "B.kt")),
+ )
+ )
+
+ assertEquals(listOf("A.kt", "B.kt", "done:A.kt", "done:B.kt"), started)
+ }
+
+ @Test
+ fun givenAReadWriteReadBatch_whenExecuting_thenResultsStayInInputOrder() = runBlocking {
+ // Whatever the schedule, result[i] belongs to toolCalls[i]; the loop pairs them positionally.
+ val read = object : ToolHandler {
+ override val toolName = "read_file"
+ override val description = "fake read"
+ override val pathArgs = emptyList()
+ override suspend fun execute(args: Map) =
+ ToolResult.success("read:${args["file_path"]}")
+ }
+ val write = object : ToolHandler {
+ override val toolName = "create_file"
+ override val description = "fake create"
+ override val pathArgs = emptyList()
+ override suspend fun execute(args: Map) =
+ ToolResult.success("wrote:${args["file_path"]}")
+ }
+ val executor = Executor(ToolRouter(listOf(read, write)), ToolApprovalManager())
+
+ val results = executor.execute(
+ listOf(
+ ToolCall("read_file", mapOf("file_path" to "A.kt")),
+ ToolCall("create_file", mapOf("file_path" to "B.kt", "content" to "x")),
+ ToolCall("read_file", mapOf("file_path" to "C.kt")),
+ )
+ )
+
+ assertEquals(
+ listOf("read:A.kt", "wrote:B.kt", "read:C.kt"),
+ results.map { it.message },
+ )
+ }
+
+ @Test
+ fun givenAHandlerThatRejectsInValidation_whenExecuting_thenItNeverReachesApprovalOrExecution() = runBlocking {
+ // The whole point of validate(): a doomed call must not cost the user a dialog.
+ var executed = false
+ val handler = object : ToolHandler {
+ override val toolName = "edit_file"
+ override val description = "fake edit"
+ override val requiresApproval = true
+ override val pathArgs = listOf("file_path")
+ override suspend fun validate(args: Map): Validation =
+ Validation.Rejected(
+ ToolResult.failure("new_string is identical to old_string — nothing to change")
+ )
+
+ override suspend fun execute(args: Map): ToolResult {
+ executed = true
+ return ToolResult.success("edited")
+ }
+ }
+ val approvalManager = ToolApprovalManager()
+ val executor = Executor(ToolRouter(listOf(handler)), approvalManager)
+
+ val results = executor.execute(
+ listOf(
+ ToolCall(
+ "edit_file",
+ mapOf("file_path" to "Main.kt", "old_string" to "x", "new_string" to "x"),
+ )
+ )
+ )
+
+ assertFalse(results.single().success)
+ assertTrue(results.single().message.contains("identical"))
+ assertFalse("the handler must not run", executed)
+ assertFalse("no approval may be requested", approvalManager.hasPendingApproval())
+ }
+
@Test
fun givenADefaultHandler_whenExecutingAnInProjectPath_thenItRuns() = runBlocking {
val handler = FakeHandler("fake_guarded", resolvesPathsInternally = false)
diff --git a/ai-assistant/src/test/kotlin/com/itsaky/androidide/plugins/aiassistant/tool/RespondArgsTest.kt b/ai-assistant/src/test/kotlin/com/itsaky/androidide/plugins/aiassistant/tool/RespondArgsTest.kt
new file mode 100644
index 00000000..01b58e1f
--- /dev/null
+++ b/ai-assistant/src/test/kotlin/com/itsaky/androidide/plugins/aiassistant/tool/RespondArgsTest.kt
@@ -0,0 +1,51 @@
+package com.itsaky.androidide.plugins.aiassistant.tool
+
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertNull
+import org.junit.Test
+
+/**
+ * Unit tests for [respondMessageOf] — the answer-key tolerance `respond` never had, because it is
+ * the one "tool" with no handler and so no `ToolHandler.argAliases`.
+ */
+class RespondArgsTest {
+
+ @Test
+ fun givenTheDocumentedKey_whenRead_thenTheMessageComesBack() {
+ assertEquals("All done.", respondMessageOf(mapOf("message" to "All done.")))
+ }
+
+ @Test
+ fun givenAnAlternateKey_whenRead_thenTheAnswerStillComesBack() {
+ listOf("text", "response", "answer", "content").forEach { key ->
+ assertEquals("'$key'", "All done.", respondMessageOf(mapOf(key to "All done.")))
+ }
+ }
+
+ @Test
+ fun givenSeveralKeys_whenRead_thenTheDocumentedOneWins() {
+ val args = mapOf("content" to "fourth", "text" to "second", "message" to "first")
+
+ assertEquals("first", respondMessageOf(args))
+ }
+
+ @Test
+ fun givenABlankDocumentedKeyAndAFilledAlternate_whenRead_thenTheAlternateIsUsed() {
+ // A blank "message" is what the reported Gemini failure looked like.
+ assertEquals("real answer", respondMessageOf(mapOf("message" to " ", "text" to "real answer")))
+ }
+
+ @Test
+ fun givenNoUsableKey_whenRead_thenItIsNull() {
+ assertNull(respondMessageOf(emptyMap()))
+ assertNull(respondMessageOf(mapOf("message" to "")))
+ assertNull(respondMessageOf(mapOf("message" to null)))
+ assertNull(respondMessageOf(mapOf("summary" to "under an unknown key")))
+ }
+
+ @Test
+ fun givenANonStringValue_whenRead_thenItIsRendered() {
+ // The extractor hands back whatever org.json parsed, which is not always a String.
+ assertEquals("42", respondMessageOf(mapOf("message" to 42)))
+ }
+}
diff --git a/ai-assistant/src/test/kotlin/com/itsaky/androidide/plugins/aiassistant/tool/ToolApprovalManagerTest.kt b/ai-assistant/src/test/kotlin/com/itsaky/androidide/plugins/aiassistant/tool/ToolApprovalManagerTest.kt
new file mode 100644
index 00000000..8ed7b5d1
--- /dev/null
+++ b/ai-assistant/src/test/kotlin/com/itsaky/androidide/plugins/aiassistant/tool/ToolApprovalManagerTest.kt
@@ -0,0 +1,154 @@
+package com.itsaky.androidide.plugins.aiassistant.tool
+
+import com.itsaky.androidide.plugins.aiassistant.models.ToolResult
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.async
+import kotlinx.coroutines.delay
+import kotlinx.coroutines.runBlocking
+import kotlinx.coroutines.withTimeout
+import org.junit.Assert.assertFalse
+import org.junit.Assert.assertTrue
+import org.junit.Test
+
+/**
+ * Unit tests for [ToolApprovalManager] — in particular the "Correct" decision, which is not a
+ * plain denial (the instruction has to reach the model), and the rule that a destructive tool
+ * is never blanket-approved for the session.
+ */
+class ToolApprovalManagerTest {
+
+ private val approvableHandler = object : ToolHandler {
+ override val toolName = "edit_file"
+ override val description = "fake edit"
+ override val requiresApproval = true
+ override suspend fun execute(args: Map) = ToolResult.success("ok")
+ }
+
+ /**
+ * Runs [ensureApproved] concurrently with the user's decision, which can only be
+ * submitted once the request is actually pending.
+ */
+ private fun decideWith(
+ manager: ToolApprovalManager,
+ result: ApprovalResult,
+ correction: String? = null,
+ toolName: String = "edit_file",
+ ): ApprovalResponse = runBlocking {
+ // Dispatchers.Default: the wait below must not sit on the thread it needs to progress on.
+ val pending = async(Dispatchers.Default) {
+ manager.ensureApproved(toolName, approvableHandler, mapOf("file_path" to "Main.kt"))
+ }
+ withTimeout(5_000) {
+ while (!manager.hasPendingApproval()) delay(5)
+ manager.submitApproval(result, correction)
+ pending.await()
+ }
+ }
+
+ @Test
+ fun givenACorrection_whenApprovalIsRequested_thenItIsNotApprovedAndTheInstructionIsRelayed() {
+ val manager = ToolApprovalManager()
+
+ val response = decideWith(manager, ApprovalResult.CORRECTED, "keep the original method name")
+
+ assertFalse("a correction must not run the tool", response.approved)
+ assertTrue(
+ "the user's words must reach the model: ${response.denialMessage}",
+ response.denialMessage?.contains("keep the original method name") == true
+ )
+ }
+
+ @Test
+ fun givenACorrectionWithNoText_whenApprovalIsRequested_thenItStillReadsAsARevisionRequest() {
+ val manager = ToolApprovalManager()
+
+ val response = decideWith(manager, ApprovalResult.CORRECTED, " ")
+
+ assertFalse(response.approved)
+ assertTrue(response.denialMessage?.contains("revise") == true)
+ }
+
+ @Test
+ fun givenSessionApprovalOfAnEdit_whenAskedAgain_thenTheUserIsAskedAgain() {
+ // Keyed by tool name alone, so honouring it would grant unreviewed writes to every file.
+ val manager = ToolApprovalManager()
+
+ val first = decideWith(manager, ApprovalResult.APPROVED_FOR_SESSION)
+ assertTrue(first.approved)
+
+ val second = decideWith(manager, ApprovalResult.DENIED)
+ assertFalse("edit_file must be re-confirmed every time", second.approved)
+ }
+
+ @Test
+ fun givenSessionApprovalOfANonDestructiveTool_whenAskedAgain_thenItIsRemembered() = runBlocking {
+ val manager = ToolApprovalManager()
+ val handler = object : ToolHandler {
+ override val toolName = "add_dependency"
+ override val description = "fake"
+ override val requiresApproval = true
+ override suspend fun execute(args: Map) = ToolResult.success("ok")
+ }
+
+ val first = decideWith(manager, ApprovalResult.APPROVED_FOR_SESSION, toolName = "add_dependency")
+ assertTrue(first.approved)
+
+ // No dialog this time: the session grant answers immediately.
+ val second = manager.ensureApproved("add_dependency", handler, emptyMap())
+ assertTrue(second.approved)
+ }
+
+ @Test
+ fun givenTwoConcurrentRequests_whenBothAreAnswered_thenNeitherCallerIsStranded() = runBlocking {
+ // One slot and one dialog: a second request used to overwrite it and strand the first.
+ val manager = ToolApprovalManager()
+
+ val first = async(Dispatchers.Default) {
+ manager.ensureApproved("edit_file", approvableHandler, mapOf("file_path" to "A.kt"))
+ }
+ withTimeout(5_000) { while (!manager.hasPendingApproval()) delay(5) }
+
+ val second = async(Dispatchers.Default) {
+ manager.ensureApproved("edit_file", approvableHandler, mapOf("file_path" to "B.kt"))
+ }
+
+ withTimeout(5_000) {
+ manager.submitApproval(ApprovalResult.APPROVED_ONCE)
+ val firstResponse = first.await()
+
+ // The second dialog can only appear now that the first has been answered.
+ while (!manager.hasPendingApproval()) delay(5)
+ manager.submitApproval(ApprovalResult.DENIED)
+ val secondResponse = second.await()
+
+ assertTrue("the first caller must get its own approval", firstResponse.approved)
+ assertFalse("the second caller must get its own denial", secondResponse.approved)
+ }
+ }
+
+ @Test
+ fun givenACancelledRun_whenApprovalWasPending_thenNoStaleRequestKeepsTheDialogUp() = runBlocking {
+ // Cancelling at the await must still clear the request, or the dialog stays on screen.
+ val manager = ToolApprovalManager()
+
+ val pending = async(Dispatchers.Default) {
+ manager.ensureApproved("edit_file", approvableHandler, mapOf("file_path" to "A.kt"))
+ }
+ withTimeout(5_000) { while (!manager.hasPendingApproval()) delay(5) }
+
+ pending.cancel()
+ withTimeout(5_000) { while (manager.hasPendingApproval()) delay(5) }
+
+ assertFalse("no request may outlive the cancelled call", manager.hasPendingApproval())
+ }
+
+ @Test
+ fun givenADenial_whenApprovalIsRequested_thenItReportsTheDenial() {
+ val manager = ToolApprovalManager()
+
+ val response = decideWith(manager, ApprovalResult.DENIED)
+
+ assertFalse(response.approved)
+ assertTrue(response.denialMessage?.contains("denied") == true)
+ }
+}
diff --git a/ai-assistant/src/test/kotlin/com/itsaky/androidide/plugins/aiassistant/tool/ToolCallExtractorTest.kt b/ai-assistant/src/test/kotlin/com/itsaky/androidide/plugins/aiassistant/tool/ToolCallExtractorTest.kt
index a7671b1c..a2dd4cf0 100644
--- a/ai-assistant/src/test/kotlin/com/itsaky/androidide/plugins/aiassistant/tool/ToolCallExtractorTest.kt
+++ b/ai-assistant/src/test/kotlin/com/itsaky/androidide/plugins/aiassistant/tool/ToolCallExtractorTest.kt
@@ -1,6 +1,7 @@
package com.itsaky.androidide.plugins.aiassistant.tool
import org.junit.Assert.assertEquals
+import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Test
@@ -63,4 +64,42 @@ class ToolCallExtractorTest {
)
assertTrue("narration must not produce tool calls, got $calls", calls.isEmpty())
}
+
+ @Test
+ fun givenProseBesideATaggedCall_whenReadingTheProse_thenOnlyTheProseComesBack() {
+ val prose = ToolCallExtractor.proseOutsideToolCalls(
+ "I renamed count to itemCount.\n{\"tool\":\"respond\",\"args\":{}}"
+ )
+ assertEquals("I renamed count to itemCount.", prose)
+ }
+
+ @Test
+ fun givenProseBetweenTwoTaggedCalls_whenReadingTheProse_thenBothEnvelopesAreRemoved() {
+ val prose = ToolCallExtractor.proseOutsideToolCalls(
+ "{\"tool\":\"a\"}Working on it.{\"tool\":\"b\"}"
+ )
+ assertEquals("Working on it.", prose)
+ }
+
+ @Test
+ fun givenNothingButATaggedCall_whenReadingTheProse_thenThereIsNone() {
+ assertNull(
+ ToolCallExtractor.proseOutsideToolCalls(
+ "{\"tool\":\"respond\",\"args\":{\"message\":\"hi\"}}"
+ )
+ )
+ }
+
+ @Test
+ fun givenAnUntaggedCallLeftOver_whenReadingTheProse_thenRawJsonIsNotTreatedAsProse() {
+ // Showing the user an untagged tool call is worse than showing nothing.
+ assertNull(
+ ToolCallExtractor.proseOutsideToolCalls("{\"tool\":\"read_file\",\"args\":{\"file_path\":\"A.kt\"}}")
+ )
+ }
+
+ @Test
+ fun givenPlainProse_whenReadingTheProse_thenItComesBackUnchanged() {
+ assertEquals("Hello, how can I help?", ToolCallExtractor.proseOutsideToolCalls("Hello, how can I help?"))
+ }
}
diff --git a/ai-assistant/src/test/kotlin/com/itsaky/androidide/plugins/aiassistant/tool/ToolRouterTest.kt b/ai-assistant/src/test/kotlin/com/itsaky/androidide/plugins/aiassistant/tool/ToolRouterTest.kt
new file mode 100644
index 00000000..5fd3ff45
--- /dev/null
+++ b/ai-assistant/src/test/kotlin/com/itsaky/androidide/plugins/aiassistant/tool/ToolRouterTest.kt
@@ -0,0 +1,57 @@
+package com.itsaky.androidide.plugins.aiassistant.tool
+
+import com.itsaky.androidide.plugins.aiassistant.models.ToolResult
+import kotlinx.coroutines.CancellationException
+import kotlinx.coroutines.runBlocking
+import org.junit.Assert.assertFalse
+import org.junit.Assert.assertTrue
+import org.junit.Assert.fail
+import org.junit.Test
+
+/**
+ * Unit tests for [ToolRouter] dispatch, focused on the difference between a tool that failed
+ * and a tool that was cancelled.
+ */
+class ToolRouterTest {
+
+ private class ThrowingHandler(
+ override val toolName: String,
+ private val error: Throwable,
+ ) : ToolHandler {
+ override val description = "throws"
+ override suspend fun execute(args: Map): ToolResult = throw error
+ }
+
+ @Test
+ fun givenACancelledTool_whenDispatched_thenCancellationPropagatesInsteadOfBecomingAFailure() {
+ // It extends Exception, so a broad catch would turn Stop into an ordinary tool failure.
+ val router = ToolRouter(listOf(ThrowingHandler("boom", CancellationException("stopped"))))
+
+ try {
+ runBlocking { router.dispatch("boom", emptyMap()) }
+ fail("dispatch should have rethrown CancellationException")
+ } catch (ce: CancellationException) {
+ assertTrue(true)
+ }
+ }
+
+ @Test
+ fun givenAFailingTool_whenDispatched_thenTheErrorIsReportedAsAFailureResult() {
+ val router = ToolRouter(listOf(ThrowingHandler("boom", IllegalStateException("bad"))))
+
+ val result = runBlocking { router.dispatch("boom", emptyMap()) }
+
+ assertFalse(result.success)
+ assertTrue(result.message.contains("bad"))
+ }
+
+ @Test
+ fun givenAnUnknownTool_whenDispatched_thenItFailsCleanly() {
+ val router = ToolRouter(emptyList())
+
+ val result = runBlocking { router.dispatch("nope", emptyMap()) }
+
+ assertFalse(result.success)
+ assertTrue(result.message.contains("Unknown tool"))
+ }
+}
diff --git a/ai-assistant/src/test/kotlin/com/itsaky/androidide/plugins/aiassistant/tool/handlers/EditFileHandlerTest.kt b/ai-assistant/src/test/kotlin/com/itsaky/androidide/plugins/aiassistant/tool/handlers/EditFileHandlerTest.kt
new file mode 100644
index 00000000..d8638989
--- /dev/null
+++ b/ai-assistant/src/test/kotlin/com/itsaky/androidide/plugins/aiassistant/tool/handlers/EditFileHandlerTest.kt
@@ -0,0 +1,747 @@
+package com.itsaky.androidide.plugins.aiassistant.tool.handlers
+
+import com.itsaky.androidide.plugins.PluginContext
+import com.itsaky.androidide.plugins.ServiceRegistry
+import com.itsaky.androidide.plugins.aiassistant.tool.Validation
+import com.itsaky.androidide.plugins.services.IdeEditorService
+import com.itsaky.androidide.plugins.services.SelectionRange
+import io.mockk.every
+import io.mockk.mockk
+import io.mockk.slot
+import io.mockk.verify
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.runBlocking
+import org.junit.After
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertFalse
+import org.junit.Assert.assertTrue
+import org.junit.Before
+import org.junit.Test
+import java.io.File
+import java.nio.file.Files
+
+/**
+ * Unit tests for [EditFileHandler] — surgical find/replace editing. Every rejection path asserts the
+ * file is **byte-for-byte unchanged**, not merely that a failure was returned: a guard that reports
+ * failure after truncating the file is the exact defect these tests exist to catch.
+ */
+class EditFileHandlerTest {
+
+ private lateinit var projectRoot: File
+ private lateinit var context: PluginContext
+ private lateinit var services: ServiceRegistry
+ private lateinit var editorService: IdeEditorService
+ private lateinit var handler: EditFileHandler
+
+ @Before
+ fun setup() {
+ projectRoot = Files.createTempDirectory("editfile-project").toFile().canonicalFile
+ PathGuard.setProjectRootForTesting(projectRoot.absolutePath)
+
+ editorService = mockk(relaxed = true)
+ // Default: nothing is open in the editor, so the disk path is exercised.
+ every { editorService.getFileContent(any()) } returns null
+ // Default: focusing before saving succeeds; the false case has its own test below.
+ every { editorService.openFile(any()) } returns true
+ services = mockk()
+ context = mockk()
+ every { context.services } returns services
+ every { context.logger } returns mockk(relaxed = true)
+ every { services.get(IdeEditorService::class.java) } returns editorService
+
+ handler = EditFileHandler(context, Dispatchers.Unconfined)
+ }
+
+ @After
+ fun tearDown() {
+ PathGuard.setProjectRootForTesting(null)
+ PathGuard.setProjectRootProvider(null)
+ projectRoot.deleteRecursively()
+ }
+
+ private fun createFile(relative: String, content: String): File =
+ File(projectRoot, relative).apply {
+ parentFile?.mkdirs()
+ writeText(content)
+ }
+
+ private fun edit(vararg pairs: Pair) = runBlocking {
+ handler.execute(mapOf(*pairs))
+ }
+
+ // --- Happy paths --------------------------------------------------------
+
+ @Test
+ fun givenAUniqueSnippet_whenEdited_thenOnlyThatSnippetChanges() {
+ val file = createFile("Main.kt", "val a = 1\nval b = 2\nval c = 3\n")
+
+ val result = edit(
+ "file_path" to "Main.kt",
+ "old_string" to "val b = 2",
+ "new_string" to "val b = 20",
+ )
+
+ assertTrue("Expected success, got: ${result.message}", result.success)
+ assertEquals("val a = 1\nval b = 20\nval c = 3\n", file.readText())
+ }
+
+ @Test
+ fun givenAnEmptyNewString_whenEdited_thenTheSnippetIsDeleted() {
+ val file = createFile("Main.kt", "keep\nremove me\nkeep\n")
+
+ val result = edit(
+ "file_path" to "Main.kt",
+ "old_string" to "remove me\n",
+ "new_string" to "",
+ )
+
+ assertTrue("Expected success, got: ${result.message}", result.success)
+ assertEquals("keep\nkeep\n", file.readText())
+ }
+
+ @Test
+ fun givenReplaceAll_whenTheSnippetRepeats_thenEveryOccurrenceChanges() {
+ val file = createFile("Main.kt", "x = 1\ny = 1\nz = 1\n")
+
+ val result = edit(
+ "file_path" to "Main.kt",
+ "old_string" to "1",
+ "new_string" to "2",
+ "replace_all" to "true",
+ )
+
+ assertTrue("Expected success, got: ${result.message}", result.success)
+ assertEquals("x = 2\ny = 2\nz = 2\n", file.readText())
+ }
+
+ @Test
+ fun givenAMultiLineSnippet_whenEdited_thenItIsReplacedWholesale() {
+ val file = createFile("Main.kt", "fun a() {\n old()\n old2()\n}\n")
+
+ val result = edit(
+ "file_path" to "Main.kt",
+ "old_string" to " old()\n old2()",
+ "new_string" to " fresh()",
+ )
+
+ assertTrue("Expected success, got: ${result.message}", result.success)
+ assertEquals("fun a() {\n fresh()\n}\n", file.readText())
+ }
+
+ @Test
+ fun givenNoEditorService_whenEdited_thenTheDiskPathStillWorks() {
+ val file = createFile("Main.kt", "old\n")
+ every { services.get(IdeEditorService::class.java) } returns null
+
+ val result = edit("file_path" to "Main.kt", "old_string" to "old", "new_string" to "new")
+
+ assertTrue("Expected success, got: ${result.message}", result.success)
+ assertEquals("new\n", file.readText())
+ }
+
+ // --- Match failures -----------------------------------------------------
+
+ @Test
+ fun givenASnippetThatIsAbsent_whenEdited_thenItFailsAndTheFileIsUntouched() {
+ val original = "val a = 1\n"
+ val file = createFile("Main.kt", original)
+
+ val result = edit(
+ "file_path" to "Main.kt",
+ "old_string" to "val zzz = 9",
+ "new_string" to "whatever",
+ )
+
+ assertFalse(result.success)
+ assertTrue(result.message.contains("not found"))
+ assertEquals(original, file.readText())
+ }
+
+ @Test
+ fun givenAnAmbiguousSnippet_whenEditedWithoutReplaceAll_thenItFailsWithTheMatchCount() {
+ val original = "dup\ndup\ndup\n"
+ val file = createFile("Main.kt", original)
+
+ val result = edit("file_path" to "Main.kt", "old_string" to "dup", "new_string" to "x")
+
+ assertFalse(result.success)
+ assertTrue("Expected the count in: ${result.message}", result.message.contains("3 times"))
+ assertEquals("nothing may be replaced when the match is ambiguous", original, file.readText())
+ }
+
+ @Test
+ fun givenAnAmbiguousBareName_whenEditedWithoutReplaceAll_thenReplaceAllIsWhatItIsToldToDo() {
+ createFile("Main.kt", "count\ncount\n")
+
+ val result = edit("file_path" to "Main.kt", "old_string" to "count", "new_string" to "total")
+
+ assertFalse(result.success)
+ // The model must be steered to one replace_all edit, not one call per occurrence.
+ assertTrue(
+ "Expected a replace_all instruction in: ${result.message}",
+ result.message.contains("replace_all") && result.message.contains("ONE edit"),
+ )
+ val replaceAllFirst = result.message.indexOf("replace_all")
+ val surroundingLater = result.message.indexOf("surrounding lines")
+ assertTrue(
+ "replace_all must be offered before adding surrounding lines: ${result.message}",
+ replaceAllFirst in 0 until surroundingLater,
+ )
+ }
+
+ @Test
+ fun givenAnAmbiguousCodeRegion_whenEditedWithoutReplaceAll_thenUniquenessIsSuggestedFirst() {
+ createFile("Main.kt", "if (a) {\n x()\n}\nif (a) {\n x()\n}\n")
+
+ val result = edit(
+ "file_path" to "Main.kt",
+ "old_string" to "if (a) {\n x()\n}",
+ "new_string" to "if (a) {\n y()\n}",
+ )
+
+ assertFalse(result.success)
+ assertTrue(
+ "A multi-line region is not a rename: ${result.message}",
+ result.message.contains("add surrounding lines"),
+ )
+ }
+
+ @Test
+ fun givenAnIdenticalReplacement_whenEdited_thenItIsRejectedRatherThanRewritingTheFile() {
+ val original = "same\n"
+ val file = createFile("Main.kt", original)
+
+ val result = edit("file_path" to "Main.kt", "old_string" to "same", "new_string" to "same")
+
+ assertFalse(result.success)
+ assertEquals(original, file.readText())
+ }
+
+ @Test
+ fun givenTheUsersInstructionPastedIntoBothArgs_whenEdited_thenTheSplitPairIsSuggested() {
+ // The exact local-model failure this hint exists for: "change _bind with _binding" echoed
+ // into old_string and new_string, then repeated verbatim until the agent loop gave up.
+ val original = "val _bind = 1\n"
+ val file = createFile("Main.kt", original)
+
+ val result = edit(
+ "file_path" to "Main.kt",
+ "old_string" to "_bind with _binding",
+ "new_string" to "_bind with _binding",
+ )
+
+ assertFalse(result.success)
+ assertTrue(
+ "Expected the corrected pair in: ${result.message}",
+ result.message.contains("old_string=\"_bind\"") &&
+ result.message.contains("new_string=\"_binding\""),
+ )
+ assertEquals(original, file.readText())
+ }
+
+ @Test
+ fun givenIdenticalArgsThatAreRealCode_whenEdited_thenNoSplitIsInvented() {
+ createFile("Main.kt", "val a = 1\n")
+
+ val result = edit(
+ "file_path" to "Main.kt",
+ "old_string" to "val a = 1",
+ "new_string" to "val a = 1",
+ )
+
+ assertFalse(result.success)
+ assertFalse(
+ "A code snippet must not be re-read as an instruction: ${result.message}",
+ result.message.contains("retry with"),
+ )
+ }
+
+ // --- Argument validation ------------------------------------------------
+
+ @Test
+ fun givenNoNewString_whenEdited_thenItFailsRatherThanGuessingADeletion() {
+ val original = "content\n"
+ val file = createFile("Main.kt", original)
+
+ val result = edit("file_path" to "Main.kt", "old_string" to "content")
+
+ assertFalse(result.success)
+ assertTrue(result.message.contains("new_string is required"))
+ assertEquals(original, file.readText())
+ }
+
+ @Test
+ fun givenNoOldString_whenEdited_thenItFails() {
+ val result = edit("file_path" to "Main.kt", "new_string" to "x")
+
+ assertFalse(result.success)
+ assertTrue(result.message.contains("old_string is required"))
+ }
+
+ @Test
+ fun givenABlankFilePath_whenEdited_thenItFails() {
+ val result = edit("file_path" to " ", "old_string" to "a", "new_string" to "b")
+
+ assertFalse(result.success)
+ assertTrue(result.message.contains("file_path is required"))
+ }
+
+ @Test
+ fun givenAnOversizedNewString_whenEdited_thenItIsRejectedAndTheFileIsUntouched() {
+ val original = "seed\n"
+ val file = createFile("Main.kt", original)
+
+ val result = edit(
+ "file_path" to "Main.kt",
+ "old_string" to "seed",
+ "new_string" to "x".repeat(EditFileHandler.MAX_ARG_CHARS + 1),
+ )
+
+ assertFalse(result.success)
+ assertEquals(original, file.readText())
+ }
+
+ // --- Path and target guards --------------------------------------------
+
+ @Test
+ fun givenAPathEscapingTheProjectRoot_whenEdited_thenItIsRejected() {
+ val result = edit("file_path" to "../outside.txt", "old_string" to "a", "new_string" to "b")
+
+ assertFalse(result.success)
+ assertTrue(result.message.contains("within project directory"))
+ }
+
+ @Test
+ fun givenANonexistentFileWithNoLookalike_whenEdited_thenItPointsAtSearchProject() {
+ val result = edit("file_path" to "Nope.kt", "old_string" to "a", "new_string" to "b")
+
+ assertFalse(result.success)
+ assertTrue(result.message.contains("search_project"))
+ }
+
+ @Test
+ fun givenAGuessedPathWithTheWrongExtension_whenEdited_thenTheOneRealCandidateIsUsed() {
+ // Bouncing back an invented .java path cost a turn and the model re-emitted the guess.
+ val real = createFile("app/src/main/java/com/example/myapp/MainActivity.kt", "val a = 1\n")
+
+ val result = edit(
+ "file_path" to "app/src/main/java/com/example/MainActivity.java",
+ "old_string" to "val a = 1",
+ "new_string" to "val a = 2",
+ )
+
+ assertTrue("Expected the guess to resolve, got: ${result.message}", result.success)
+ assertEquals("val a = 2\n", real.readText())
+ }
+
+ @Test
+ fun givenAGuessedPath_whenValidated_thenTheCorrectedPathIsWhatGetsApproved() {
+ // The user must review the file that will really change, not the model's guess.
+ createFile("app/src/main/java/com/example/myapp/MainActivity.kt", "val a = 1\n")
+
+ val validation = runBlocking {
+ handler.validate(
+ mapOf(
+ "file_path" to "app/src/main/java/com/example/MainActivity.java",
+ "old_string" to "val a = 1",
+ "new_string" to "val a = 2",
+ )
+ )
+ }
+
+ val accepted = validation as Validation.Accepted
+ assertEquals(
+ "app/src/main/java/com/example/myapp/MainActivity.kt",
+ accepted.args["file_path"],
+ )
+ }
+
+ @Test
+ fun givenSeveralPlausibleFiles_whenEdited_thenItAsksInsteadOfPickingOne() {
+ // With more than one candidate there is nothing safe to guess.
+ createFile("app/src/main/java/a/MainActivity.kt", "val a = 1\n")
+ createFile("app/src/main/java/b/MainActivity.kt", "val a = 1\n")
+
+ val result = edit(
+ "file_path" to "app/src/main/java/com/example/MainActivity.java",
+ "old_string" to "val a = 1",
+ "new_string" to "val a = 2",
+ )
+
+ assertFalse(result.success)
+ assertTrue(result.message.contains("did you mean"))
+ assertEquals("val a = 1\n", File(projectRoot, "app/src/main/java/a/MainActivity.kt").readText())
+ assertEquals("val a = 1\n", File(projectRoot, "app/src/main/java/b/MainActivity.kt").readText())
+ }
+
+ @Test
+ fun givenAGuessedFolderButTheRightName_whenEdited_thenTheRealFileIsUsed() {
+ val real = createFile("app/src/main/java/com/example/myapp/Settings.kt", "x\n")
+
+ val result = edit("file_path" to "app/Settings.kt", "old_string" to "x", "new_string" to "y")
+
+ assertTrue("Expected the guess to resolve, got: ${result.message}", result.success)
+ assertEquals("y\n", real.readText())
+ }
+
+ // --- Content guards -----------------------------------------------------
+
+ @Test
+ fun givenAFileLargerThanTheCap_whenEdited_thenItIsRejectedBeforeBeingRead() {
+ val file = File(projectRoot, "big.bin").apply {
+ writeBytes(ByteArray((EditFileHandler.MAX_EDIT_BYTES + 1024).toInt()) { 'a'.code.toByte() })
+ }
+ val sizeBefore = file.length()
+
+ val result = edit("file_path" to "big.bin", "old_string" to "aaa", "new_string" to "bbb")
+
+ assertFalse(result.success)
+ assertTrue(result.message.contains("too large"))
+ assertEquals(sizeBefore, file.length())
+ }
+
+ @Test
+ fun givenAFileWithNulBytes_whenEdited_thenItIsRefusedAsBinary() {
+ val bytes = byteArrayOf('a'.code.toByte(), 0, 'b'.code.toByte())
+ val file = File(projectRoot, "blob.bin").apply { writeBytes(bytes) }
+
+ val result = edit("file_path" to "blob.bin", "old_string" to "a", "new_string" to "z")
+
+ assertFalse(result.success)
+ assertTrue(result.message.contains("not a UTF-8 text file"))
+ assertArrayEqualsBytes(bytes, file.readBytes())
+ }
+
+ @Test
+ fun givenInvalidUtf8_whenEdited_thenItIsRefusedRatherThanRoundTrippedThroughReplacementChars() {
+ // 0xFF is not valid UTF-8; a lenient read writes back U+FFFD and corrupts the file.
+ val bytes = byteArrayOf('a'.code.toByte(), 0xFF.toByte(), 'b'.code.toByte())
+ val file = File(projectRoot, "latin.txt").apply { writeBytes(bytes) }
+
+ val result = edit("file_path" to "latin.txt", "old_string" to "a", "new_string" to "z")
+
+ assertFalse(result.success)
+ assertArrayEqualsBytes(bytes, file.readBytes())
+ }
+
+ // --- Line endings -------------------------------------------------------
+
+ @Test
+ fun givenACrlfFileAndAnLfSnippet_whenEdited_thenTheEditAppliesAndCrlfIsPreserved() {
+ // A model emits \n, so a literal match finds nothing in a CRLF file and can never succeed.
+ val file = createFile("Main.kt", "fun a() {\r\n old()\r\n}\r\n")
+
+ val result = edit(
+ "file_path" to "Main.kt",
+ "old_string" to "fun a() {\n old()",
+ "new_string" to "fun a() {\n fresh()",
+ )
+
+ assertTrue("Expected success, got: ${result.message}", result.success)
+ // The file keeps its own convention: adapting the snippet must not rewrite every line.
+ assertEquals("fun a() {\r\n fresh()\r\n}\r\n", file.readText())
+ }
+
+ @Test
+ fun givenACrlfFile_whenASingleLineSnippetIsEdited_thenOnlyThatLineChanges() {
+ val file = createFile("Main.kt", "val a = 1\r\nval b = 2\r\n")
+
+ val result = edit("file_path" to "Main.kt", "old_string" to "val b = 2", "new_string" to "val b = 3")
+
+ assertTrue("Expected success, got: ${result.message}", result.success)
+ assertEquals("val a = 1\r\nval b = 3\r\n", file.readText())
+ }
+
+ @Test
+ fun givenAMixedLineEndingFile_whenEditedAcrossLines_thenItRefusesRatherThanGuessing() {
+ // Converting either way would rewrite lines the user never approved.
+ val file = createFile("Main.kt", "one\r\ntwo\nthree\r\n")
+ val bytes = file.readBytes()
+
+ val result = edit("file_path" to "Main.kt", "old_string" to "one\ntwo", "new_string" to "x\ny")
+
+ assertFalse(result.success)
+ assertArrayEqualsBytes(bytes, file.readBytes())
+ }
+
+ // --- Open-editor path ---------------------------------------------------
+
+ @Test
+ fun givenAFileOpenInTheEditor_whenEdited_thenTheChangeGoesThroughTheBufferAtTheRightRange() {
+ createFile("Main.kt", "line0\nline1\nline2\n")
+ val target = File(projectRoot, "Main.kt")
+ every { editorService.getFileContent(target) } returns "line0\nline1\nline2\n"
+ every { editorService.replaceRange(any(), any(), any()) } returns true
+ every { editorService.saveCurrentFile() } returns true
+
+ val range = slot()
+ val replacement = slot()
+
+ val result = edit("file_path" to "Main.kt", "old_string" to "line1", "new_string" to "LINE1")
+
+ assertTrue("Expected success, got: ${result.message}", result.success)
+ verify { editorService.replaceRange(eq(target), capture(range), capture(replacement)) }
+ // 0-based line/column, matching the host editor's coordinate space.
+ assertEquals(1, range.captured.startLine)
+ assertEquals(0, range.captured.startColumn)
+ assertEquals(1, range.captured.endLine)
+ assertEquals(5, range.captured.endColumn)
+ assertEquals("LINE1", replacement.captured)
+ verify { editorService.saveCurrentFile() }
+ }
+
+ @Test
+ fun givenUnsavedEditorChanges_whenEdited_thenTheBufferIsTheTextMatchedAndNothingIsLost() {
+ // Disk is stale; the user's unsaved buffer is what the model must edit.
+ val file = createFile("Main.kt", "val a = 1\n")
+ every { editorService.getFileContent(file) } returns "val a = 1\nval userTyped = 2\n"
+ every { editorService.replaceRange(any(), any(), any()) } returns true
+ every { editorService.saveCurrentFile() } returns true
+
+ val result = edit(
+ "file_path" to "Main.kt",
+ "old_string" to "val userTyped = 2",
+ "new_string" to "val userTyped = 3",
+ )
+
+ assertTrue(
+ "The snippet exists only in the unsaved buffer; got: ${result.message}",
+ result.success
+ )
+ verify { editorService.replaceRange(eq(file), any(), eq("val userTyped = 3")) }
+ // The handler must not have written the stale disk copy behind the editor's back.
+ assertEquals("val a = 1\n", file.readText())
+ }
+
+ @Test
+ fun givenReplaceAllInAnOpenBuffer_whenEdited_thenTheWholeBufferIsSwappedInOneEdit() {
+ val file = createFile("Main.kt", "a\na\n")
+ every { editorService.getFileContent(file) } returns "a\na\n"
+ every { editorService.replaceRange(any(), any(), any()) } returns true
+ every { editorService.saveCurrentFile() } returns true
+
+ val range = slot()
+ val replacement = slot()
+
+ val result = edit(
+ "file_path" to "Main.kt",
+ "old_string" to "a",
+ "new_string" to "b",
+ "replace_all" to true,
+ )
+
+ assertTrue("Expected success, got: ${result.message}", result.success)
+ verify(exactly = 1) { editorService.replaceRange(any(), capture(range), capture(replacement)) }
+ assertEquals(0, range.captured.startLine)
+ assertEquals(0, range.captured.startColumn)
+ assertEquals("b\nb\n", replacement.captured)
+ }
+
+ @Test
+ fun givenTheTabCannotBeFocused_whenEdited_thenNothingIsSavedAndTheResultSaysUnsaved() {
+ // saveCurrentFile() saves the FOCUSED tab, so saving unfocused persists a different file.
+ val file = createFile("Main.kt", "old\n")
+ every { editorService.getFileContent(file) } returns "old\n"
+ every { editorService.replaceRange(any(), any(), any()) } returns true
+ every { editorService.openFile(any()) } returns false
+
+ val result = edit("file_path" to "Main.kt", "old_string" to "old", "new_string" to "new")
+
+ assertTrue("The buffer edit applied, so this is a success: ${result.message}", result.success)
+ assertTrue(
+ "Must not claim the file was saved; got: ${result.message}",
+ result.message.contains("left unsaved")
+ )
+ verify(exactly = 0) { editorService.saveCurrentFile() }
+ assertEquals("old\n", file.readText())
+ }
+
+ @Test
+ fun givenTheEditorRejectingTheEdit_whenEdited_thenItFailsAndTheDiskCopyIsUntouched() {
+ val file = createFile("Main.kt", "old\n")
+ every { editorService.getFileContent(file) } returns "old\n"
+ every { editorService.replaceRange(any(), any(), any()) } returns false
+
+ val result = edit("file_path" to "Main.kt", "old_string" to "old", "new_string" to "new")
+
+ assertFalse(result.success)
+ assertEquals("old\n", file.readText())
+ verify(exactly = 0) { editorService.saveCurrentFile() }
+ }
+
+ @Test
+ fun givenTheUserTypingWhileTheEditWaitedForApproval_whenApplied_thenTheStaleOffsetsAreNotUsed() {
+ // Offsets from the analysed buffer would replace the wrong span in a since-edited one.
+ val file = createFile("Main.kt", "line0\nline1\nline2\n")
+ val analysed = "line0\nline1\nline2\n"
+ every { editorService.getFileContent(file) } returnsMany listOf(
+ analysed,
+ // What the user typed while the dialog was up: every offset past line 0 has moved.
+ "inserted\nline0\nline1\nline2\n",
+ )
+ every { editorService.replaceRange(any(), any(), any()) } returns true
+
+ val result = edit("file_path" to "Main.kt", "old_string" to "line1", "new_string" to "LINE1")
+
+ assertFalse("a stale range must not be applied: ${result.message}", result.success)
+ assertTrue(
+ "the model needs to know why, so it can re-read: ${result.message}",
+ result.message.contains("changed")
+ )
+ verify(exactly = 0) { editorService.replaceRange(any(), any(), any()) }
+ verify(exactly = 0) { editorService.saveCurrentFile() }
+ assertEquals("the disk copy must not be touched either", analysed, file.readText())
+ }
+
+ @Test
+ fun givenAnUnchangedBuffer_whenApplied_thenTheReReadDoesNotBlockTheEdit() {
+ // The guard above must not fire when nothing changed between analysis and application.
+ val file = createFile("Main.kt", "line0\nline1\n")
+ every { editorService.getFileContent(file) } returnsMany listOf(
+ "line0\nline1\n",
+ "line0\nline1\n",
+ )
+ every { editorService.replaceRange(any(), any(), any()) } returns true
+ every { editorService.saveCurrentFile() } returns true
+
+ val result = edit("file_path" to "Main.kt", "old_string" to "line1", "new_string" to "LINE1")
+
+ assertTrue("Expected success, got: ${result.message}", result.success)
+ verify(exactly = 1) { editorService.replaceRange(any(), any(), any()) }
+ }
+
+ // --- Pre-approval validation -------------------------------------------
+
+ @Test
+ fun givenAnApplicableEdit_whenValidated_thenItPasses() = runBlocking {
+ createFile("Main.kt", "val a = 1\n")
+
+ val validation = handler.validate(
+ mapOf("file_path" to "Main.kt", "old_string" to "val a = 1", "new_string" to "val a = 2")
+ )
+
+ assertTrue("expected acceptance, got $validation", validation is Validation.Accepted)
+ }
+
+ @Test
+ fun givenIdenticalStrings_whenValidated_thenItIsRejectedWithoutPromptingTheUser() = runBlocking {
+ // The commonest malformed edit: approving it can only fail, so never show the dialog.
+ createFile("Main.kt", "_binding\n")
+
+ val result = rejectionOf(
+ mapOf("file_path" to "Main.kt", "old_string" to "_binding", "new_string" to "_binding")
+ )
+
+ assertTrue(result?.success == false)
+ assertTrue(result!!.message.contains("identical"))
+ }
+
+ @Test
+ fun givenAHallucinatedPath_whenValidated_thenItIsRejectedWithoutPromptingTheUser() = runBlocking {
+ val result = rejectionOf(
+ mapOf("file_path" to "app/src/main/java/com/nope/Ghost.java", "old_string" to "a", "new_string" to "b")
+ )
+
+ assertTrue(result?.success == false)
+ assertTrue(result!!.message.contains("does not exist"))
+ }
+
+ @Test
+ fun givenAnAbsentSnippet_whenValidated_thenItIsRejectedWithoutPromptingTheUser() = runBlocking {
+ createFile("Main.kt", "val a = 1\n")
+
+ val result = rejectionOf(
+ mapOf("file_path" to "Main.kt", "old_string" to "not in the file", "new_string" to "x")
+ )
+
+ assertTrue(result?.success == false)
+ assertTrue(result!!.message.contains("not found"))
+ }
+
+ @Test
+ fun givenAProtectedPath_whenValidated_thenItIsRejectedWithoutPromptingTheUser() = runBlocking {
+ createFile(".git/config", "[core]\n")
+
+ val result = rejectionOf(
+ mapOf("file_path" to ".git/config", "old_string" to "[core]", "new_string" to "[x]")
+ )
+
+ assertTrue(result?.success == false)
+ }
+
+ @Test
+ fun givenValidation_whenItRuns_thenTheFileIsNotTouched() = runBlocking {
+ // Running before approval, validate() must be side-effect free even when the edit is good.
+ val file = createFile("Main.kt", "val a = 1\n")
+
+ handler.validate(
+ mapOf("file_path" to "Main.kt", "old_string" to "val a = 1", "new_string" to "val a = 2")
+ )
+
+ assertEquals("val a = 1\n", file.readText())
+ verify(exactly = 0) { editorService.replaceRange(any(), any(), any()) }
+ verify(exactly = 0) { editorService.saveCurrentFile() }
+ }
+
+ // --- Content changing while the approval dialog is open -----------------
+
+ @Test
+ fun givenTheFileChangedAfterApproval_whenApplied_thenItRefusesRatherThanEditUnreviewedText() =
+ runBlocking {
+ // Rewritten under the dialog, old_string can still match once where nobody reviewed.
+ val file = createFile("Main.kt", "val a = 1\nval keep = 0\n")
+ val approved = acceptedArgsOf(
+ mapOf("file_path" to "Main.kt", "old_string" to "val a = 1", "new_string" to "val a = 2")
+ )
+
+ file.writeText("val other = 9\nval a = 1\n")
+ val result = handler.execute(approved)
+
+ assertFalse("expected a refusal, got: ${result.message}", result.success)
+ assertTrue(result.message.contains("changed after"))
+ assertEquals("the file must be left exactly as it was", "val other = 9\nval a = 1\n", file.readText())
+ }
+
+ @Test
+ fun givenTheFileIsUntouchedAfterApproval_whenApplied_thenTheEditStillGoesThrough() = runBlocking {
+ // The staleness check must not cost the ordinary validate-then-execute path.
+ val file = createFile("Main.kt", "val a = 1\n")
+ val approved = acceptedArgsOf(
+ mapOf("file_path" to "Main.kt", "old_string" to "val a = 1", "new_string" to "val a = 2")
+ )
+
+ val result = handler.execute(approved)
+
+ assertTrue("Expected success, got: ${result.message}", result.success)
+ assertEquals("val a = 2\n", file.readText())
+ }
+
+ @Test
+ fun givenNoFingerprint_whenApplied_thenTheEditIsNotBlocked() = runBlocking {
+ // A caller that doesn't pre-validate gets the old behaviour rather than a hard failure.
+ val file = createFile("Main.kt", "val a = 1\n")
+
+ val result = handler.execute(
+ mapOf("file_path" to "Main.kt", "old_string" to "val a = 1", "new_string" to "val a = 2")
+ )
+
+ assertTrue("Expected success, got: ${result.message}", result.success)
+ assertEquals("val a = 2\n", file.readText())
+ }
+
+ /** The arguments [EditFileHandler.validate] hands on for approval, fingerprint included. */
+ private suspend fun acceptedArgsOf(args: Map): Map =
+ (handler.validate(args) as Validation.Accepted).args
+
+ /** The failure from a rejected validation, or null when it was accepted. */
+ private suspend fun rejectionOf(args: Map) =
+ (handler.validate(args) as? Validation.Rejected)?.result
+
+ private fun assertArrayEqualsBytes(expected: ByteArray, actual: ByteArray) {
+ assertEquals(
+ "file bytes must be unchanged",
+ expected.joinToString(",") { it.toString() },
+ actual.joinToString(",") { it.toString() },
+ )
+ }
+}
diff --git a/ai-assistant/src/test/kotlin/com/itsaky/androidide/plugins/aiassistant/tool/handlers/edit/AtomicFileWriterTest.kt b/ai-assistant/src/test/kotlin/com/itsaky/androidide/plugins/aiassistant/tool/handlers/edit/AtomicFileWriterTest.kt
new file mode 100644
index 00000000..bb1a82f7
--- /dev/null
+++ b/ai-assistant/src/test/kotlin/com/itsaky/androidide/plugins/aiassistant/tool/handlers/edit/AtomicFileWriterTest.kt
@@ -0,0 +1,103 @@
+package com.itsaky.androidide.plugins.aiassistant.tool.handlers.edit
+
+import org.junit.After
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertTrue
+import org.junit.Before
+import org.junit.Test
+import java.io.File
+import java.nio.charset.StandardCharsets
+import java.nio.file.Files
+
+/**
+ * Unit tests for [AtomicFileWriter]. The case that matters most is the ordinary one: replacing a file
+ * that **already exists**, which `File.renameTo` reports a bare `false` for on some volumes. So
+ * "overwrite it, and leave no staging file behind" is asserted directly.
+ */
+class AtomicFileWriterTest {
+
+ private lateinit var dir: File
+
+ @Before
+ fun setup() {
+ dir = Files.createTempDirectory("atomic-writer").toFile().canonicalFile
+ }
+
+ @After
+ fun tearDown() {
+ dir.deleteRecursively()
+ }
+
+ private fun bytesOf(text: String) = text.toByteArray(StandardCharsets.UTF_8)
+
+ private fun stagingFiles() = dir.listFiles()?.filter { it.name.endsWith(".aiedit") }.orEmpty()
+
+ @Test
+ fun givenAnExistingFile_whenReplaced_thenItHoldsTheNewBytes() {
+ val file = File(dir, "Main.kt").apply { writeText("old\n") }
+
+ val outcome = AtomicFileWriter.replace(file, "Main.kt", bytesOf("new\n"))
+
+ assertTrue("expected success, got $outcome", outcome is AtomicFileWriter.Outcome.Written)
+ assertEquals("new\n", file.readText())
+ }
+
+ @Test
+ fun givenAnExistingFile_whenReplaced_thenNoStagingFileIsLeftBehind() {
+ val file = File(dir, "Main.kt").apply { writeText("old\n") }
+
+ AtomicFileWriter.replace(file, "Main.kt", bytesOf("new\n"))
+
+ assertEquals("no .aiedit litter may survive a write", emptyList(), stagingFiles())
+ }
+
+ @Test
+ fun givenAFileWithUnusualPermissions_whenReplaced_thenTheyAreCarriedOver() {
+ // A move adopts the temp file's 0600 mode, so a group-readable file would come back private.
+ val file = File(dir, "Main.kt").apply { writeText("old\n") }
+ val before = runCatching { Files.getPosixFilePermissions(file.toPath()) }.getOrNull()
+ org.junit.Assume.assumeTrue("POSIX permissions unsupported here", before != null)
+
+ AtomicFileWriter.replace(file, "Main.kt", bytesOf("new\n"))
+
+ assertEquals(before, Files.getPosixFilePermissions(file.toPath()))
+ }
+
+ @Test
+ fun givenANonWritableFile_whenReplaced_thenItFailsAndTheContentSurvives() {
+ val file = File(dir, "Main.kt").apply { writeText("old\n") }
+ org.junit.Assume.assumeTrue("cannot drop write permission here", file.setWritable(false))
+
+ val outcome = AtomicFileWriter.replace(file, "Main.kt", bytesOf("new\n"))
+
+ assertTrue(outcome is AtomicFileWriter.Outcome.Failed)
+ assertTrue(
+ (outcome as AtomicFileWriter.Outcome.Failed).reason.contains("not writable")
+ )
+ assertEquals("old\n", file.readText())
+ assertEquals(emptyList(), stagingFiles())
+ }
+
+ @Test
+ fun givenAnEmptyReplacement_whenWritten_thenTheFileIsTruncatedRatherThanLeftAlone() {
+ // An edit whose new_string deletes the file's entire contents is legal.
+ val file = File(dir, "Main.kt").apply { writeText("old\n") }
+
+ val outcome = AtomicFileWriter.replace(file, "Main.kt", ByteArray(0))
+
+ assertTrue(outcome is AtomicFileWriter.Outcome.Written)
+ assertEquals("", file.readText())
+ }
+
+ @Test
+ fun givenRepeatedReplacements_whenWritten_thenEachOneLandsAndNothingAccumulates() {
+ val file = File(dir, "Main.kt").apply { writeText("v0\n") }
+
+ repeat(5) { index ->
+ AtomicFileWriter.replace(file, "Main.kt", bytesOf("v${index + 1}\n"))
+ }
+
+ assertEquals("v5\n", file.readText())
+ assertEquals(emptyList(), stagingFiles())
+ }
+}
diff --git a/ai-assistant/src/test/kotlin/com/itsaky/androidide/plugins/aiassistant/tool/handlers/edit/EditTargetResolverTest.kt b/ai-assistant/src/test/kotlin/com/itsaky/androidide/plugins/aiassistant/tool/handlers/edit/EditTargetResolverTest.kt
new file mode 100644
index 00000000..840a7dbe
--- /dev/null
+++ b/ai-assistant/src/test/kotlin/com/itsaky/androidide/plugins/aiassistant/tool/handlers/edit/EditTargetResolverTest.kt
@@ -0,0 +1,182 @@
+package com.itsaky.androidide.plugins.aiassistant.tool.handlers.edit
+
+import com.itsaky.androidide.plugins.aiassistant.tool.handlers.PathGuard
+import org.junit.After
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertNull
+import org.junit.Assert.assertTrue
+import org.junit.Before
+import org.junit.Test
+import java.io.File
+import java.nio.file.Files
+
+/**
+ * Unit tests for [EditTargetResolver] — the decision about *which* file an edit may touch. Both halves
+ * are load-bearing: rejecting too much makes the tool unusable on a local model, whose paths are often
+ * invented, and accepting too much lets a write land in the git database.
+ */
+class EditTargetResolverTest {
+
+ private lateinit var projectRoot: File
+
+ @Before
+ fun setup() {
+ projectRoot = Files.createTempDirectory("edit-target").toFile().canonicalFile
+ PathGuard.setProjectRootForTesting(projectRoot.absolutePath)
+ }
+
+ @After
+ fun tearDown() {
+ PathGuard.setProjectRootForTesting(null)
+ PathGuard.setProjectRootProvider(null)
+ projectRoot.deleteRecursively()
+ }
+
+ private fun createFile(relative: String, content: String = "x\n"): File =
+ File(projectRoot, relative).apply {
+ parentFile?.mkdirs()
+ writeText(content)
+ }
+
+ private fun resolved(path: String) =
+ EditTargetResolver.resolve(path) as? EditTargetResolver.Target.Resolved
+ ?: error("expected $path to resolve")
+
+ private fun rejection(path: String) =
+ (EditTargetResolver.resolve(path) as? EditTargetResolver.Target.Rejected)?.reason
+ ?: error("expected $path to be rejected")
+
+ @Test
+ fun givenAnExistingFile_whenResolved_thenItIsUsedAsGiven() {
+ val file = createFile("app/src/Main.kt")
+
+ val target = resolved("app/src/Main.kt")
+
+ assertEquals(file.canonicalFile, target.file.canonicalFile)
+ assertEquals("app/src/Main.kt", target.displayPath)
+ assertNull("nothing was corrected", target.correctedFrom)
+ }
+
+ @Test
+ fun givenAGuessedPathWithOneRealCandidate_whenResolved_thenTheRealFileIsUsedAndReported() {
+ // The commonest local-model error: right class, wrong language or folder.
+ createFile("app/src/main/kotlin/Main.kt")
+
+ val target = resolved("app/src/main/java/Main.java")
+
+ assertEquals("Main.kt", target.file.name)
+ assertEquals(
+ "the corrected path is what the approval dialog must show",
+ "app/src/main/kotlin/Main.kt",
+ target.displayPath,
+ )
+ assertEquals("app/src/main/java/Main.java", target.correctedFrom)
+ }
+
+ @Test
+ fun givenSeveralPlausibleCandidates_whenResolved_thenItAsksInsteadOfPickingOne() {
+ createFile("a/Main.kt")
+ createFile("b/Main.kt")
+
+ val reason = rejection("c/Main.kt")
+
+ assertTrue("must offer the candidates: $reason", reason.contains("did you mean"))
+ assertTrue("both candidates belong in the message: $reason", reason.contains("b/Main.kt"))
+ }
+
+ @Test
+ fun givenOneExactNameMatchBesideOtherLanguages_whenResolved_thenTheExactNameWins() {
+ // The extension the model asked for settles it; bouncing this back costs a whole turn for
+ // nothing. Only rivals *with the same name* make the choice genuinely ambiguous.
+ createFile("app/src/main/kotlin/Main.kt")
+ createFile("app/legacy/Main.java")
+
+ val target = resolved("app/src/Main.kt")
+
+ assertEquals("app/src/main/kotlin/Main.kt", target.displayPath)
+ assertEquals("app/src/Main.kt", target.correctedFrom)
+ }
+
+ @Test
+ fun givenTwoSameNameMatchesAndAStemMatch_whenResolved_thenItStillAsks() {
+ createFile("a/Main.kt")
+ createFile("b/Main.kt")
+ createFile("c/Main.java")
+
+ val reason = rejection("d/Main.kt")
+
+ assertTrue("got: $reason", reason.contains("did you mean"))
+ }
+
+ @Test
+ fun givenNoPlausibleCandidate_whenResolved_thenItPointsAtSearchProjectAndCreateFile() {
+ val reason = rejection("com/nope/Ghost.java")
+
+ assertTrue(reason.contains("search_project"))
+ assertTrue(reason.contains("create_file"))
+ }
+
+ @Test
+ fun givenAPathOutsideTheProject_whenResolved_thenItIsRejected() {
+ val reason = rejection("../../etc/hosts")
+
+ assertTrue(reason.contains("within project directory"))
+ }
+
+ @Test
+ fun givenAFileInsideTheGitDatabase_whenResolved_thenItIsRefused() {
+ createFile(".git/config", "[core]\n")
+
+ val reason = rejection(".git/config")
+
+ assertTrue("git internals are unrecoverable for a user with no other checkout: $reason",
+ reason.contains(".git"))
+ }
+
+ @Test
+ fun givenAFileInAGeneratedTree_whenResolved_thenItIsRefused() {
+ createFile("app/build/generated/Out.kt")
+
+ val reason = rejection("app/build/generated/Out.kt")
+
+ assertTrue(reason.contains("build/"))
+ }
+
+ @Test
+ fun givenSigningMaterial_whenResolved_thenItIsRefused() {
+ createFile("release.keystore")
+
+ val reason = rejection("release.keystore")
+
+ assertTrue(reason.contains("signing"))
+ }
+
+ @Test
+ fun givenBuildConfiguration_whenResolved_thenItIsRefused() {
+ createFile("local.properties", "sdk.dir=/x\n")
+
+ val reason = rejection("local.properties")
+
+ assertTrue(reason.contains("build configuration"))
+ }
+
+ @Test
+ fun givenADirectory_whenResolved_thenItIsRejectedBeforeAnyWriteIsAttempted() {
+ // A directory resolves and is contained, so only the isFile check stops the write.
+ File(projectRoot, "app/src").mkdirs()
+
+ val reason = rejection("app/src")
+
+ assertTrue("got: $reason", reason.contains("not a file"))
+ }
+
+ @Test
+ fun givenADenylistedDirectoryItself_whenResolved_thenItIsRejectedRatherThanTreatedAsAFile() {
+ // The denylist's dropLast(1) skips the basename, so the isFile check refuses a bare "build".
+ File(projectRoot, "build").mkdirs()
+
+ val reason = rejection("build")
+
+ assertTrue("got: $reason", reason.contains("not a file"))
+ }
+}
diff --git a/ai-assistant/src/test/kotlin/com/itsaky/androidide/plugins/aiassistant/tool/handlers/edit/FileTextMatcherTest.kt b/ai-assistant/src/test/kotlin/com/itsaky/androidide/plugins/aiassistant/tool/handlers/edit/FileTextMatcherTest.kt
new file mode 100644
index 00000000..6797828b
--- /dev/null
+++ b/ai-assistant/src/test/kotlin/com/itsaky/androidide/plugins/aiassistant/tool/handlers/edit/FileTextMatcherTest.kt
@@ -0,0 +1,97 @@
+package com.itsaky.androidide.plugins.aiassistant.tool.handlers.edit
+
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertFalse
+import org.junit.Assert.assertTrue
+import org.junit.Test
+
+/**
+ * Unit tests for [FileTextMatcher]. The line-ending cases are why this class exists: a model emits
+ * `\n`, so a CRLF file used to be uneditable while the advice to "copy the text exactly" was already
+ * followed. Every adapted case asserts the direction too: the snippet converts, never the file.
+ */
+class FileTextMatcherTest {
+
+ private fun found(match: FileTextMatcher.Match) =
+ match as? FileTextMatcher.Match.Found ?: error("expected a match, got $match")
+
+ @Test
+ fun givenAnLfFileAndAnLfSnippet_whenMatching_thenItMatchesVerbatim() {
+ val match = found(FileTextMatcher.match("a\nold\nb\n", "old", "new"))
+
+ assertEquals(1, match.occurrences)
+ assertEquals("old", match.oldString)
+ assertEquals("new", match.newString)
+ assertFalse("no adaptation was needed", match.lineEndingsAdapted)
+ }
+
+ @Test
+ fun givenACrlfFileAndAnLfSnippet_whenMatching_thenTheSnippetIsAdaptedToCrlf() {
+ val text = "fun a() {\r\n old()\r\n}\r\n"
+
+ val match = found(FileTextMatcher.match(text, "fun a() {\n old()", "fun a() {\n new()"))
+
+ assertEquals(1, match.occurrences)
+ assertTrue("adaptation must be reported", match.lineEndingsAdapted)
+ assertEquals("fun a() {\r\n old()", match.oldString)
+ // The replacement is adapted the same way, or the edit would leave mixed endings.
+ assertEquals("fun a() {\r\n new()", match.newString)
+ }
+
+ @Test
+ fun givenACrlfFileAndAnLfSnippet_whenReplacing_thenTheFilesCrlfEndingsSurvive() {
+ // Normalising the *file* to LF for a two-line edit rewrites every untouched line.
+ val text = "one\r\ntwo\r\nthree\r\n"
+
+ val match = found(FileTextMatcher.match(text, "one\ntwo", "one\nTWO"))
+ val updated = text.replace(match.oldString, match.newString)
+
+ assertEquals("one\r\nTWO\r\nthree\r\n", updated)
+ }
+
+ @Test
+ fun givenAnLfFileAndACrlfSnippet_whenMatching_thenTheSnippetIsAdaptedToLf() {
+ val match = found(FileTextMatcher.match("one\ntwo\n", "one\r\ntwo", "one\r\nTWO"))
+
+ assertEquals(1, match.occurrences)
+ assertTrue(match.lineEndingsAdapted)
+ assertEquals("one\ntwo", match.oldString)
+ assertEquals("one\nTWO", match.newString)
+ }
+
+ @Test
+ fun givenAMixedEndingFile_whenMatchingAnLfSnippet_thenItRefusesToGuess() {
+ // Half CRLF, half LF: there is no single convention, so either choice rewrites lines.
+ val text = "one\r\ntwo\nthree\r\n"
+
+ val match = FileTextMatcher.match(text, "one\ntwo", "x\ny")
+
+ assertTrue("must not guess on mixed endings", match is FileTextMatcher.Match.NotFound)
+ }
+
+ @Test
+ fun givenASingleLineSnippetThatIsAbsent_whenMatching_thenNoAdaptationIsAttempted() {
+ val match = FileTextMatcher.match("a\r\nb\r\n", "zzz", "x")
+
+ assertTrue(match is FileTextMatcher.Match.NotFound)
+ }
+
+ @Test
+ fun givenACrlfFileAndARepeatedLfSnippet_whenMatching_thenEveryOccurrenceIsCounted() {
+ val text = "x\r\ny\r\nx\r\ny\r\n"
+
+ val match = found(FileTextMatcher.match(text, "x\ny", "z\nw"))
+
+ assertEquals(2, match.occurrences)
+ }
+
+ @Test
+ fun givenAnEmptyNeedle_whenCounting_thenItIsZeroRatherThanUnbounded() {
+ assertEquals(0, FileTextMatcher.countOccurrences("abc", ""))
+ }
+
+ @Test
+ fun givenOverlappingCandidates_whenCounting_thenMatchesDoNotOverlap() {
+ assertEquals(2, FileTextMatcher.countOccurrences("aaaa", "aa"))
+ }
+}
diff --git a/ai-assistant/src/test/kotlin/com/itsaky/androidide/plugins/aiassistant/utils/AgentTraceTest.kt b/ai-assistant/src/test/kotlin/com/itsaky/androidide/plugins/aiassistant/utils/AgentTraceTest.kt
new file mode 100644
index 00000000..315a2d60
--- /dev/null
+++ b/ai-assistant/src/test/kotlin/com/itsaky/androidide/plugins/aiassistant/utils/AgentTraceTest.kt
@@ -0,0 +1,69 @@
+package com.itsaky.androidide.plugins.aiassistant.utils
+
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertFalse
+import org.junit.Assert.assertTrue
+import org.junit.Test
+
+/**
+ * Unit tests for [AgentTrace]'s previewing rules — the part that decides how much of a
+ * user's prompt and source code ends up in logcat. A regression here leaks file contents
+ * into a log rather than merely formatting something oddly.
+ */
+class AgentTraceTest {
+
+ @Test
+ fun givenAShortValue_whenPreviewed_thenItIsQuotedInFull() {
+ assertEquals("\"hello\"", AgentTrace.preview("hello"))
+ }
+
+ @Test
+ fun givenALongValue_whenPreviewed_thenItIsCutAndTheRealLengthIsReported() {
+ val preview = AgentTrace.preview("x".repeat(500))
+
+ assertTrue("should be truncated: $preview", preview.contains("…"))
+ assertTrue("should report the true size: $preview", preview.contains("(500 chars)"))
+ assertTrue(
+ "must not carry the whole value",
+ preview.length < 500
+ )
+ }
+
+ @Test
+ fun givenMultiLineCode_whenPreviewed_thenItCollapsesToOneLogLine() {
+ val preview = AgentTrace.preview("fun a() {\n body()\n}")
+
+ assertFalse("a log line must not contain raw newlines", preview.contains("\n"))
+ assertTrue(preview.contains("⏎"))
+ }
+
+ @Test
+ fun givenCarriageReturns_whenPreviewed_thenTheyAreStripped() {
+ assertFalse(AgentTrace.preview("a\r\nb").contains("\r"))
+ }
+
+ @Test
+ fun givenNull_whenPreviewed_thenItIsRenderedWithoutQuotes() {
+ assertEquals("null", AgentTrace.preview(null))
+ }
+
+ @Test
+ fun givenEditArguments_whenPreviewed_thenEachValueIsCappedIndependently() {
+ val rendered = AgentTrace.previewArgs(
+ mapOf(
+ "file_path" to "app/src/main/java/Main.java",
+ "old_string" to "y".repeat(300),
+ "new_string" to "short",
+ )
+ )
+
+ assertTrue(rendered.contains("file_path="))
+ assertTrue("the long snippet must be capped: $rendered", rendered.contains("(300 chars)"))
+ assertTrue("short values stay intact: $rendered", rendered.contains("\"short\""))
+ }
+
+ @Test
+ fun givenNoArguments_whenPreviewed_thenItRendersEmpty() {
+ assertEquals("", AgentTrace.previewArgs(emptyMap()))
+ }
+}
diff --git a/ai-assistant/src/test/kotlin/com/itsaky/androidide/plugins/aiassistant/utils/ToolArgsTest.kt b/ai-assistant/src/test/kotlin/com/itsaky/androidide/plugins/aiassistant/utils/ToolArgsTest.kt
new file mode 100644
index 00000000..2594c98a
--- /dev/null
+++ b/ai-assistant/src/test/kotlin/com/itsaky/androidide/plugins/aiassistant/utils/ToolArgsTest.kt
@@ -0,0 +1,46 @@
+package com.itsaky.androidide.plugins.aiassistant.utils
+
+import org.junit.Assert.assertFalse
+import org.junit.Assert.assertTrue
+import org.junit.Test
+import java.util.Locale
+
+/**
+ * Unit tests for [parseToolBoolean] — one parser shared by every place that reads a boolean out
+ * of a tool call, so the approval dialog and the handler cannot disagree about a flag as
+ * consequential as `replace_all`.
+ */
+class ToolArgsTest {
+
+ @Test
+ fun givenARealBoolean_whenParsed_thenItIsUsedDirectly() {
+ assertTrue(parseToolBoolean(true))
+ assertFalse(parseToolBoolean(false))
+ }
+
+ @Test
+ fun givenTheStringsAModelEmits_whenParsed_thenTheyReadAsTrue() {
+ listOf("true", "TRUE", "True", " true ", "yes", "YES", "1").forEach {
+ assertTrue("'$it' should read as true", parseToolBoolean(it))
+ }
+ }
+
+ @Test
+ fun givenAnythingElse_whenParsed_thenItIsFalse() {
+ listOf(null, "", " ", "false", "no", "0", "maybe", "2", "on").forEach {
+ assertFalse("'$it' should read as false", parseToolBoolean(it))
+ }
+ }
+
+ @Test
+ fun givenATurkishDefaultLocale_whenParsingAnUppercaseTrue_thenItStillReadsAsTrue() {
+ // The no-argument lowercase() is locale-independent; a locale-aware one drops the flag.
+ val original = Locale.getDefault()
+ try {
+ Locale.setDefault(Locale.forLanguageTag("tr-TR"))
+ assertTrue(parseToolBoolean("TRUE"))
+ } finally {
+ Locale.setDefault(original)
+ }
+ }
+}
diff --git a/ai-assistant/src/test/kotlin/com/itsaky/androidide/plugins/aiassistant/viewmodel/AgentReplyRendererTest.kt b/ai-assistant/src/test/kotlin/com/itsaky/androidide/plugins/aiassistant/viewmodel/AgentReplyRendererTest.kt
new file mode 100644
index 00000000..bc6a6c26
--- /dev/null
+++ b/ai-assistant/src/test/kotlin/com/itsaky/androidide/plugins/aiassistant/viewmodel/AgentReplyRendererTest.kt
@@ -0,0 +1,127 @@
+package com.itsaky.androidide.plugins.aiassistant.viewmodel
+
+import com.itsaky.androidide.plugins.aiassistant.tool.ToolCall
+import org.junit.Assert.assertEquals
+import org.junit.Test
+
+/**
+ * Unit tests for [AgentReplyRenderer]. The bug these pin down was reported on Gemini: an `edit_file`
+ * succeeded, then the last message read "(No response…)" because the terminal `respond` call was read
+ * for a `message` key alone. A finished answer must not be thrown away over its key.
+ */
+class AgentReplyRendererTest {
+
+ private companion object {
+ const val TERMINAL = "respond"
+ const val FAILED = "(action failed)"
+ const val NO_RESPONSE = "(no response)"
+ }
+
+ private fun render(
+ rawText: String,
+ toolCalls: List = emptyList(),
+ lastToolFailed: Boolean = false,
+ ) = AgentReplyRenderer.render(
+ rawText = rawText,
+ toolCalls = toolCalls,
+ terminalTool = TERMINAL,
+ lastToolFailed = lastToolFailed,
+ actionFailedText = FAILED,
+ noResponseText = NO_RESPONSE,
+ ) { call -> "🔧 ${call.name}" }
+
+ private fun respond(vararg args: Pair) =
+ listOf(ToolCall(TERMINAL, mapOf(*args)))
+
+ @Test
+ fun givenARespondCallWithAMessage_whenRendered_thenTheMessageIsShown() {
+ val text = render("…", respond("message" to "All done."))
+
+ assertEquals("All done.", text)
+ }
+
+ @Test
+ fun givenARespondCallUnderAnAlternateKey_whenRendered_thenTheAnswerIsStillShown() {
+ // Neither system prompt documents these, but models substitute them anyway.
+ listOf("text", "response", "answer", "content").forEach { key ->
+ assertEquals(
+ "'$key' must be read as the answer",
+ "All done.",
+ render("…", respond(key to "All done.")),
+ )
+ }
+ }
+
+ @Test
+ fun givenBothMessageAndAnAlternateKey_whenRendered_thenMessageWins() {
+ val text = render("x", respond("text" to "second", "message" to "first"))
+
+ assertEquals("first", text)
+ }
+
+ @Test
+ fun givenAnEmptyRespondCallBesideProse_whenRendered_thenTheProseIsShown() {
+ // The exact Gemini shape: the summary is written as prose and the envelope is empty.
+ val raw = "I renamed count to itemCount in MainActivity.java.\n" +
+ "{\"tool\":\"respond\",\"args\":{}}"
+
+ val text = render(raw, respond())
+
+ assertEquals("I renamed count to itemCount in MainActivity.java.", text)
+ }
+
+ @Test
+ fun givenABlankRespondMessageBesideProse_whenRendered_thenTheProseIsShown() {
+ val raw = "Done — the file now compiles.\n{\"tool\":\"respond\"}"
+
+ val text = render(raw, respond("message" to " "))
+
+ assertEquals("Done — the file now compiles.", text)
+ }
+
+ @Test
+ fun givenAnEmptyRespondCallAndNoProse_whenRendered_thenTheFallbackIsShown() {
+ val text = render("{\"tool\":\"respond\",\"args\":{}}", respond())
+
+ assertEquals(NO_RESPONSE, text)
+ }
+
+ @Test
+ fun givenAnEmptyRespondCallAndOnlyBareJsonBeside_whenRendered_thenRawJsonIsNotShown() {
+ // Showing the user an untagged tool call is worse than showing nothing.
+ val raw = "{\"tool\":\"read_file\",\"args\":{\"file_path\":\"A.kt\"}}\n" +
+ "{\"tool\":\"respond\",\"args\":{}}"
+
+ val text = render(raw, respond())
+
+ assertEquals(NO_RESPONSE, text)
+ }
+
+ @Test
+ fun givenARespondCallAfterAFailedTool_whenRendered_thenTheFailureWinsOverTheClaim() {
+ // The model asserting success after a tool failed must not be relayed as success.
+ val text = render("x", respond("message" to "Done!"), lastToolFailed = true)
+
+ assertEquals(FAILED, text)
+ }
+
+ @Test
+ fun givenRealToolCalls_whenRendered_thenBadgesAreShown() {
+ val text = render(
+ "x",
+ listOf(ToolCall("read_file", mapOf("file_path" to "A.kt")), ToolCall("edit_file", emptyMap())),
+ )
+
+ assertEquals("🔧 read_file\n🔧 edit_file", text)
+ }
+
+ @Test
+ fun givenPlainProseAndNoToolCalls_whenRendered_thenTheProseIsShown() {
+ assertEquals("Hi! What would you like to build?", render("Hi! What would you like to build?"))
+ }
+
+ @Test
+ fun givenNothingAtAll_whenRendered_thenTheFallbackIsShown() {
+ assertEquals(NO_RESPONSE, render(" "))
+ }
+}
diff --git a/ai-core/build.gradle.kts b/ai-core/build.gradle.kts
index 43089d40..0f532019 100644
--- a/ai-core/build.gradle.kts
+++ b/ai-core/build.gradle.kts
@@ -16,8 +16,8 @@ android {
applicationId = "com.itsaky.androidide.plugins.aicore"
minSdk = 33
targetSdk = 34
- versionCode = 1
- versionName = "1.0.0"
+ versionCode = 2
+ versionName = "1.1.0"
}
buildTypes {