diff --git a/plugins/AI-Agent-Claude/README.md b/plugins/AI-Agent-Claude/README.md
index 22657b28..a9943b25 100644
--- a/plugins/AI-Agent-Claude/README.md
+++ b/plugins/AI-Agent-Claude/README.md
@@ -32,8 +32,8 @@ cd plugins/AI-Agent-Claude
## Configuration
-Everything is configured in **AI Core → Agent settings**, on the pane this plugin
-contributes: API key, model, and one **Test Connection & List Models** button.
+Everything is configured in **Preferences → Configuration → Agent**, on the pane
+this plugin contributes: API key, model, and one **Test Connection & List Models** button.
Nothing outside this plugin handles the key. Listing models and testing the key
are the same `GET /v1/models`, so they are one control, and the model is a
single editable dropdown: type any id, or pick one the key can use.
@@ -121,6 +121,48 @@ register with. Order does not matter: this plugin re-registers when it sees
ai-core activate. Copy `build/plugin/ai-agent-claude.cgp` to the device, install
via CodeOnTheGo's Plugin Manager, then restart the IDE.
+## System prompt config
+
+The prompt Claude asks ai-core to send lives in `src/main/assets/prompts/`, one YAML
+file per concern, apart from the code that sends it. Changing the tone, adding a
+rule or translating the prompt is an edit to those files alone. ai-core appends its
+own IDE CONTEXT block after the rendered prompt.
+
+The files are loaded, validated and cached once, when the plugin is activated.
+`getSystemPrompt` renders `layout.yml` from that cache for each request, since the
+tool list, the protocol and the example path vary per run; it never waits. Until the
+config has loaded, or if it cannot render, it returns null and ai-core sends its own
+default prompt.
+
+| File | Keys | What it is |
+|---|---|---|
+| `agent.yml` | `schema_version`, `identity`, `include` | The entry point: the version (`1`; another is refused rather than misread), who the agent is, and the files below. |
+| `scope.yml` | `scope` | What the agent will answer: anything, with the project's tools only when the request is about the open project. |
+| `rules.yml` | `rules` | Priority groups, highest first; each has a `heading` (`CRITICAL`, `IMPORTANT`, `MANDATORY`, `OPTIONAL`) and its `items`. **Adding a rule is adding an item.** |
+| `workflow.yml` | `behavior`, `workflow` | How to go about building or changing something; the workflow's `steps` are numbered when rendered. |
+| `tools.yml` | `tools`, `tool_call_format` | What introduces the tool list, and how to call a tool: `native` under the function-calling API, `text` (with its examples) when calls travel in the reply. Exactly one is sent. |
+| `layout.yml` | `layout.system_prompt` | Where each text goes. |
+
+Loading and checking follow ai-core's rules (see ai-core's README): a key belongs to
+one file, only `agent.yml` includes, and a missing, unknown, misspelled or duplicate
+key, an empty list or an unquoted number is refused naming the file and path, e.g.
+`rules.yml: rules[1].items is empty`. Texts are named by their YAML path in upper
+case (`scope.heading` is `SCOPE_HEADING`); each rule group has `HEADING` and `ITEMS`,
+each item and step has `TEXT`, each step has `NUMBER`, and each example has `PURPOSE`
+and `CALL`. The request's values are `TOOLS` (each with `NAME`, `DESCRIPTION`,
+inserted verbatim), `TOOL_CALL_SYNTAX` (null under native calling),
+`NATIVE_TOOL_CALLS`, `EXAMPLE_FILE_PATH` and `EXAMPLE_FILE_STEM`.
+
+Rendering is strict: an unknown name throws, naming the text it was in. Activation
+renders the prompt for requests that open and close every section and logs any
+failure, and `ClaudeSystemPromptTest` fails on one in the shipped files. A new key
+needs `ClaudePromptConfig` and its parser; a new name needs `ClaudePromptVariables`.
+
+The engine and the YAML plumbing (`PromptTemplateEngine`, `PromptConfigLoader`,
+`PromptConfigStore`, `PromptConfigObject`, ...) are the IDE's, in `plugin-api.jar`'s
+`com.itsaky.androidide.plugins.ai.prompt`, shared with ai-core and the other backends.
+Only `ClaudePromptConfig`, its mapping in `ClaudePromptConfigParser`, and `sharedPromptConfig` are this plugin's own.
+
## Key classes
- `plugin/ClaudePlugin.kt` — entry point; registers the backend with ai-core
@@ -134,7 +176,9 @@ via CodeOnTheGo's Plugin Manager, then restart the IDE.
- `backend/ClaudeModelCatalog.kt` — reads `GET /v1/models` (pure)
- `backend/ClaudeHttpClient.kt` — sockets, headers and timeouts
- `errors/ClaudeErrorFormatter.kt` — turns a failure into one translated sentence
-- `prompt/ClaudeSystemPrompt.kt` — the system prompt this cloud model is given
+- `prompt/ClaudeSystemPrompt.kt` — renders `layout.yml` from `ClaudePromptVariables`;
+ `prompt/config/` maps `assets/prompts/` onto this plugin's config type, which the
+ IDE's `ai.prompt` package loads, validates, caches and renders
- `settings/` — the pane this backend contributes to the selector
- `logging/` — `LOG_PREFIX` (`AiAgentClaude`), prefixing every logcat tag
diff --git a/plugins/AI-Agent-Claude/ai-agent-claude.html b/plugins/AI-Agent-Claude/ai-agent-claude.html
index ecdedf69..be9d355b 100644
--- a/plugins/AI-Agent-Claude/ai-agent-claude.html
+++ b/plugins/AI-Agent-Claude/ai-agent-claude.html
@@ -66,17 +66,40 @@
Technical architecture
calls, per-model parameters, retries and error classification.
The API key is encrypted with the IDE's KeystoreSecretStore
under this plugin's own alias.
+ The system prompt lives in assets/prompts/, one YAML file per
+ concern, so its wording changes without touching code. It is loaded and
+ checked once on activation; if it cannot load or render, AI Core's default
+ prompt is sent instead.
+ Registration with AI Core goes through the IDE's
+ LlmBackendRegistration, which re-registers when AI Core restarts
+ and tells the chat when the key or model changes, so its backend tag names
+ the model in use.
Usage
- Install AI Core and this plugin, then restart the IDE.
- - Open AI Core → Agent settings and select Claude.
+ - Open Preferences → Configuration → Agent and select
+ Claude. This plugin's own pane appears below it.
- Tap Get API Key, create a key in the Claude Console, paste it and
tap Save Key.
- Optionally pick a model, then start a chat.
+ Key benefits
+
+ - Frontier models on any device — Claude runs in Anthropic's cloud,
+ so a phone that could not run a large model locally can still use one.
+ - Reliable tool use — the Agent's tools are declared to Claude
+ natively, so edits arrive as structured calls rather than text that has to
+ be parsed.
+ - Small footprint — no bundled model or native library.
+ - Credential hygiene — the key is checked before it is saved,
+ encrypted at rest, and dropped from memory when the plugin unloads.
+ - Coexists with the other backends — install it beside AI Agent Local,
+ Gemini or OpenAI and switch between them in Preferences → Configuration → Agent.
+
+
Cost
The Claude API is billed to prepaid credit, separate from a Claude.ai
subscription. The free alternatives are AI Agent Local and AI Agent
diff --git a/plugins/AI-Agent-Claude/build.gradle.kts b/plugins/AI-Agent-Claude/build.gradle.kts
index 5dc9c0ea..bd580566 100644
--- a/plugins/AI-Agent-Claude/build.gradle.kts
+++ b/plugins/AI-Agent-Claude/build.gradle.kts
@@ -65,6 +65,8 @@ dependencies {
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.7.3")
testImplementation(files("../../libs/plugin-api.jar"))
+ // plugin-api's prompt loader parses YAML with the host's copy; JVM tests need their own, same version
+ testImplementation("org.snakeyaml:snakeyaml-engine:2.10")
testImplementation("junit:junit:4.13.2")
testImplementation("io.mockk:mockk:1.13.8")
testImplementation("org.json:json:20231013")
@@ -78,3 +80,8 @@ tasks.matching {
it.name.contains("checkDebugAarMetadata") ||
it.name.contains("checkReleaseAarMetadata")
}.configureEach { enabled = false }
+
+// The prompt tests read src/main/assets/prompts from disk; declared, so a YAML-only edit reruns them.
+tasks.withType().configureEach {
+ inputs.dir("src/main/assets/prompts").withPropertyName("shippedPrompts")
+}
diff --git a/plugins/AI-Agent-Claude/src/main/AndroidManifest.xml b/plugins/AI-Agent-Claude/src/main/AndroidManifest.xml
index 2c1f2e6d..accf1369 100644
--- a/plugins/AI-Agent-Claude/src/main/AndroidManifest.xml
+++ b/plugins/AI-Agent-Claude/src/main/AndroidManifest.xml
@@ -39,12 +39,12 @@
android:name="plugin.author"
android:value="App Dev for All" />
-
+
+ android:value="26.41" />
-
+ You are the coding assistant built into CodeOnTheGo, an Android IDE that runs on the user's
+ phone or tablet. Most requests you get are about the Android project that is open, and you have
+ tools for it — but you are a general assistant first.
+
+include:
+ - scope.yml
+ - rules.yml
+ - workflow.yml
+ - tools.yml
+ - layout.yml
diff --git a/plugins/AI-Agent-Claude/src/main/assets/prompts/layout.yml b/plugins/AI-Agent-Claude/src/main/assets/prompts/layout.yml
new file mode 100644
index 00000000..7db7aeda
--- /dev/null
+++ b/plugins/AI-Agent-Claude/src/main/assets/prompts/layout.yml
@@ -0,0 +1,55 @@
+# Where each text from the other files goes, by the name it is rendered under (see README.md).
+# A line holding only a section tag (#, ^ or /) vanishes, so tags can sit on their own lines.
+
+layout:
+ system_prompt: |-
+ {{IDENTITY}}
+
+ {{SCOPE_HEADING}}:
+ {{#SCOPE_ITEMS}}
+ - {{TEXT}}
+ {{/SCOPE_ITEMS}}
+
+ {{TOOLS_HEADING}}:
+ {{#TOOLS}}
+ - {{NAME}}: {{DESCRIPTION}}
+ {{/TOOLS}}
+
+ {{BEHAVIOR_HEADING}}:
+ {{#BEHAVIOR_ITEMS}}
+ - {{TEXT}}
+ {{/BEHAVIOR_ITEMS}}
+
+ {{#RULES}}
+ {{^FIRST}}
+
+ {{/FIRST}}
+ {{HEADING}}:
+ {{#ITEMS}}
+ - {{TEXT}}
+ {{/ITEMS}}
+ {{/RULES}}
+ {{#NATIVE_TOOL_CALLS}}
+
+ {{TOOL_CALL_FORMAT_NATIVE}}
+ {{TOOL_CALL_FORMAT_NO_NARRATION}}
+ {{/NATIVE_TOOL_CALLS}}
+ {{#TOOL_CALL_SYNTAX}}
+
+ {{TOOL_CALL_FORMAT_TEXT_INSTRUCTION}}
+ {{TOOL_CALL_SYNTAX}}
+ {{TOOL_CALL_FORMAT_NO_NARRATION}}
+ {{TOOL_CALL_FORMAT_TEXT_ONLY_THE_LINE_RUNS}}
+
+ {{TOOL_CALL_FORMAT_TEXT_EXAMPLES_HEADING}}:
+ {{#TOOL_CALL_FORMAT_TEXT_EXAMPLES}}
+ {{PURPOSE}}:
+ {{CALL}}
+ {{/TOOL_CALL_FORMAT_TEXT_EXAMPLES}}
+ {{/TOOL_CALL_SYNTAX}}
+
+ {{WORKFLOW_HEADING}}:
+ {{#WORKFLOW_STEPS}}
+ {{NUMBER}}. {{TEXT}}
+ {{/WORKFLOW_STEPS}}
+ {{WORKFLOW_CLOSING}}
diff --git a/plugins/AI-Agent-Claude/src/main/assets/prompts/rules.yml b/plugins/AI-Agent-Claude/src/main/assets/prompts/rules.yml
new file mode 100644
index 00000000..d26139f0
--- /dev/null
+++ b/plugins/AI-Agent-Claude/src/main/assets/prompts/rules.yml
@@ -0,0 +1,48 @@
+# What the agent must and must not do, highest priority first. Adding a rule is adding an item.
+# Each group renders as "HEADING:" with its items as "- " lines.
+
+rules:
+ - heading: CRITICAL
+ items:
+ - >-
+ When you call a tool, emit ONE 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.
+ - >-
+ Never fabricate tool output. Emit a tool call, then wait for the real result before
+ continuing.
+ - >-
+ 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.
+ - heading: IMPORTANT
+ items:
+ - >-
+ 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.
+ - heading: MANDATORY
+ items:
+ - >-
+ 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.
+ - >-
+ 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.
+ - >-
+ A greeting, or a question you can answer without reading the project or checking a claim
+ on the web, is answered in the reply itself, with no tool call — briefly for small talk, in
+ full for a real question. Once you have called any tool, the task ends only with a single respond call
+ carrying your summary in its "message" — never an empty respond. A reply without a tool
+ call does not finish it.
diff --git a/plugins/AI-Agent-Claude/src/main/assets/prompts/scope.yml b/plugins/AI-Agent-Claude/src/main/assets/prompts/scope.yml
new file mode 100644
index 00000000..838da7f0
--- /dev/null
+++ b/plugins/AI-Agent-Claude/src/main/assets/prompts/scope.yml
@@ -0,0 +1,63 @@
+# What the agent will answer, and how it makes sure the answer is right. Each item renders as a
+# "- " line under "HEADING:". Principles only: an example named here gets pattern-matched rather
+# than understood, and the next defect is always a different one (ADFA-6223).
+
+scope:
+ heading: SCOPE
+ items:
+ - >-
+ Answer whatever the user asks. A question about another language, another platform, a
+ general programming concept, or something that is not about code at all is an ordinary
+ request: answer it directly and well.
+ - >-
+ Never decline a request on the grounds that it is not about Android, not about this
+ project, or not about code. You have no such restriction.
+ - >-
+ Reach for a project tool only when the request is about the open project's files.
+ # Confidence is the model's signal for searching, and it is highest exactly where the world
+ # has moved on since training; so the trigger is the kind of claim, not how sure it feels.
+ - >-
+ Your knowledge stops at a cutoff, and today's date is stated below. A claim that can stop
+ being true over time — whether a library, API or tool is current, deprecated or removed,
+ what replaced it, its latest version, the recommended way to use it — must be checked
+ before you make it, whenever web_search is among your tools. Judging code is such a claim:
+ calling code correct, current or good practice asserts that everything it uses still is.
+ Feeling sure is not checking. Search each claim on its own, naming exactly what you are
+ checking. If the results leave it open, search more precisely or read the primary source
+ with fetch_url; if it is still open, say what you could not verify.
+ - >-
+ The user never sees tool results, only your replies. State every fact you took from a
+ search or a page in the reply itself, with the link it came from next to it.
+ - >-
+ A request to review, analyze or examine code asks what is wrong with it. Check the code as
+ given before anything else: whether it compiles as written, whether what it uses is current,
+ and whether every path through it does what its author meant. Lead with the findings, each
+ with its evidence, before anything the code does well.
+ - >-
+ When you propose changed code, every difference from the original is a finding: state what
+ you changed and why, including an added import, annotation, opt-in or dependency. Your
+ version fixes every finding and never carries forward anything you found to be wrong.
+ # The self-check. Each item is a way of reasoning about code, not a list of known bugs.
+ - >-
+ Before you send code, check it as hard as you checked the user's. Trace every branch and
+ state to the concrete situations that reach it; if situations that need different behavior
+ reach the same branch, the code is wrong until you add what tells them apart.
+ - >-
+ Every operation in your code must be valid for every value its inputs can hold. Where it is
+ valid for only some, narrow what the code accepts or handle the rest — never assume.
+ - >-
+ Use each API the way its own documentation intends, and prefer what a library or platform
+ already provides over reimplementing it by hand.
+ - >-
+ Never hedge inside code — a fallback control, a comment or label saying "if this applies".
+ Hedging means a question is still open: resolve it, and if you cannot, say so in prose.
+ - >-
+ Code you send is complete: every import, annotation and opt-in it needs is present, and
+ every dependency version comes from a search result or is marked as unverified.
+ - >-
+ When a request has several parts (research, design, code), deliver every part. Do not stop
+ after one part to announce the next or to ask whether to proceed.
+ - >-
+ Say you cannot do something only when you genuinely cannot — you have no tool for it, it
+ needs information you do not have, or it is something you should not do. Say which, and say
+ what you can do instead. Never ask the user to do what one of your tools can do.
diff --git a/plugins/AI-Agent-Claude/src/main/assets/prompts/tools.yml b/plugins/AI-Agent-Claude/src/main/assets/prompts/tools.yml
new file mode 100644
index 00000000..5bf1c64d
--- /dev/null
+++ b/plugins/AI-Agent-Claude/src/main/assets/prompts/tools.yml
@@ -0,0 +1,39 @@
+# How the tool list is introduced, and how to call a tool. Exactly one of the two formats is sent:
+# native under the function-calling API, text when calls travel in the reply.
+
+tools:
+ heading: AVAILABLE TOOLS
+
+tool_call_format:
+ # Sent under either format, after the format's own instruction.
+ no_narration: >-
+ Do NOT describe the action in prose (e.g. "Okay, I'll open the file…") — narrating does
+ nothing.
+ # Its line breaks are sent as written.
+ native: |-
+ TOOL CALL FORMAT — the tools above are declared to you: call one through the function-calling
+ API. A call written into your reply text is NOT read by this system and will not run.
+ text:
+ instruction: >-
+ TOOL CALL FORMAT — to run a tool, emit a single line in EXACTLY this format and nothing
+ after it:
+ # Claude takes tools natively, so the text format also has to rule that channel out.
+ only_the_line_runs: >-
+ Do NOT use your provider's native function-calling channel either; a structured tool call is
+ not read by this system. The tool only runs when you emit the tool call line itself.
+ # Its line breaks are sent as written.
+ examples_heading: |-
+ 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)
+ # Each renders as "PURPOSE:" followed by the call on its own line.
+ examples:
+ - purpose: Report the finished task (the summary goes in "message")
+ call: '{"tool":"respond","args":{"message":"Renamed count to itemCount."}}'
+ - purpose: Open a file once you know its path
+ call: '{"tool":"open_file","args":{"file_path":"{{EXAMPLE_FILE_PATH}}"}}'
+ - purpose: Find a file by name
+ call: '{"tool":"search_project","args":{"query":"{{EXAMPLE_FILE_STEM}}"}}'
+ - purpose: List the project's top-level files (an empty directory means the project root)
+ call: '{"tool":"list_files","args":{"directory":""}}'
+ - purpose: Change part of a file (line breaks inside a value MUST be written as \n)
+ call: '{"tool":"edit_file","args":{"file_path":"{{EXAMPLE_FILE_PATH}}","old_string":"count = 0","new_string":"count = 1"}}'
diff --git a/plugins/AI-Agent-Claude/src/main/assets/prompts/workflow.yml b/plugins/AI-Agent-Claude/src/main/assets/prompts/workflow.yml
new file mode 100644
index 00000000..a3a9a6e9
--- /dev/null
+++ b/plugins/AI-Agent-Claude/src/main/assets/prompts/workflow.yml
@@ -0,0 +1,32 @@
+# How the agent goes about building or changing something in the project; neither applies to a
+# question, which is answered directly.
+
+behavior:
+ heading: BEHAVIOR — for a request to build or change something in this project
+ items:
+ - Create complete, production-ready code
+ - Call tools proactively to build, test, and verify your work
+ - Read files to understand project structure before making changes
+ - After each file modification, verify the build compiles
+ - Generate apps that actually run and work as described
+
+# Numbered in order when rendered.
+workflow:
+ heading: >-
+ WORKFLOW — follow these steps only when the user tells you to build or change something in
+ this project
+ steps:
+ - Understand the user's request
+ - >-
+ Locate what you need with ONE search_project call — the IDE CONTEXT block below already
+ names the source, layout and manifest paths
+ - Create/modify files with complete implementations
+ - Add dependencies if needed
+ - Sync gradle and verify compilation
+ - Run the app to confirm it works
+ - Report success and what was built
+ closing: >-
+ Skip every one of those steps when the user is asking a question, asking for an explanation,
+ asking about anything other than the open project, or asking you to design, implement or write
+ code without telling you to add it to their project or app — answer directly instead, with the
+ complete code in your reply, and offer to add it to the project.
diff --git a/plugins/AI-Agent-Claude/src/main/kotlin/com/itsaky/androidide/plugins/aiagentclaude/backend/ClaudeBackend.kt b/plugins/AI-Agent-Claude/src/main/kotlin/com/itsaky/androidide/plugins/aiagentclaude/backend/ClaudeBackend.kt
index a760a9fa..06446634 100644
--- a/plugins/AI-Agent-Claude/src/main/kotlin/com/itsaky/androidide/plugins/aiagentclaude/backend/ClaudeBackend.kt
+++ b/plugins/AI-Agent-Claude/src/main/kotlin/com/itsaky/androidide/plugins/aiagentclaude/backend/ClaudeBackend.kt
@@ -14,6 +14,7 @@ import com.itsaky.androidide.plugins.aiagentclaude.errors.isCredentialProblem
import com.itsaky.androidide.plugins.aiagentclaude.logging.LOG_PREFIX
import com.itsaky.androidide.plugins.aiagentclaude.preferences.ClaudePreferences
import com.itsaky.androidide.plugins.aiagentclaude.prompt.ClaudeSystemPrompt
+import com.itsaky.androidide.plugins.aiagentclaude.prompt.config.ClaudePromptConfig
import com.itsaky.androidide.plugins.aiagentclaude.security.ApiKeyCache
import com.itsaky.androidide.plugins.services.LlmInferenceService.*
import java.io.BufferedReader
@@ -52,10 +53,15 @@ private const val TAG = "$LOG_PREFIX.AgentTrace"
*
* Not an [EmbeddingBackend]: Anthropic offers no embeddings endpoint, and a backend that claimed
* one would leave semantic search failing on every index build.
+ *
+ * @param context this plugin's context
+ * @param promptConfig the loaded prompt config, or null while it loads; must return without blocking
*/
class ClaudeBackend(
- private val context: PluginContext
-) : HistoryCapableBackend, CancellableBackend, ConfigurableBackend, ToolCallingBackend {
+ private val context: PluginContext,
+ private val promptConfig: () -> ClaudePromptConfig?,
+) : HistoryCapableBackend, CancellableBackend, ConfigurableBackend, ToolCallingBackend,
+ ActiveModelReportingBackend {
private val scope = CoroutineScope(Dispatchers.IO)
@@ -154,11 +160,30 @@ class ClaudeBackend(
}
/**
- * Written for a large cloud model; see [ClaudeSystemPrompt] for why the wording belongs here
- * rather than with the caller.
+ * The chat model requests go to, for the Agent's backend tag. Read from preferences like every
+ * request, so it is never stale; the plugin reports a change through `notifyBackendChanged`.
+ */
+ override fun getActiveModelName(): String = getModelName()
+
+ /**
+ * Written for a large cloud model; see [ClaudeSystemPrompt] for why the wording belongs here.
+ * Null until the config is loaded or when it cannot render, which ai-core
+ * answers with its default prompt; never blocks, since the caller's thread is ai-core's.
*/
- override fun getSystemPrompt(request: SystemPromptRequest): String =
- ClaudeSystemPrompt.build(request)
+ override fun getSystemPrompt(request: SystemPromptRequest): String? {
+ val config = promptConfig()
+ if (config == null) {
+ context.logger.warn("ClaudeBackend: prompt config not loaded; ai-core default used")
+ return null
+ }
+ return try {
+ ClaudeSystemPrompt.build(request, config)
+ } catch (e: Exception) {
+ // Not just the engine's IllegalArgumentException: any failure here would end ai-core's turn.
+ context.logger.error("ClaudeBackend: prompt did not render; ai-core default used", e)
+ null
+ }
+ }
/** Null: this backend sends no `temperature`, which current Claude models reject outright. */
override fun getDefaultTemperature(): Float? = null
diff --git a/plugins/AI-Agent-Claude/src/main/kotlin/com/itsaky/androidide/plugins/aiagentclaude/plugin/ClaudePlugin.kt b/plugins/AI-Agent-Claude/src/main/kotlin/com/itsaky/androidide/plugins/aiagentclaude/plugin/ClaudePlugin.kt
index 1b0b6c08..7fe9f4fc 100644
--- a/plugins/AI-Agent-Claude/src/main/kotlin/com/itsaky/androidide/plugins/aiagentclaude/plugin/ClaudePlugin.kt
+++ b/plugins/AI-Agent-Claude/src/main/kotlin/com/itsaky/androidide/plugins/aiagentclaude/plugin/ClaudePlugin.kt
@@ -2,13 +2,16 @@ package com.itsaky.androidide.plugins.aiagentclaude.plugin
import com.itsaky.androidide.plugins.IPlugin
import com.itsaky.androidide.plugins.PluginContext
-import com.itsaky.androidide.plugins.PluginLifecycleListener
+import com.itsaky.androidide.plugins.ai.LlmBackendRegistration
+import com.itsaky.androidide.plugins.ai.prompt.AssetPromptConfigSource
import com.itsaky.androidide.plugins.aiagentclaude.backend.ClaudeBackend
+import com.itsaky.androidide.plugins.aiagentclaude.preferences.ClaudePreferences
+import com.itsaky.androidide.plugins.aiagentclaude.prompt.ClaudeSystemPrompt
+import com.itsaky.androidide.plugins.aiagentclaude.prompt.config.ClaudePromptConfig
+import com.itsaky.androidide.plugins.aiagentclaude.prompt.config.sharedPromptConfig
import com.itsaky.androidide.plugins.extensions.DocumentationExtension
import com.itsaky.androidide.plugins.extensions.PluginTooltipButton
import com.itsaky.androidide.plugins.extensions.PluginTooltipEntry
-import com.itsaky.androidide.plugins.services.LlmInferenceService
-import com.itsaky.androidide.plugins.services.SharedServices
/**
* Registers the Claude backend with AI Core's inference router.
@@ -20,18 +23,22 @@ import com.itsaky.androidide.plugins.services.SharedServices
class ClaudePlugin : IPlugin, DocumentationExtension {
private lateinit var context: PluginContext
- private var backend: ClaudeBackend? = null
- /** True once [backend] is registered with the router, so re-registration is idempotent. */
- @Volatile private var registered = false
+ /** The live backend, from [activate] until [deactivate] releases it. */
+ @Volatile private var backend: ClaudeBackend? = null
+
+ /** Keeps [backend] registered with AI Core across its restarts, and reports setting changes. */
+ private lateinit var registration: LlmBackendRegistration
companion object {
const val PLUGIN_ID = "com.itsaky.androidide.plugins.aiagentclaude"
- /** Provider of [LlmInferenceService]; this plugin is useless without it. */
- private const val AI_CORE_PLUGIN_ID = "com.itsaky.androidide.plugins.aicore"
-
- private const val TOOLTIP_TAG_PLUGIN = "plugin_ai_backend_claude"
+ /**
+ * The whole-plugin entry, and the only one carrying the Tier-3 guide button. Anchored to
+ * the key status line on this backend's settings pane — the one element this plugin always
+ * draws, and an entry no element long-presses is an entry nobody can read.
+ */
+ const val TOOLTIP_TAG_PLUGIN = "plugin_ai_agent_claude"
/**
* Category the host registers this plugin's tooltips under. Must be `"plugin_"` + the full
@@ -46,6 +53,9 @@ class ClaudePlugin : IPlugin, DocumentationExtension {
const val TOOLTIP_TAG_SETTINGS_TEST = "ai_claude_test_connection"
const val TOOLTIP_TAG_SETTINGS_GET_KEY = "ai_claude_get_key"
+ /** The settings that change what [ClaudeBackend.isAvailable] or its model name answers. */
+ private val WATCHED_KEYS = setOf(ClaudePreferences.KEY_API_KEY, ClaudePreferences.KEY_MODEL)
+
@Volatile
private var pluginContext: PluginContext? = null
@@ -62,31 +72,16 @@ class ClaudePlugin : IPlugin, DocumentationExtension {
fun getBackend(): ClaudeBackend? = activeBackend
}
- /**
- * Re-registers when AI Core activates. Plugins load in parallel with no ordering, so
- * [activate] may run before AI Core has published its service; this closes that race instead
- * of polling for it.
- */
- private val aiCoreLifecycle = object : PluginLifecycleListener {
- override fun onPluginActivated(pluginId: String) {
- if (pluginId == AI_CORE_PLUGIN_ID) registerBackend()
- }
-
- override fun onPluginDeactivated(pluginId: String) {
- // The router went away and took the registration with it; allow a fresh one.
- if (pluginId == AI_CORE_PLUGIN_ID) registered = false
- }
-
- override fun onPluginUninstalled(pluginId: String) {
- if (pluginId == AI_CORE_PLUGIN_ID) registered = false
- }
- }
-
override fun initialize(context: PluginContext): Boolean {
return try {
this.context = context
// Published for the settings pane, which the hosting screen constructs directly.
pluginContext = context
+ registration = LlmBackendRegistration(
+ context = context,
+ preferences = { ClaudePreferences.of(context) },
+ watchedKeys = WATCHED_KEYS,
+ )
context.logger.info("ClaudePlugin: Plugin initialized successfully")
true
} catch (e: Exception) {
@@ -101,22 +96,15 @@ class ClaudePlugin : IPlugin, DocumentationExtension {
return try {
// A half-failed activation can leave a backend behind; keep at most one live.
releaseBackend()
+ preloadPromptConfig()
- val claude = ClaudeBackend(context)
+ val claude = ClaudeBackend(context, sharedPromptConfig::configIfLoaded)
backend = claude
activeBackend = claude
// Decrypt the key off-thread now, so a main-thread isAvailable() can't say "no key".
claude.warmKeyCache()
-
- // Listen first, then try: a listener added after a successful attempt would still be
- // needed for a later AI Core restart, and one added before costs nothing.
- context.addPluginLifecycleListener(aiCoreLifecycle)
- if (!registerBackend()) {
- context.logger.info(
- "ClaudePlugin: AI Core is not active yet; will register when it activates"
- )
- }
+ registration.start(claude)
true
} catch (e: Exception) {
@@ -125,59 +113,36 @@ class ClaudePlugin : IPlugin, DocumentationExtension {
}
}
- /**
- * Registers the backend with AI Core's router, if the router is reachable.
- *
- * @return true when the backend is registered (now or already), false when AI Core is absent
- */
- private fun registerBackend(): Boolean {
- if (registered) return true
- val claude = backend ?: return false
-
- val service = resolveInferenceService()
- if (service == null) {
- context.logger.debug("ClaudePlugin: LlmInferenceService not available yet")
- return false
- }
-
- return try {
- service.registerBackend(claude)
- registered = true
- context.logger.info("ClaudePlugin: Registered '${claude.getId()}' backend with AI Core")
- true
- } catch (e: Exception) {
- context.logger.error("ClaudePlugin: Could not register the Claude backend", e)
- false
+ /** Reads and validates the prompt config now, so building a prompt does no disk I/O. */
+ private fun preloadPromptConfig() {
+ val source = AssetPromptConfigSource(context.androidContext.assets)
+ sharedPromptConfig.reload(source, ::reportLoadedConfig) { error ->
+ context.logger.error(
+ "ClaudePlugin: prompt config failed to load; ai-core's default prompt is sent instead",
+ error,
+ )
}
}
/**
- * Resolves AI Core's router, preferring the process-global registry and falling back to the
- * provider-scoped lookup so a registry cleared by another plugin is not fatal.
+ * Logs that the config loaded, and any name typo its layout would hit at render time.
+ *
+ * @param config the config just loaded.
*/
- private fun resolveInferenceService(): LlmInferenceService? = try {
- SharedServices.get(LlmInferenceService::class.java)
- ?: context.getPluginService(AI_CORE_PLUGIN_ID, LlmInferenceService::class.java)
- } catch (e: Exception) {
- context.logger.warn("ClaudePlugin: Could not resolve LlmInferenceService: ${e.message}")
- null
+ private fun reportLoadedConfig(config: ClaudePromptConfig) {
+ context.logger.info("ClaudePlugin: loaded prompt config with ${config.rules.size} rule groups")
+ for (problem in ClaudeSystemPrompt.problems(config)) {
+ context.logger.warn("ClaudePlugin: $problem; ai-core's default prompt is sent instead")
+ }
}
override fun deactivate(): Boolean {
context.logger.info("ClaudePlugin: Deactivating plugin")
return try {
- context.removePluginLifecycleListener(aiCoreLifecycle)
-
- val claude = backend
- if (claude != null && registered) {
- resolveInferenceService()?.unregisterBackend(claude.getId())
- registered = false
- context.logger.info("ClaudePlugin: Unregistered '${claude.getId()}' backend")
- }
-
// A disabled plugin must not keep the decrypted key on the host heap.
releaseBackend()
+ sharedPromptConfig.clear()
true
} catch (e: Exception) {
@@ -191,19 +156,17 @@ class ClaudePlugin : IPlugin, DocumentationExtension {
* backend. Idempotent, so a [deactivate] followed by [dispose] closes nothing twice.
*/
private fun releaseBackend() {
+ if (::registration.isInitialized) registration.stop()
backend?.close()
backend = null
activeBackend = null
- registered = false
}
override fun dispose() {
context.logger.info("ClaudePlugin: Disposing plugin")
- // deactivate() removes this too; a dispose without one would leave the host holding this.
- runCatching { context.removePluginLifecycleListener(aiCoreLifecycle) }
-
releaseBackend()
+ sharedPromptConfig.clear()
pluginContext = null
context.logger.info("ClaudePlugin: Released Claude backend")
}
@@ -221,9 +184,15 @@ class ClaudePlugin : IPlugin, DocumentationExtension {
The agent's tools are declared to Claude directly, so it reads
and edits your project through structured calls rather than
text it has to get exactly right.
- Install AI Core as well, then add your key in
- AI Core → Agent settings. Prompts and any file
- contents a plugin sends are transmitted to Anthropic.
+ Install AI Core as well, then open Preferences →
+ Configuration → Agent, select the claude backend and
+ enter your API key in the pane this plugin adds there. Prompts
+ and any file contents a plugin sends are transmitted to
+ Anthropic.
+ The system prompt is set in YAML files under the plugin's
+ assets/prompts/, so its wording changes without code.
+ If they cannot load or render, AI Core's default prompt is sent
+ instead.
""".trimIndent(),
buttons = listOf(
PluginTooltipButton(
diff --git a/plugins/AI-Agent-Claude/src/main/kotlin/com/itsaky/androidide/plugins/aiagentclaude/prompt/ClaudePromptVariables.kt b/plugins/AI-Agent-Claude/src/main/kotlin/com/itsaky/androidide/plugins/aiagentclaude/prompt/ClaudePromptVariables.kt
new file mode 100644
index 00000000..1647e98a
--- /dev/null
+++ b/plugins/AI-Agent-Claude/src/main/kotlin/com/itsaky/androidide/plugins/aiagentclaude/prompt/ClaudePromptVariables.kt
@@ -0,0 +1,108 @@
+package com.itsaky.androidide.plugins.aiagentclaude.prompt
+
+import com.itsaky.androidide.plugins.aiagentclaude.prompt.config.ClaudePromptConfig
+import com.itsaky.androidide.plugins.services.LlmInferenceService.SystemPromptRequest
+
+/**
+ * The values the prompt config is rendered with: its texts, named by YAML path, and the request's.
+ * Every key is always present, empty when it does not apply, so a name missing here is a typo in
+ * a file and fails the render instead of silently dropping text.
+ */
+internal object ClaudePromptVariables {
+
+ // Config texts: `identity` is IDENTITY, `scope.heading` is SCOPE_HEADING, and so on.
+ const val IDENTITY = "IDENTITY"
+ const val SCOPE_HEADING = "SCOPE_HEADING"
+ const val SCOPE_ITEMS = "SCOPE_ITEMS"
+ const val RULES = "RULES"
+ const val HEADING = "HEADING"
+ const val ITEMS = "ITEMS"
+ const val TEXT = "TEXT"
+ const val BEHAVIOR_HEADING = "BEHAVIOR_HEADING"
+ const val BEHAVIOR_ITEMS = "BEHAVIOR_ITEMS"
+ const val WORKFLOW_HEADING = "WORKFLOW_HEADING"
+ const val WORKFLOW_STEPS = "WORKFLOW_STEPS"
+ const val WORKFLOW_CLOSING = "WORKFLOW_CLOSING"
+ const val TOOLS_HEADING = "TOOLS_HEADING"
+ const val TOOL_CALL_FORMAT_NO_NARRATION = "TOOL_CALL_FORMAT_NO_NARRATION"
+ const val TOOL_CALL_FORMAT_NATIVE = "TOOL_CALL_FORMAT_NATIVE"
+ const val TOOL_CALL_FORMAT_TEXT_INSTRUCTION = "TOOL_CALL_FORMAT_TEXT_INSTRUCTION"
+ const val TOOL_CALL_FORMAT_TEXT_ONLY_THE_LINE_RUNS = "TOOL_CALL_FORMAT_TEXT_ONLY_THE_LINE_RUNS"
+ const val TOOL_CALL_FORMAT_TEXT_EXAMPLES_HEADING = "TOOL_CALL_FORMAT_TEXT_EXAMPLES_HEADING"
+ const val TOOL_CALL_FORMAT_TEXT_EXAMPLES = "TOOL_CALL_FORMAT_TEXT_EXAMPLES"
+ const val PURPOSE = "PURPOSE"
+ const val CALL = "CALL"
+
+ /** A workflow step's 1-based position, inside `{{#WORKFLOW_STEPS}}`. */
+ const val NUMBER = "NUMBER"
+
+ /** The tools the request offers, each with [NAME] and [DESCRIPTION]. */
+ const val TOOLS = "TOOLS"
+
+ /** The tool-call envelope; null when calls travel through the function-calling API. */
+ const val TOOL_CALL_SYNTAX = "TOOL_CALL_SYNTAX"
+
+ /** Whether calls travel through the function-calling API rather than the reply text. */
+ const val NATIVE_TOOL_CALLS = "NATIVE_TOOL_CALLS"
+
+ /** A real project path to show in examples. */
+ const val EXAMPLE_FILE_PATH = "EXAMPLE_FILE_PATH"
+
+ /** [EXAMPLE_FILE_PATH]'s file name without folder or extension, for search examples. */
+ const val EXAMPLE_FILE_STEM = "EXAMPLE_FILE_STEM"
+
+ /** A tool's name, inside `{{#TOOLS}}`. */
+ const val NAME = "NAME"
+
+ /** A tool's description, inside `{{#TOOLS}}`. */
+ const val DESCRIPTION = "DESCRIPTION"
+
+ /** Path used in examples when the caller names none, so they still show a concrete shape. */
+ const val FALLBACK_EXAMPLE_PATH = "app/src/main/java/com/example/MainActivity.kt"
+
+ /**
+ * Collects every value `layout.system_prompt` may use.
+ *
+ * @param config the loaded prompt config.
+ * @param request the tool list, envelope syntax and example path to describe.
+ * @return the values, keyed by name.
+ */
+ fun collect(config: ClaudePromptConfig, request: SystemPromptRequest): Map {
+ val examplePath = request.exampleFilePath ?: FALLBACK_EXAMPLE_PATH
+ val format = config.toolCallFormat
+ return mapOf(
+ IDENTITY to config.identity,
+ SCOPE_HEADING to config.scope.heading,
+ SCOPE_ITEMS to config.scope.items.map { mapOf(TEXT to it) },
+ RULES to config.rules.map { group ->
+ mapOf(HEADING to group.heading, ITEMS to group.items.map { mapOf(TEXT to it) })
+ },
+ BEHAVIOR_HEADING to config.behavior.heading,
+ BEHAVIOR_ITEMS to config.behavior.items.map { mapOf(TEXT to it) },
+ WORKFLOW_HEADING to config.workflow.heading,
+ WORKFLOW_STEPS to config.workflow.steps.mapIndexed { index, step ->
+ mapOf(NUMBER to (index + 1).toString(), TEXT to step)
+ },
+ WORKFLOW_CLOSING to config.workflow.closing,
+ TOOLS_HEADING to config.tools.heading,
+ TOOL_CALL_FORMAT_NO_NARRATION to format.noNarration,
+ TOOL_CALL_FORMAT_NATIVE to format.native,
+ TOOL_CALL_FORMAT_TEXT_INSTRUCTION to format.text.instruction,
+ TOOL_CALL_FORMAT_TEXT_ONLY_THE_LINE_RUNS to format.text.onlyTheLineRuns,
+ TOOL_CALL_FORMAT_TEXT_EXAMPLES_HEADING to format.text.examplesHeading,
+ TOOL_CALL_FORMAT_TEXT_EXAMPLES to format.text.examples.map {
+ mapOf(PURPOSE to it.purpose, CALL to it.call)
+ },
+ // Plain Strings, so a contributed tool's description is never rendered as a template.
+ TOOLS to request.tools.map { mapOf(NAME to it.name, DESCRIPTION to it.description) },
+ EXAMPLE_FILE_PATH to examplePath,
+ // A dotfile's name is all extension, so its stem would be an empty search.
+ EXAMPLE_FILE_STEM to examplePath.substringAfterLast('/').let { name ->
+ name.substringBeforeLast('.').ifEmpty { name }
+ },
+ // Null syntax: calls arrive via the function-calling API, not the text (ADFA-5410).
+ TOOL_CALL_SYNTAX to request.toolCallSyntax,
+ NATIVE_TOOL_CALLS to (request.toolCallSyntax == null),
+ )
+ }
+}
diff --git a/plugins/AI-Agent-Claude/src/main/kotlin/com/itsaky/androidide/plugins/aiagentclaude/prompt/ClaudeSystemPrompt.kt b/plugins/AI-Agent-Claude/src/main/kotlin/com/itsaky/androidide/plugins/aiagentclaude/prompt/ClaudeSystemPrompt.kt
index 94b5d273..76bbccfe 100644
--- a/plugins/AI-Agent-Claude/src/main/kotlin/com/itsaky/androidide/plugins/aiagentclaude/prompt/ClaudeSystemPrompt.kt
+++ b/plugins/AI-Agent-Claude/src/main/kotlin/com/itsaky/androidide/plugins/aiagentclaude/prompt/ClaudeSystemPrompt.kt
@@ -1,110 +1,53 @@
package com.itsaky.androidide.plugins.aiagentclaude.prompt
+import com.itsaky.androidide.plugins.ai.prompt.PromptTemplateEngine
+import com.itsaky.androidide.plugins.aiagentclaude.prompt.config.ClaudePromptConfig
import com.itsaky.androidide.plugins.services.LlmInferenceService.SystemPromptRequest
+import com.itsaky.androidide.plugins.services.LlmInferenceService.ToolDefinition
/**
- * The system prompt this backend asks for.
- *
- * Written for a large cloud model: it states a goal and a workflow and trusts the model to plan
- * within them, where a small on-device model needs each step spelled out. That difference is a
- * property of the model, so the prompt lives with the backend that talks to it.
- *
- * Model-facing text, so it stays in Kotlin rather than `strings.xml` — it is never shown to the
- * user, must not be translated, and is asserted on in unit tests.
- *
- * Pure and free of Android types, so it is unit-testable without a device or a network.
+ * The system prompt this backend asks for: `layout.yml`'s `system_prompt`, rendered in one pass.
+ * Written for a large cloud model, so the wording lives with the backend that talks to it; knows
+ * no wording itself, which is the config's. Pure and thread-safe.
*/
internal object ClaudeSystemPrompt {
/**
- * Path used in the examples when the caller names none, so they still show a concrete shape.
+ * Builds the prompt; the envelope and its examples appear only when the caller parses text.
+ *
+ * @param request the tool list, envelope syntax and example path to describe.
+ * @param config the loaded prompt config.
+ * @return the system prompt, without the caller's IDE-context block.
*/
- private const val FALLBACK_EXAMPLE_PATH = "app/src/main/java/com/example/MainActivity.kt"
-
- /** How to call a tool when the caller takes calls through the provider's own API. */
- private val NATIVE_CALL_FORMAT = """
- TOOL CALL FORMAT — the tools above are declared to you: call one through the function-calling
- API. A call written into your reply text is NOT read by this system and will not run.
- Do NOT describe the action in prose (e.g. "Okay, I'll open the file…") — narrating does nothing.
- """.trimIndent()
+ fun build(request: SystemPromptRequest, config: ClaudePromptConfig): String =
+ PromptTemplateEngine.render(config.layout.systemPrompt, ClaudePromptVariables.collect(config, request))
+ .trimEnd()
/**
- * Builds the prompt for [request].
- *
- * [SystemPromptRequest.toolCallSyntax] is reproduced verbatim — a paraphrase would produce
- * replies nothing reads — and a null one means the caller reads calls off the provider's own
- * function-calling API, so [NATIVE_CALL_FORMAT] replaces the envelope rather than joining it.
+ * Renders requests that open and close every section, to catch a name typo.
*
- * @return the system prompt, without the caller's IDE-context block
+ * @param config the loaded prompt config.
+ * @return one message per distinct failure; empty when every request renders.
*/
- fun build(request: SystemPromptRequest): String {
- val toolDescriptions = request.tools.joinToString("\n") { "- ${it.name}: ${it.description}" }
- val examplePath = request.exampleFilePath ?: FALLBACK_EXAMPLE_PATH
- val exampleStem = examplePath.substringAfterLast('/').substringBeforeLast('.')
-
- val head = """
- You are a senior Android developer integrated into CodeOnTheGo. Your goal is to build complete, working Android apps from user descriptions.
-
- AVAILABLE TOOLS:
- $toolDescriptions
-
- BEHAVIOR:
- - Create complete, production-ready code
- - Call tools proactively to build, test, and verify your work
- - Read files to understand project structure before making changes
- - After each file modification, verify the build compiles
- - 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:", 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, 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.
- """.trimIndent()
-
- val workflow = """
- WORKFLOW:
- 1. Understand the user's request
- 2. Locate what you need with ONE search_project call — the IDE CONTEXT block above already names the source, layout and manifest paths
- 3. Create/modify files with complete implementations
- 4. Add dependencies if needed
- 5. Sync gradle and verify compilation
- 6. Run the app to confirm it works
- 7. Report success and what was built
- """.trimIndent()
-
- // Null syntax means the caller reads calls off the function-calling API instead. Saying so
- // is what stops the model writing one as text, where nothing would run it (ADFA-5410).
- val syntax = request.toolCallSyntax ?: return listOf(head, NATIVE_CALL_FORMAT, workflow)
- .joinToString("\n\n")
-
- val callFormat = """
- TOOL CALL FORMAT — to run a tool, emit a single line in EXACTLY this format and nothing after it:
- $syntax
- Do NOT describe the action in prose (e.g. "Okay, I'll open the file…") — narrating does nothing.
- Do NOT use your provider's native function-calling channel either; a structured tool call is not read by this system.
- The tool only runs when you emit the tool call line itself.
-
- 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":"$examplePath"}}
- Find a file by name:
- {"tool":"search_project","args":{"query":"$exampleStem"}}
- 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"}}
- """.trimIndent()
-
- return head + "\n\n" + callFormat + "\n\n" + workflow
+ fun problems(config: ClaudePromptConfig): List =
+ CHECK_REQUESTS.mapNotNull { request ->
+ try {
+ build(request, config)
+ null
+ } catch (e: IllegalArgumentException) {
+ e.message
+ }
+ }.distinct()
+
+ /** Text and native calling, two tools and none, a real path and the fallback. */
+ private val CHECK_REQUESTS: List = run {
+ val tools = listOf(
+ ToolDefinition("read_file", "Read a file.", emptyMap()),
+ ToolDefinition("respond", "Reply.", emptyMap()),
+ )
+ listOf(
+ SystemPromptRequest(tools, "…", "app/Main.kt"),
+ SystemPromptRequest(emptyList(), null, null),
+ )
}
}
diff --git a/plugins/AI-Agent-Claude/src/main/kotlin/com/itsaky/androidide/plugins/aiagentclaude/prompt/config/ClaudePromptConfig.kt b/plugins/AI-Agent-Claude/src/main/kotlin/com/itsaky/androidide/plugins/aiagentclaude/prompt/config/ClaudePromptConfig.kt
new file mode 100644
index 00000000..c84b97d3
--- /dev/null
+++ b/plugins/AI-Agent-Claude/src/main/kotlin/com/itsaky/androidide/plugins/aiagentclaude/prompt/config/ClaudePromptConfig.kt
@@ -0,0 +1,90 @@
+package com.itsaky.androidide.plugins.aiagentclaude.prompt.config
+
+import com.itsaky.androidide.plugins.ai.prompt.PromptConfigLoader
+import com.itsaky.androidide.plugins.ai.prompt.PromptText
+
+
+/**
+ * Claude's system prompt as `assets/prompts/` declares it: the wording, and the layout that
+ * arranges it. Loaded by [PromptConfigLoader]; immutable, so one instance serves every request.
+ *
+ * @property identity who the agent is.
+ * @property scope what the agent will answer.
+ * @property rules the rules, highest priority first.
+ * @property behavior how to go about building or changing something.
+ * @property workflow the steps of such a task, in order.
+ * @property tools the wording around the tool list.
+ * @property toolCallFormat how to call a tool, natively or as text.
+ * @property layout where each text goes.
+ */
+data class ClaudePromptConfig(
+ val identity: PromptText,
+ val scope: Section,
+ val rules: List,
+ val behavior: Section,
+ val workflow: Workflow,
+ val tools: Tools,
+ val toolCallFormat: ToolCallFormat,
+ val layout: Layout,
+) {
+
+ /**
+ * A heading and the lines under it.
+ *
+ * @property heading what the lines are about.
+ * @property items one sentence each.
+ */
+ data class Section(val heading: PromptText, val items: List)
+
+ /**
+ * One priority's rules.
+ *
+ * @property heading the priority's name, e.g. `CRITICAL`.
+ * @property items the rules, one sentence each.
+ */
+ data class RuleGroup(val heading: PromptText, val items: List)
+
+ /**
+ * @property heading what introduces the steps.
+ * @property steps the steps, numbered in order when rendered.
+ * @property closing when to skip them.
+ */
+ data class Workflow(val heading: PromptText, val steps: List, val closing: PromptText)
+
+ /** @property heading what introduces the tool list. */
+ data class Tools(val heading: PromptText)
+
+ /**
+ * @property noNarration sent under either format: acting means calling, not describing.
+ * @property native how to call under the function-calling API.
+ * @property text how to call when calls travel in the reply.
+ */
+ data class ToolCallFormat(val noNarration: PromptText, val native: PromptText, val text: TextFormat)
+
+ /**
+ * @property instruction the sentence introducing the envelope.
+ * @property onlyTheLineRuns that only the envelope line itself runs a tool.
+ * @property examplesHeading what introduces [examples].
+ * @property examples well-formed calls, each with what it is for.
+ */
+ data class TextFormat(
+ val instruction: PromptText,
+ val onlyTheLineRuns: PromptText,
+ val examplesHeading: PromptText,
+ val examples: List,
+ )
+
+ /**
+ * @property purpose what the call does.
+ * @property call the call, as the model should write it.
+ */
+ data class Example(val purpose: PromptText, val call: PromptText)
+
+ /** @property systemPrompt the whole prompt; ai-core appends its IDE CONTEXT block after it. */
+ data class Layout(val systemPrompt: PromptText)
+
+ companion object {
+ /** The `schema_version` this code reads; bump it when a key is renamed or removed. */
+ const val SCHEMA_VERSION = 1
+ }
+}
diff --git a/plugins/AI-Agent-Claude/src/main/kotlin/com/itsaky/androidide/plugins/aiagentclaude/prompt/config/ClaudePromptConfigParser.kt b/plugins/AI-Agent-Claude/src/main/kotlin/com/itsaky/androidide/plugins/aiagentclaude/prompt/config/ClaudePromptConfigParser.kt
new file mode 100644
index 00000000..5a524651
--- /dev/null
+++ b/plugins/AI-Agent-Claude/src/main/kotlin/com/itsaky/androidide/plugins/aiagentclaude/prompt/config/ClaudePromptConfigParser.kt
@@ -0,0 +1,64 @@
+package com.itsaky.androidide.plugins.aiagentclaude.prompt.config
+
+import com.itsaky.androidide.plugins.ai.prompt.PromptConfigDocument
+import com.itsaky.androidide.plugins.ai.prompt.PromptConfigException
+import com.itsaky.androidide.plugins.ai.prompt.PromptConfigLoader
+import com.itsaky.androidide.plugins.ai.prompt.PromptConfigObject
+import com.itsaky.androidide.plugins.ai.prompt.PromptConfigParser
+import com.itsaky.androidide.plugins.aiagentclaude.prompt.config.ClaudePromptConfig.Example
+import com.itsaky.androidide.plugins.aiagentclaude.prompt.config.ClaudePromptConfig.Layout
+import com.itsaky.androidide.plugins.aiagentclaude.prompt.config.ClaudePromptConfig.RuleGroup
+import com.itsaky.androidide.plugins.aiagentclaude.prompt.config.ClaudePromptConfig.Section
+import com.itsaky.androidide.plugins.aiagentclaude.prompt.config.ClaudePromptConfig.TextFormat
+import com.itsaky.androidide.plugins.aiagentclaude.prompt.config.ClaudePromptConfig.ToolCallFormat
+import com.itsaky.androidide.plugins.aiagentclaude.prompt.config.ClaudePromptConfig.Tools
+import com.itsaky.androidide.plugins.aiagentclaude.prompt.config.ClaudePromptConfig.Workflow
+
+/**
+ * Maps the merged config onto a [ClaudePromptConfig]. Strict: a missing, mistyped or unknown key
+ * throws [PromptConfigException] naming the file that holds it, so a typo fails on activation.
+ */
+object ClaudePromptConfigParser : PromptConfigParser {
+
+ /**
+ * Parses the config merged from `agent.yml` and its includes; see [PromptConfigLoader].
+ *
+ * @param document the merged top-level keys and the file each came from.
+ * @return the config.
+ */
+ override fun parse(document: PromptConfigDocument): ClaudePromptConfig =
+ document.read {
+ val version = int("schema_version")
+ if (version != ClaudePromptConfig.SCHEMA_VERSION) {
+ val supported = ClaudePromptConfig.SCHEMA_VERSION
+ throw invalid("schema_version", "is $version, but this Claude plugin reads $supported")
+ }
+ ClaudePromptConfig(
+ identity = text("identity"),
+ scope = obj("scope").read { section() },
+ rules = objects("rules").map { it.read { RuleGroup(text("heading"), texts("items")) } },
+ behavior = obj("behavior").read { section() },
+ workflow = obj("workflow").read { Workflow(text("heading"), texts("steps"), text("closing")) },
+ tools = obj("tools").read { Tools(text("heading")) },
+ toolCallFormat = obj("tool_call_format").read {
+ ToolCallFormat(
+ noNarration = text("no_narration"),
+ native = text("native"),
+ text = obj("text").read {
+ TextFormat(
+ instruction = text("instruction"),
+ onlyTheLineRuns = text("only_the_line_runs"),
+ examplesHeading = text("examples_heading"),
+ examples = objects("examples").map { example ->
+ example.read { Example(text("purpose"), text("call")) }
+ },
+ )
+ },
+ )
+ },
+ layout = obj("layout").read { Layout(text("system_prompt")) },
+ )
+ }
+
+ private fun PromptConfigObject.section() = Section(text("heading"), texts("items"))
+}
diff --git a/plugins/AI-Agent-Claude/src/main/kotlin/com/itsaky/androidide/plugins/aiagentclaude/prompt/config/SharedPromptConfig.kt b/plugins/AI-Agent-Claude/src/main/kotlin/com/itsaky/androidide/plugins/aiagentclaude/prompt/config/SharedPromptConfig.kt
new file mode 100644
index 00000000..dd9a0882
--- /dev/null
+++ b/plugins/AI-Agent-Claude/src/main/kotlin/com/itsaky/androidide/plugins/aiagentclaude/prompt/config/SharedPromptConfig.kt
@@ -0,0 +1,8 @@
+package com.itsaky.androidide.plugins.aiagentclaude.prompt.config
+
+import com.itsaky.androidide.plugins.ai.prompt.PromptConfigStore
+
+
+
+/** This plugin's prompt config, filled on activation and read by every chat turn. */
+val sharedPromptConfig: PromptConfigStore = PromptConfigStore(ClaudePromptConfigParser)
diff --git a/plugins/AI-Agent-Claude/src/main/kotlin/com/itsaky/androidide/plugins/aiagentclaude/settings/ClaudeSettingsFragment.kt b/plugins/AI-Agent-Claude/src/main/kotlin/com/itsaky/androidide/plugins/aiagentclaude/settings/ClaudeSettingsFragment.kt
index 3a0fe690..f9ab5ff1 100644
--- a/plugins/AI-Agent-Claude/src/main/kotlin/com/itsaky/androidide/plugins/aiagentclaude/settings/ClaudeSettingsFragment.kt
+++ b/plugins/AI-Agent-Claude/src/main/kotlin/com/itsaky/androidide/plugins/aiagentclaude/settings/ClaudeSettingsFragment.kt
@@ -26,11 +26,15 @@ import androidx.lifecycle.lifecycleScope
import com.google.android.material.dialog.MaterialAlertDialogBuilder
import com.google.android.material.textfield.TextInputLayout
import com.itsaky.androidide.plugins.PluginContext
+import com.itsaky.androidide.plugins.ai.ui.ButtonColors
+import com.itsaky.androidide.plugins.ai.ui.FieldColors
+import com.itsaky.androidide.plugins.ai.ui.PaneStyle
+import com.itsaky.androidide.plugins.ai.ui.RevealToggle
+import com.itsaky.androidide.plugins.ai.ui.SecretRevealController
+import com.itsaky.androidide.plugins.ai.ui.applyPaneStyling
import com.itsaky.androidide.plugins.aiagentclaude.R
import com.itsaky.androidide.plugins.aiagentclaude.backend.WorkspaceIds
import com.itsaky.androidide.plugins.aiagentclaude.plugin.ClaudePlugin
-import com.itsaky.androidide.plugins.aiagentclaude.ui.SecretRevealController
-import com.itsaky.androidide.plugins.aiagentclaude.ui.applyPaneStyling
import com.itsaky.androidide.plugins.base.PluginFragmentHelper
import com.itsaky.androidide.plugins.security.KeystoreSecretStore
import com.itsaky.androidide.plugins.services.IdeTooltipService
@@ -47,6 +51,30 @@ private val OUTLINED_BUTTON_IDS = setOf(
R.id.btn_test_connection,
)
+/** This plugin's resources for [applyPaneStyling]. */
+private val PANE_STYLE = PaneStyle(
+ filledButton = ButtonColors(
+ content = R.color.plugin_button_filled_content,
+ ripple = R.color.plugin_button_filled_ripple,
+ container = R.color.plugin_button_filled_container,
+ ),
+ outlinedButton = ButtonColors(
+ content = R.color.plugin_button_outlined_content,
+ ripple = R.color.plugin_button_outlined_ripple,
+ stroke = R.color.plugin_button_outlined_stroke,
+ ),
+ field = FieldColors(
+ stroke = R.color.plugin_box_stroke,
+ error = R.color.plugin_error,
+ hint = R.color.plugin_text_muted,
+ endIcon = R.color.plugin_on_surface_variant,
+ ),
+ divider = R.color.plugin_outline_variant,
+ buttonStrokeWidth = R.dimen.button_stroke_width,
+ cornerRadius = R.dimen.radius_md,
+ dividerThickness = R.dimen.divider_thickness,
+)
+
/**
* This backend's settings pane, mounted by whichever screen offers a backend selector.
*
@@ -109,7 +137,7 @@ class ClaudeSettingsFragment : Fragment() {
ClaudeSettingsViewModelFactory { ClaudePlugin.getContext() }
)[ClaudeSettingsViewModel::class.java]
- view.applyPaneStyling(OUTLINED_BUTTON_IDS)
+ view.applyPaneStyling(PANE_STYLE, OUTLINED_BUTTON_IDS)
setupApiKeyUi(view)
setupModelPicker(view)
setupConnectionTest(view)
@@ -221,10 +249,14 @@ class ClaudeSettingsFragment : Fragment() {
// Not on apiKeyInput: long-press there is the paste menu, and a key is pasted.
listOf(
- apiKeyBox, saveButton, editButton, clearButton, statusTextView,
- verificationText, keyLabel
+ keyLabel, apiKeyBox, saveButton, editButton, clearButton, verificationText
).forEach { wireTooltip(it, ClaudePlugin.TOOLTIP_TAG_SETTINGS_KEY) }
wireTooltip(getKeyButton, ClaudePlugin.TOOLTIP_TAG_SETTINGS_GET_KEY)
+
+ // The status line takes the whole-plugin entry rather than another copy of the key one:
+ // this pane is the only UI this plugin draws, so the guide button that hangs off that entry
+ // is otherwise unreachable.
+ wireTooltip(statusTextView, ClaudePlugin.TOOLTIP_TAG_PLUGIN)
listOf(
view.findViewById(R.id.claude_workspace_label),
view.findViewById(R.id.claude_workspace_box),
@@ -310,7 +342,12 @@ class ClaudeSettingsFragment : Fragment() {
// Not endIconMode="password_toggle": the window has to be flagged secure for as long as the
// key is legible, and the built-in toggle gives no hook for that.
- val reveal = SecretRevealController(apiKeyBox, apiKeyInput) { legible ->
+ val reveal = SecretRevealController(
+ apiKeyBox,
+ apiKeyInput,
+ reveal = RevealToggle(R.drawable.ic_visibility, R.string.cd_show_credential),
+ hide = RevealToggle(R.drawable.ic_visibility_off, R.string.cd_hide_credential),
+ ) { legible ->
setSecureWindow(legible)
}
reveal.attach()
diff --git a/plugins/AI-Agent-Claude/src/main/kotlin/com/itsaky/androidide/plugins/aiagentclaude/ui/PaneStyling.kt b/plugins/AI-Agent-Claude/src/main/kotlin/com/itsaky/androidide/plugins/aiagentclaude/ui/PaneStyling.kt
deleted file mode 100644
index b9166c04..00000000
--- a/plugins/AI-Agent-Claude/src/main/kotlin/com/itsaky/androidide/plugins/aiagentclaude/ui/PaneStyling.kt
+++ /dev/null
@@ -1,77 +0,0 @@
-package com.itsaky.androidide.plugins.aiagentclaude.ui
-
-import android.content.res.ColorStateList
-import android.graphics.Color
-import android.view.View
-import android.view.ViewGroup
-import androidx.annotation.ColorRes
-import androidx.core.content.ContextCompat
-import com.google.android.material.button.MaterialButton
-import com.google.android.material.divider.MaterialDivider
-import com.google.android.material.textfield.TextInputLayout
-import com.itsaky.androidide.plugins.aiagentclaude.R
-
-/** How much a pane button stands out: one filled action per section, the rest outlined. */
-private enum class ButtonEmphasis { FILLED, OUTLINED }
-
-/**
- * Gives every Material button, text field and divider under [this] its Material 3 colours, outline
- * and ripple in code. The styles' `app:` items are dropped inside the host, so XML alone leaves
- * these controls on the host theme's values.
- *
- * @param outlinedButtonIds the buttons that are secondary actions; every other button is filled.
- */
-internal fun View.applyPaneStyling(outlinedButtonIds: Set) {
- when (this) {
- is MaterialButton -> applyEmphasis(
- if (id in outlinedButtonIds) ButtonEmphasis.OUTLINED else ButtonEmphasis.FILLED
- )
- is TextInputLayout -> applyOutline()
- is MaterialDivider -> applyHairline()
- }
- if (this is ViewGroup) {
- for (i in 0 until childCount) getChildAt(i).applyPaneStyling(outlinedButtonIds)
- }
-}
-
-/** Container, label, icon, border and ripple for [emphasis], each with its disabled state. */
-private fun MaterialButton.applyEmphasis(emphasis: ButtonEmphasis) {
- val filled = emphasis == ButtonEmphasis.FILLED
- val content = colors(
- if (filled) R.color.plugin_button_filled_content else R.color.plugin_button_outlined_content
- )
- backgroundTintList = if (filled) {
- colors(R.color.plugin_button_filled_container)
- } else {
- ColorStateList.valueOf(Color.TRANSPARENT)
- }
- setTextColor(content)
- iconTint = content
- rippleColor = colors(
- if (filled) R.color.plugin_button_filled_ripple else R.color.plugin_button_outlined_ripple
- )
- strokeColor = colors(R.color.plugin_button_outlined_stroke)
- strokeWidth = if (filled) 0 else resources.getDimensionPixelSize(R.dimen.button_stroke_width)
- cornerRadius = resources.getDimensionPixelSize(R.dimen.radius_md)
-}
-
-/** Outline, corners, hint and end icon of an outlined-box field. */
-private fun TextInputLayout.applyOutline() {
- setBoxStrokeColorStateList(colors(R.color.plugin_box_stroke))
- setBoxStrokeErrorColor(colors(R.color.plugin_error))
- val radius = resources.getDimension(R.dimen.radius_md)
- setBoxCornerRadii(radius, radius, radius, radius)
- val hint = colors(R.color.plugin_text_muted)
- defaultHintTextColor = hint
- hintTextColor = hint
- setEndIconTintList(colors(R.color.plugin_on_surface_variant))
-}
-
-private fun MaterialDivider.applyHairline() {
- setDividerColorResource(R.color.plugin_outline_variant)
- setDividerThicknessResource(R.dimen.divider_thickness)
-}
-
-/** Resolved against this view's context, which carries the plugin's resources. */
-private fun View.colors(@ColorRes id: Int): ColorStateList =
- requireNotNull(ContextCompat.getColorStateList(context, id))
diff --git a/plugins/AI-Agent-Claude/src/main/kotlin/com/itsaky/androidide/plugins/aiagentclaude/ui/SecretRevealController.kt b/plugins/AI-Agent-Claude/src/main/kotlin/com/itsaky/androidide/plugins/aiagentclaude/ui/SecretRevealController.kt
deleted file mode 100644
index ad45fe97..00000000
--- a/plugins/AI-Agent-Claude/src/main/kotlin/com/itsaky/androidide/plugins/aiagentclaude/ui/SecretRevealController.kt
+++ /dev/null
@@ -1,106 +0,0 @@
-package com.itsaky.androidide.plugins.aiagentclaude.ui
-
-import android.text.method.HideReturnsTransformationMethod
-import android.text.method.PasswordTransformationMethod
-import android.view.Choreographer
-import android.widget.EditText
-import com.google.android.material.textfield.TextInputLayout
-import com.itsaky.androidide.plugins.aiagentclaude.R
-
-/**
- * The reveal control for this plugin's masked credential field.
- *
- * The control is the field's own [TextInputLayout] end icon rather than a loose `ImageButton`,
- * which is what gives it a real touch target wherever the field is shown. Icon, content
- * description and toggle behaviour are decided here and nowhere else, so this pane cannot drift
- * from the other AI plugins' panes (ADFA-5491).
- *
- * Deliberately one copy per AI plugin: each addon is an independent Gradle build sharing only the
- * repo's `libs/` jars, so there is nowhere cheaper to put this until the host's plugin-api carries
- * it — a change to the masking logic is three edits, on purpose.
- *
- * @param box the field's own layout, whose end icon becomes the control
- * @param field the masked field
- * @param onLegibleChanged called with true while the secret stands in clear text, so the caller can
- * flag its window secure — which window that is depends on the screen, not on this control
- */
-internal class SecretRevealController(
- private val box: TextInputLayout,
- private val field: EditText,
- private val onLegibleChanged: (legible: Boolean) -> Unit,
-) {
-
- /** Whether the secret currently stands in clear text. */
- var isRevealed: Boolean = false
- private set
-
- /**
- * Take over [box]'s end icon and mask the field.
- *
- * The drawable is set here rather than in the layout because an end icon declared as
- * `app:endIconDrawable` draws blank inside the host.
- */
- fun attach() {
- box.endIconMode = TextInputLayout.END_ICON_CUSTOM
- // Not announced as a toggle: with END_ICON_CUSTOM nothing ever moves the icon's checked
- // state, so TalkBack would read "not checked" over a legible secret. The content
- // description below carries the state instead.
- box.isEndIconCheckable = false
- box.setEndIconOnClickListener { toggle() }
- apply()
- }
-
- /**
- * Re-mask the secret and report it illegible.
- *
- * Called when the pane leaves the foreground as well as when a new secret is loaded, so
- * neither a screenshot nor the recents thumbnail can catch a revealed credential.
- */
- fun mask() {
- if (!isRevealed) return
- isRevealed = false
- apply()
- }
-
- private fun toggle() {
- isRevealed = !isRevealed
- apply()
- }
-
- /** Dress the field and its icon for [isRevealed], then report what is now legible. */
- private fun apply() {
- field.transformationMethod = if (isRevealed) {
- HideReturnsTransformationMethod.getInstance()
- } else {
- PasswordTransformationMethod.getInstance()
- }
- box.setEndIconDrawable(
- if (isRevealed) R.drawable.ic_visibility_off else R.drawable.ic_visibility
- )
- box.setEndIconContentDescription(
- if (isRevealed) R.string.cd_hide_credential else R.string.cd_show_credential
- )
- // Swapping the transformation drops the cursor to the start, so typing would continue in
- // front of the key rather than after it.
- field.setSelection(field.text?.length ?: 0)
- // Masking only invalidates: the secret stays on screen until the next frame is drawn.
- if (isRevealed) {
- onLegibleChanged(true)
- } else {
- afterNextDraw { if (!isRevealed) onLegibleChanged(false) }
- }
- }
-
- /**
- * Run [action] once the next frame has been drawn, or right away if the field is already gone:
- * a frame callback runs before that frame's traversal, so a message posted from it lands after
- * the field has been redrawn.
- */
- private fun afterNextDraw(action: () -> Unit) {
- if (!field.isAttachedToWindow) {
- action()
- return
- }
- Choreographer.getInstance().postFrameCallback { field.post(action) }
- }
-}
diff --git a/plugins/AI-Agent-Claude/src/test/kotlin/com/itsaky/androidide/plugins/aiagentclaude/backend/ClaudeBackendTest.kt b/plugins/AI-Agent-Claude/src/test/kotlin/com/itsaky/androidide/plugins/aiagentclaude/backend/ClaudeBackendTest.kt
index c030875d..8fc33039 100644
--- a/plugins/AI-Agent-Claude/src/test/kotlin/com/itsaky/androidide/plugins/aiagentclaude/backend/ClaudeBackendTest.kt
+++ b/plugins/AI-Agent-Claude/src/test/kotlin/com/itsaky/androidide/plugins/aiagentclaude/backend/ClaudeBackendTest.kt
@@ -1,21 +1,38 @@
package com.itsaky.androidide.plugins.aiagentclaude.backend
+import android.content.SharedPreferences
+import com.itsaky.androidide.plugins.PluginContext
+import com.itsaky.androidide.plugins.aiagentclaude.preferences.ClaudePreferences
+import com.itsaky.androidide.plugins.aiagentclaude.prompt.config.DirectoryPromptConfigSource.Companion.shippedConfig
+import com.itsaky.androidide.plugins.aiagentclaude.prompt.config.DirectoryPromptConfigSource.Companion.shippedWith
+import com.itsaky.androidide.plugins.services.LlmInferenceService.ActiveModelReportingBackend
import com.itsaky.androidide.plugins.services.LlmInferenceService.CancellableBackend
import com.itsaky.androidide.plugins.services.LlmInferenceService.HistoryCapableBackend
import com.itsaky.androidide.plugins.services.LlmInferenceService.LlmBackend
+import com.itsaky.androidide.plugins.services.LlmInferenceService.SystemPromptRequest
import com.itsaky.androidide.plugins.services.LlmInferenceService.ToolCallingBackend
+import com.itsaky.androidide.plugins.services.LlmInferenceService.ToolDefinition
+import io.mockk.every
import io.mockk.mockk
import org.junit.Assert.assertEquals
+import org.junit.Assert.assertNotNull
+import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Test
/**
- * What this backend declares to the caller. Each interface is optional, so dropping one compiles
- * and degrades silently — which is the only way these regress.
+ * What this backend declares to the caller, and what it answers from its prompt config and model
+ * setting. Each interface is optional, so dropping one compiles and degrades silently.
*/
class ClaudeBackendTest {
- private val backend = ClaudeBackend(mockk(relaxed = true))
+ private val backend = ClaudeBackend(mockk(relaxed = true)) { null }
+
+ @Test
+ fun givenConfigNotYetLoaded_whenAskedForItsPrompt_thenItReturnsNullInsteadOfBlocking() {
+ // Null is the contract's "no prompt of my own": ai-core then sends its default prompt.
+ assertNull(backend.getSystemPrompt(SystemPromptRequest(emptyList(), null, "app/Main.kt")))
+ }
@Test
fun givenTheBackend_whenAskedForItsIdentity_thenItRegistersAsClaude() {
@@ -32,4 +49,52 @@ class ClaudeBackendTest {
assertTrue(declared is CancellableBackend)
assertTrue(declared is ToolCallingBackend)
}
+
+ @Test
+ fun givenTheBackend_whenAskedForItsCapabilities_thenItReportsItsActiveModel() {
+ // Without it the Agent's backend tag names the backend but never the model it talks to.
+ val declared: LlmBackend = backend
+
+ assertTrue(declared is ActiveModelReportingBackend)
+ }
+
+ @Test
+ fun givenALoadedConfig_whenAskedForItsPrompt_thenItRendersTheShippedFiles() {
+ val loaded = ClaudeBackend(mockk(relaxed = true)) { shippedConfig }
+ val tools = listOf(ToolDefinition("read_file", "Read a file", emptyMap()))
+
+ val prompt = loaded.getSystemPrompt(SystemPromptRequest(tools, null, "app/Main.kt"))
+
+ assertNotNull(prompt)
+ assertTrue(prompt!!.contains("- read_file: Read a file"))
+ assertTrue(prompt.contains("TOOL CALL FORMAT — the tools above are declared to you"))
+ }
+
+ @Test
+ fun givenAConfigThatCannotRender_whenAskedForItsPrompt_thenItFallsBackToNull() {
+ // A typo that slipped past activation must cost the prompt, not the whole chat turn.
+ val broken = shippedWith("layout.yml") { it.replace("{{IDENTITY}}", "{{IDENTITTY}}") }
+ val backend = ClaudeBackend(mockk(relaxed = true)) { broken }
+
+ assertNull(backend.getSystemPrompt(SystemPromptRequest(emptyList(), null, "app/Main.kt")))
+ }
+
+ @Test
+ fun givenAStoredModel_whenAskedForTheActiveModel_thenItIsThatIdTrimmed() {
+ assertEquals("claude-sonnet-5-5", backendWithStoredModel(" claude-sonnet-5-5 ").getActiveModelName())
+ }
+
+ @Test
+ fun givenNoStoredModel_whenAskedForTheActiveModel_thenItIsTheDefault() {
+ // A blank field must not reach the backend tag as an unnamed model.
+ assertEquals(ClaudeBackend.DEFAULT_MODEL, backendWithStoredModel(" ").getActiveModelName())
+ }
+
+ private fun backendWithStoredModel(model: String): ClaudeBackend {
+ val prefs = mockk(relaxed = true)
+ every { prefs.getString(ClaudePreferences.KEY_MODEL, any()) } returns model
+ val context = mockk(relaxed = true)
+ every { context.getPluginSharedPreferences(any()) } returns prefs
+ return ClaudeBackend(context) { null }
+ }
}
diff --git a/plugins/AI-Agent-Claude/src/test/kotlin/com/itsaky/androidide/plugins/aiagentclaude/prompt/ClaudeSystemPromptTest.kt b/plugins/AI-Agent-Claude/src/test/kotlin/com/itsaky/androidide/plugins/aiagentclaude/prompt/ClaudeSystemPromptTest.kt
index 6407e478..a27d71b7 100644
--- a/plugins/AI-Agent-Claude/src/test/kotlin/com/itsaky/androidide/plugins/aiagentclaude/prompt/ClaudeSystemPromptTest.kt
+++ b/plugins/AI-Agent-Claude/src/test/kotlin/com/itsaky/androidide/plugins/aiagentclaude/prompt/ClaudeSystemPromptTest.kt
@@ -2,99 +2,270 @@ package com.itsaky.androidide.plugins.aiagentclaude.prompt
import com.itsaky.androidide.plugins.services.LlmInferenceService.SystemPromptRequest
import com.itsaky.androidide.plugins.services.LlmInferenceService.ToolDefinition
+import com.itsaky.androidide.plugins.aiagentclaude.prompt.config.DirectoryPromptConfigSource.Companion.shippedConfig
+import com.itsaky.androidide.plugins.aiagentclaude.prompt.config.DirectoryPromptConfigSource.Companion.shippedWith
+import com.itsaky.androidide.plugins.aiagentclaude.prompt.config.ClaudePromptConfig
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Test
/**
- * The system prompt. The tool-call envelope is what the caller parses back out of the reply, so a
- * paraphrase of it would produce replies nothing reads.
+ * Unit tests for [ClaudeSystemPrompt]. Focus: the prompt teaches exactly one way to call a tool;
+ * teaching both (ADFA-5410) is how a call ends up written as text that nothing runs. Also: its
+ * wording changes by editing `assets/prompts/` alone, and a typo is caught, by file.
*/
class ClaudeSystemPromptTest {
- private fun request(
- syntax: String? = """{"tool":"NAME","args":{}}""",
- tools: List = listOf(
- ToolDefinition("read_file", "Read a file", emptyMap()),
- ToolDefinition("respond", "Finish the task", emptyMap()),
- ),
- examplePath: String? = "app/src/main/java/com/example/MainActivity.kt",
- ) = SystemPromptRequest(tools, syntax, examplePath)
+ private companion object {
+ /** The rule priorities, highest first; `rules.yml` may use only these headings. */
+ val PRIORITIES = listOf("CRITICAL", "IMPORTANT", "MANDATORY", "OPTIONAL")
+
+ const val SYNTAX = """{"tool":"TOOL_NAME","args":{"arg":"value"}}"""
+ }
+
+ private val tools = listOf(ToolDefinition("read_file", "Read a file", emptyMap()))
+
+ private fun prompt(
+ toolCallSyntax: String?,
+ tools: List = this.tools,
+ config: ClaudePromptConfig = shippedConfig,
+ examplePath: String = "app/src/main/java/com/example/MainActivity.kt",
+ ) = ClaudeSystemPrompt.build(
+ SystemPromptRequest(tools, toolCallSyntax, examplePath),
+ config,
+ )
@Test
- fun givenAToolCallSyntax_whenBuilt_thenItAppearsVerbatim() {
- val syntax = """@@CALL{"tool":"NAME"}@@"""
- assertTrue(ClaudeSystemPrompt.build(request(syntax = syntax)).contains(syntax))
+ fun givenAnEnvelopeSyntax_whenBuilding_thenItIsReproducedVerbatim() {
+ assertTrue(prompt(SYNTAX).contains(SYNTAX))
}
@Test
- fun givenTools_whenBuilt_thenEachNameAndDescriptionIsListed() {
- val prompt = ClaudeSystemPrompt.build(request())
- assertTrue(prompt.contains("- read_file: Read a file"))
- assertTrue(prompt.contains("- respond: Finish the task"))
+ fun givenNoEnvelopeSyntax_whenBuilding_thenTheEnvelopeIsNeverTaught() {
+ // The caller parses no envelope here, so an example of one is a call that would not run.
+ assertFalse(prompt(null).contains(""))
}
@Test
- fun givenAnExamplePath_whenBuilt_thenItIsUsedInTheExamples() {
- val prompt = ClaudeSystemPrompt.build(request(examplePath = "src/Foo.kt"))
- assertTrue(prompt.contains(""""file_path":"src/Foo.kt""""))
- // The stem drives the search_project example.
- assertTrue(prompt.contains(""""query":"Foo""""))
+ fun givenNoEnvelopeSyntax_whenBuilding_thenTheFunctionCallingApiIsNamedInstead() {
+ assertTrue(prompt(null).contains("function-calling"))
}
@Test
- fun givenNoTools_whenBuilt_thenThePromptStillBuilds() {
- val prompt = ClaudeSystemPrompt.build(request(tools = emptyList()))
- assertTrue(prompt.contains("AVAILABLE TOOLS:"))
+ fun givenEitherMode_whenBuilding_thenTheToolsAreAlwaysListed() {
+ listOf(SYNTAX, null).forEach { syntax ->
+ assertTrue("tools must be listed either way", prompt(syntax).contains("read_file"))
+ }
}
@Test
- fun givenNoToolCallSyntax_whenBuilt_thenTheEnvelopeIsNeverTaught() {
- // A null syntax means the caller parses no envelope; an example of one is a call that
- // would not run.
- val prompt = ClaudeSystemPrompt.build(request(syntax = null))
+ fun givenEitherMode_whenBuilding_thenAnOffDomainRequestIsNeverDeclined() {
+ // A prompt whose stated goal was only building Android apps left a general question no
+ // legal path through it, and the model declined rather than answer (ADFA-6223).
+ listOf(SYNTAX, null).forEach { syntax ->
+ val prompt = prompt(syntax)
- assertFalse(prompt.contains(""))
- assertTrue(prompt.contains("AVAILABLE TOOLS:"))
- assertTrue(prompt.contains("WORKFLOW:"))
+ assertTrue(prompt.contains("SCOPE:"))
+ assertTrue(
+ prompt.contains("Never decline a request on the grounds that it is not about Android")
+ )
+ }
}
@Test
- fun givenEitherMode_whenBuilt_thenExactlyOneWayToCallAToolIsTaught() {
- // Teaching both (ADFA-5410) is how one call runs twice: the provider carries it and the
- // text copy is extracted as a second call.
- listOf(request(), request(syntax = null)).forEach { request ->
- assertEquals(1, ClaudeSystemPrompt.build(request).split("TOOL CALL FORMAT").size - 1)
+ fun givenEitherMode_whenBuilding_thenTheBuildWorkflowIsIntroducedConditionally() {
+ // Every step presumes an app-build task, so stated unconditionally it is the refusal above.
+ listOf(SYNTAX, null).forEach { syntax ->
+ val prompt = prompt(syntax)
+
+ assertTrue(prompt.contains("WORKFLOW — follow these steps only when the user tells you to build"))
}
}
@Test
- fun givenNoExamplePath_whenBuilt_thenTheExamplesStillCarryAConcretePath() {
- val prompt = ClaudeSystemPrompt.build(request(examplePath = null))
+ fun givenAnyRequest_whenBuilding_thenOneToolCallPerReplyIsRequiredOnlyWhenAToolIsCalled() {
+ // Stated absolutely it contradicts the rule below that a question is answered in the reply
+ // itself, which is most of the traffic now.
+ val prompt = prompt(SYNTAX)
- assertTrue(prompt.contains(""""file_path":"app/src/main/java/com/example/MainActivity.kt""""))
+ assertTrue(prompt.contains("When you call a tool, emit ONE per reply"))
+ assertFalse(prompt.contains("Emit ONE tool call per reply"))
+ }
+
+ @Test
+ fun givenEitherMode_whenBuilding_thenTheWorkflowDoesNotContradictTheRuleAgainstWalkingTheTree() {
+ // WORKFLOW step 2 used to say "List files to understand the project structure", against a
+ // RULE forbidding exactly that. A run followed the workflow and spent 7 of 16 turns on it.
+ listOf(SYNTAX, null).forEach { syntax ->
+ val prompt = prompt(syntax)
+
+ assertFalse(prompt.contains("2. List files"))
+ assertTrue(prompt.contains("Never walk the tree with repeated list_files calls"))
+ }
+ }
+
+ @Test
+ fun givenSeveralTools_whenBuilding_thenNoLineIsIndented() {
+ // The Kotlin version interpolated the tool list into a raw string, which defeated
+ // trimIndent and sent 25 of 44 lines indented by eight spaces.
+ val many = tools + ToolDefinition("respond", "Answer the user", emptyMap())
+
+ listOf(SYNTAX, null).forEach { syntax ->
+ assertFalse(prompt(syntax, many).lines().any { it.startsWith(" ") })
+ }
+ }
+
+ @Test
+ fun givenNativeCalling_whenBuilding_thenOnlyTheNativeFormatIsSentAndNoTagLeaksThrough() {
+ val prompt = prompt(null)
+
+ assertTrue(prompt.contains("TOOL CALL FORMAT — the tools above are declared to you"))
+ assertFalse(prompt.contains("{{"))
+ }
+
+ @Test
+ fun givenTheExamplePath_whenBuilding_thenTheSearchExampleUsesItsFileStem() {
+ assertTrue(prompt(SYNTAX).contains("""{"query":"MainActivity"}"""))
+ }
+
+ @Test
+ fun givenADotfileExamplePath_whenBuilding_thenTheSearchExampleUsesItsWholeName() {
+ assertTrue(prompt(SYNTAX, examplePath = "app/.gitignore").contains("""{"query":".gitignore"}"""))
+ }
+
+ @Test
+ fun givenTheShippedRules_whenRead_thenThePrioritiesAreKnownAndInOrder() {
+ // A heading the model has not been taught has no weight, and a lower one first misleads it.
+ val headings = shippedConfig.rules.map { it.heading.template }
+
+ assertTrue(headings.all { it in PRIORITIES })
+ assertEquals(headings.sortedBy { PRIORITIES.indexOf(it) }, headings)
+ }
+
+ @Test
+ fun givenEitherMode_whenBuilding_thenTheCriticalRulesComeFirst() {
+ listOf(SYNTAX, null).forEach { syntax ->
+ val prompt = prompt(syntax)
+
+ assertTrue(prompt.indexOf("CRITICAL:") < prompt.indexOf("- When you call a tool, emit ONE"))
+ assertTrue(prompt.indexOf("- Never fabricate tool output") < prompt.indexOf("IMPORTANT:"))
+ }
}
@Test
- fun givenAToolCallSyntax_whenBuilt_thenNativeFunctionCallingIsForbidden() {
+ fun givenTheShippedLayout_whenBuilding_thenOnlyOneBlankLineSeparatesTheRuleGroups() {
+ // The engine sets FIRST per list item, so {{^FIRST}} adds no blank line above the first group.
+ val prompt = prompt(SYNTAX)
+
+ assertTrue(prompt.contains("\n\nCRITICAL:"))
+ assertFalse(prompt.contains("\n\n\nCRITICAL:"))
+ assertTrue(prompt.contains("\n\nIMPORTANT:"))
+ assertFalse(prompt.contains("\n\n\nIMPORTANT:"))
+ }
+
+ @Test
+ fun givenTheShippedFiles_whenChecked_thenThePromptRendersForEveryRequest() {
+ // A typo fails the render, so the shipped set must have none.
+ assertEquals(emptyList(), ClaudeSystemPrompt.problems(shippedConfig))
+ }
+
+ @Test
+ fun givenARuleWithATypo_whenChecked_thenItIsReportedByItsFileAndPath() {
+ val config = shippedWith("rules.yml") {
+ it.replace("Never fabricate tool output.", "Never fabricate {{TOOL_LIST}} output.")
+ }
+
+ assertEquals(
+ listOf("rules.yml: rules[0].items[1]: unknown name {{TOOL_LIST}}"),
+ ClaudeSystemPrompt.problems(config),
+ )
+ }
+
+ @Test
+ fun givenATypoBehindTheTextProtocol_whenChecked_thenItIsStillReported() {
+ // The check renders under both protocols, so the one a run rarely takes is covered too.
+ val config = shippedWith("tools.yml") { it.replace("{{EXAMPLE_FILE_STEM}}", "{{EXAMPLE_STEM}}") }
+
+ assertEquals(
+ listOf("tools.yml: tool_call_format.text.examples[2].call: unknown name {{EXAMPLE_STEM}}"),
+ ClaudeSystemPrompt.problems(config),
+ )
+ }
+
+ @Test
+ fun givenANewRule_whenBuilding_thenItIsSentAmongTheRulesWithNoCodeChange() {
+ val config = shippedWith("rules.yml") {
+ it.replace(" - heading: IMPORTANT\n items:\n", " - heading: IMPORTANT\n items:\n - NEW RULE.\n")
+ }
+
+ val prompt = prompt(SYNTAX, config = config)
+
+ assertTrue(prompt.contains("IMPORTANT:\n- NEW RULE.\n- To locate a file"))
+ assertTrue(prompt.indexOf("NEW RULE.") < prompt.indexOf("TOOL CALL FORMAT"))
+ }
+
+ @Test
+ fun givenANewWorkflowStep_whenBuilding_thenTheStepsAreRenumbered() {
+ val config = shippedWith("workflow.yml") {
+ it.replace(" - Understand the user's request\n", " - Understand the user's request\n - Ask if unsure\n")
+ }
+
+ val prompt = prompt(SYNTAX, config = config)
+
+ assertTrue(prompt.contains("1. Understand the user's request\n2. Ask if unsure\n3. Locate"))
+ assertTrue(prompt.contains("8. Report success and what was built"))
+ }
+
+ @Test
+ fun givenANewIdentity_whenBuilding_thenTheToneChangesWithNoCodeChange() {
+ val config = shippedWith("agent.yml") {
+ it.replace(Regex("(?s)identity: >-\n.*?\n\n"), "identity: Eres el asistente de CodeOnTheGo.\n\n")
+ }
+
+ assertTrue(prompt(null, config = config).startsWith("Eres el asistente de CodeOnTheGo.\n\nSCOPE:"))
+ }
+
+ @Test
+ fun givenAReorderedLayout_whenBuilding_thenTheSectionsFollowIt() {
+ // The order the model reads things in is config too, not code.
+ val config = shippedWith("layout.yml") {
+ it.replace(" {{IDENTITY}}\n\n", " {{WORKFLOW_CLOSING}}\n {{IDENTITY}}\n\n")
+ }
+
+ assertTrue(prompt(null, config = config).startsWith("Skip every one of those steps"))
+ }
+
+ @Test
+ fun givenEitherMode_whenBuilding_thenExactlyOneWayToCallAToolIsTaught() {
+ // Teaching both (ADFA-5410) is how one call runs twice: the provider carries it and the
+ // text copy is extracted as a second call.
+ listOf(SYNTAX, null).forEach { syntax ->
+ assertEquals(1, prompt(syntax).split("TOOL CALL FORMAT").size - 1)
+ }
+ }
+
+ @Test
+ fun givenAnEnvelopeSyntax_whenBuilding_thenNativeFunctionCallingIsForbidden() {
// In envelope mode nothing reads the provider's channel, so a model using it would hang.
- val prompt = ClaudeSystemPrompt.build(request())
- assertTrue(prompt.contains("native function-calling channel"))
+ assertTrue(prompt(SYNTAX).contains("Do NOT use your provider's native function-calling channel"))
}
@Test
- fun givenNoToolCallSyntax_whenBuilt_thenTheFunctionCallingApiIsNamedInstead() {
- // The reverse of the rule above: the tools are declared, so the channel is the only way in
- // and forbidding it would leave the model no way to call anything.
- val prompt = ClaudeSystemPrompt.build(request(syntax = null))
+ fun givenNativeCalling_whenBuilding_thenTheNativeChannelIsNotForbidden() {
+ // The tools are declared, so the channel is the only way in; forbidding it leaves none.
+ assertFalse(prompt(null).contains("Do NOT use your provider's native function-calling channel"))
+ }
- assertTrue(prompt.contains("function-calling"))
- assertFalse(prompt.contains("Do NOT use your provider's native function-calling channel"))
+ @Test
+ fun givenNoExamplePath_whenBuilding_thenTheExamplesStillCarryAConcretePath() {
+ val prompt = ClaudeSystemPrompt.build(SystemPromptRequest(tools, SYNTAX, null), shippedConfig)
+
+ assertTrue(prompt.contains(""""file_path":"app/src/main/java/com/example/MainActivity.kt""""))
}
@Test
- fun givenAnyRequest_whenBuilt_thenOneToolCallPerReplyIsRequired() {
- assertTrue(ClaudeSystemPrompt.build(request()).contains("Emit ONE tool call per reply"))
+ fun givenNoTools_whenBuilding_thenThePromptStillBuilds() {
+ assertTrue(prompt(SYNTAX, tools = emptyList()).contains("AVAILABLE TOOLS:"))
}
}
diff --git a/plugins/AI-Agent-Claude/src/test/kotlin/com/itsaky/androidide/plugins/aiagentclaude/prompt/config/ClaudePromptConfigParserTest.kt b/plugins/AI-Agent-Claude/src/test/kotlin/com/itsaky/androidide/plugins/aiagentclaude/prompt/config/ClaudePromptConfigParserTest.kt
new file mode 100644
index 00000000..c363c4be
--- /dev/null
+++ b/plugins/AI-Agent-Claude/src/test/kotlin/com/itsaky/androidide/plugins/aiagentclaude/prompt/config/ClaudePromptConfigParserTest.kt
@@ -0,0 +1,113 @@
+package com.itsaky.androidide.plugins.aiagentclaude.prompt.config
+
+import com.itsaky.androidide.plugins.ai.prompt.PromptConfigException
+import com.itsaky.androidide.plugins.aiagentclaude.prompt.config.DirectoryPromptConfigSource.Companion.shippedConfig
+import com.itsaky.androidide.plugins.aiagentclaude.prompt.config.DirectoryPromptConfigSource.Companion.shippedWith
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertThrows
+import org.junit.Assert.assertTrue
+import org.junit.Test
+
+/**
+ * Unit tests for [ClaudePromptConfigParser]: a mistake in a prompt file is refused naming that file
+ * and the key, rather than reaching the model as a prompt with a hole in it.
+ */
+class ClaudePromptConfigParserTest {
+
+ @Test
+ fun givenTheShippedFiles_whenParsing_thenEveryTextIsLabelledWithItsOwnFileAndPath() {
+ assertEquals("agent.yml: identity", shippedConfig.identity.label)
+ assertEquals("scope.yml: scope.items[1]", shippedConfig.scope.items[1].label)
+ assertEquals("rules.yml: rules[0].items[2]", shippedConfig.rules[0].items[2].label)
+ assertEquals("workflow.yml: workflow.steps[1]", shippedConfig.workflow.steps[1].label)
+ assertEquals(
+ "tools.yml: tool_call_format.text.examples[2].call",
+ shippedConfig.toolCallFormat.text.examples[2].call.label,
+ )
+ assertEquals("layout.yml: layout.system_prompt", shippedConfig.layout.systemPrompt.label)
+ }
+
+ @Test
+ fun givenAFoldedScalar_whenParsing_thenItsLinesAreJoinedIntoOneSentence() {
+ // Source line wraps must not reach the model as newlines mid-sentence.
+ val rule = shippedConfig.rules[0].items[0].template
+
+ assertTrue(rule.startsWith("When you call a tool, emit ONE per reply, then stop and wait."))
+ assertTrue('\n' !in rule)
+ }
+
+ @Test
+ fun givenALiteralBlock_whenParsing_thenItsLineBreaksAreKept() {
+ val native = shippedConfig.toolCallFormat.native.template
+
+ assertEquals(2, native.lines().size)
+ }
+
+ @Test
+ fun givenAMissingNestedKey_whenParsing_thenItIsNamedWithItsFileAndPath() {
+ assertRefused("tools.yml: tool_call_format.text.only_the_line_runs is missing", "tools.yml") {
+ it.replace(Regex("(?m)^ only_the_line_runs: >-\n(?: .*\n)+"), "")
+ }
+ }
+
+ @Test
+ fun givenAMissingTopLevelKey_whenParsing_thenItIsReportedAgainstTheEntryFile() {
+ // No file holds it, so the entry file, which decides what is read, is the one to fix.
+ assertRefused("agent.yml: scope is missing", "scope.yml") { "other: x\n" }
+ }
+
+ @Test
+ fun givenAnExtraNestedKey_whenParsing_thenItIsRefusedAsUnknown() {
+ assertRefused("tools.yml: tools: unknown key tone; expected heading", "tools.yml") {
+ it.replace("tools:\n heading: AVAILABLE TOOLS", "tools:\n heading: AVAILABLE TOOLS\n tone: friendly")
+ }
+ }
+
+ @Test
+ fun givenAnExampleWithoutItsCall_whenParsing_thenTheExampleIsNamed() {
+ assertRefused("tools.yml: tool_call_format.text.examples[0].call is missing", "tools.yml") {
+ it.replace(Regex("(?m)^ call: '\\{\"tool\":\"respond\".*\n"), "")
+ }
+ }
+
+ @Test
+ fun givenAnUnquotedNumber_whenParsing_thenItIsRefusedAsNotText() {
+ assertRefused("scope.yml: scope.heading expected text; quote it", "scope.yml") {
+ it.replace(" heading: SCOPE", " heading: 42")
+ }
+ }
+
+ @Test
+ fun givenAWorkflowWithNoSteps_whenParsing_thenItIsRefused() {
+ assertRefused("workflow.yml: workflow.steps is empty", "workflow.yml") {
+ it.replace(Regex("(?s) steps:\n.*?(?= closing:)"), " steps: []\n")
+ }
+ }
+
+ @Test
+ fun givenANewerSchemaVersion_whenParsing_thenItIsRefusedNamingBoth() {
+ assertRefused("agent.yml: schema_version is 2, but this Claude plugin reads 1", "agent.yml") {
+ it.replace("schema_version: 1", "schema_version: 2")
+ }
+ }
+
+ @Test
+ fun givenBrokenYaml_whenParsing_thenTheFileNameAndPositionAreReported() {
+ val error = refused("rules.yml") { "rules: [unclosed" }
+
+ assertTrue(error.message!!.startsWith("rules.yml: "))
+ assertTrue(error.message!!.contains("line"))
+ }
+
+ @Test
+ fun givenADuplicateKeyInOneFile_whenParsing_thenItIsRefused() {
+ // YAML would otherwise keep the second silently, and an edit to the first would do nothing.
+ refused("agent.yml") { "$it\nidentity: again\n" }
+ }
+
+ private fun refused(file: String, edit: (String) -> String): PromptConfigException =
+ assertThrows(PromptConfigException::class.java) { shippedWith(file, edit) }
+
+ private fun assertRefused(message: String, file: String, edit: (String) -> String) =
+ assertEquals(message, refused(file, edit).message)
+}
diff --git a/plugins/AI-Agent-Claude/src/test/kotlin/com/itsaky/androidide/plugins/aiagentclaude/prompt/config/DirectoryPromptConfigSource.kt b/plugins/AI-Agent-Claude/src/test/kotlin/com/itsaky/androidide/plugins/aiagentclaude/prompt/config/DirectoryPromptConfigSource.kt
new file mode 100644
index 00000000..c3e67b92
--- /dev/null
+++ b/plugins/AI-Agent-Claude/src/test/kotlin/com/itsaky/androidide/plugins/aiagentclaude/prompt/config/DirectoryPromptConfigSource.kt
@@ -0,0 +1,46 @@
+package com.itsaky.androidide.plugins.aiagentclaude.prompt.config
+
+import com.itsaky.androidide.plugins.ai.prompt.PromptConfigLoader
+import com.itsaky.androidide.plugins.ai.prompt.PromptConfigSource
+import java.io.File
+import java.io.FileNotFoundException
+import kotlinx.coroutines.runBlocking
+
+/**
+ * Reads config from a directory, so JVM tests render the exact files the `.cgp` ships.
+ *
+ * @param root the directory holding the config files.
+ * @param edits replaces one file's text before it is returned, as a device would see an edited file.
+ */
+class DirectoryPromptConfigSource(
+ private val root: File,
+ private val edits: Map String> = emptyMap(),
+) : PromptConfigSource {
+
+ override fun read(path: String): String {
+ val file = File(root, path)
+ if (!file.isFile) throw FileNotFoundException(path)
+ return edits[path]?.invoke(file.readText()) ?: file.readText()
+ }
+
+ companion object {
+ /** The shipped config files; unit tests run with the module directory as working dir. */
+ val SHIPPED_ROOT = File("src/main/assets/prompts")
+
+ /** The shipped config, loaded once for every test that renders a prompt. */
+ val shippedConfig: ClaudePromptConfig by lazy { load(DirectoryPromptConfigSource(SHIPPED_ROOT)) }
+
+ /**
+ * Loads the shipped config with one file rewritten by [edit].
+ *
+ * @param file the file to edit, e.g. `rules.yml`.
+ * @param edit rewrites that file's text.
+ * @return the config loaded from the edited files.
+ */
+ fun shippedWith(file: String, edit: (String) -> String): ClaudePromptConfig =
+ load(DirectoryPromptConfigSource(SHIPPED_ROOT, mapOf(file to edit)))
+
+ private fun load(source: PromptConfigSource): ClaudePromptConfig =
+ runBlocking { PromptConfigLoader.load(source, ClaudePromptConfigParser) }
+ }
+}
diff --git a/plugins/AI-Agent-Claude/src/test/kotlin/com/itsaky/androidide/plugins/aiagentclaude/prompt/config/ShippedPromptFilesTest.kt b/plugins/AI-Agent-Claude/src/test/kotlin/com/itsaky/androidide/plugins/aiagentclaude/prompt/config/ShippedPromptFilesTest.kt
new file mode 100644
index 00000000..5fb859aa
--- /dev/null
+++ b/plugins/AI-Agent-Claude/src/test/kotlin/com/itsaky/androidide/plugins/aiagentclaude/prompt/config/ShippedPromptFilesTest.kt
@@ -0,0 +1,55 @@
+package com.itsaky.androidide.plugins.aiagentclaude.prompt.config
+
+import com.itsaky.androidide.plugins.ai.prompt.PromptConfigLoader
+import com.itsaky.androidide.plugins.ai.prompt.PromptConfigSource
+import com.itsaky.androidide.plugins.aiagentclaude.prompt.config.DirectoryPromptConfigSource.Companion.SHIPPED_ROOT
+import java.io.File
+import kotlinx.coroutines.runBlocking
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertFalse
+import org.junit.Assert.assertTrue
+import org.junit.Test
+
+/** The shipped `assets/prompts/` files: every one included once, in order, with no malformed tag. */
+class ShippedPromptFilesTest {
+
+ /** The shipped files, by name. */
+ private val shipped: Map =
+ SHIPPED_ROOT.listFiles { f -> f.extension == "yml" }!!.associate { it.name to it.readText() }
+
+ @Test
+ fun givenTheShippedEntryFile_whenLoading_thenItAndEveryIncludeAreReadInOrder() {
+ val paths = mutableListOf()
+ val source = PromptConfigSource { path -> paths += path; File(SHIPPED_ROOT, path).readText() }
+
+ runBlocking { PromptConfigLoader.load(source, ClaudePromptConfigParser) }
+
+ assertEquals(
+ listOf("agent.yml", "scope.yml", "rules.yml", "workflow.yml", "tools.yml", "layout.yml"),
+ paths,
+ )
+ }
+
+ @Test
+ fun givenEveryShippedFile_whenListed_thenEachIsIncludedExactlyOnce() {
+ // A .yml nobody includes is dead wording that looks live to whoever edits it.
+ val entry = shipped.getValue("agent.yml")
+ val included = Regex("(?m)^ - (\\S+\\.yml)$").findAll(entry).map { it.groupValues[1] }
+
+ assertEquals(shipped.keys - "agent.yml", included.toSet())
+ }
+
+ @Test
+ fun givenTheShippedFiles_whenScanned_thenNoTagIsMalformed() {
+ // A `{{name}}` or `{{ #X}}` typo would reach the model verbatim, since it is no tag.
+ assertTrue(shipped.isNotEmpty())
+ for ((name, text) in shipped) {
+ assertFalse("$name has a malformed tag", MALFORMED_TAG.containsMatchIn(text))
+ }
+ }
+
+ private companion object {
+ /** A `{{` that opens none of `{{NAME}}`, `{{#NAME}}`, `{{^NAME}}` or `{{/NAME}}`. */
+ val MALFORMED_TAG = Regex("""\{\{(?![#^/]?[A-Z])""")
+ }
+}
diff --git a/plugins/AI-Core/src/main/kotlin/com/itsaky/androidide/plugins/aicore/prompt/IdeContextReader.kt b/plugins/AI-Core/src/main/kotlin/com/itsaky/androidide/plugins/aicore/prompt/IdeContextReader.kt
index 062a787e..9616e6c1 100644
--- a/plugins/AI-Core/src/main/kotlin/com/itsaky/androidide/plugins/aicore/prompt/IdeContextReader.kt
+++ b/plugins/AI-Core/src/main/kotlin/com/itsaky/androidide/plugins/aicore/prompt/IdeContextReader.kt
@@ -45,15 +45,12 @@ class IdeContextReader(private val getContext: () -> PluginContext?) : IdeContex
.getOrDefault(null to emptyList())
}
- fun relative(file: File): String = runCatching { file.relativeToOrSelf(root).path }
- .getOrDefault(file.name)
-
return IdeContext(
- currentFile = current?.let(::relative),
+ currentFile = current?.let { relativePath(it, root) },
otherFiles = open.orEmpty()
.filter { it != current }
- .take(MAX_OPEN_FILES)
- .map(::relative),
+ .mapNotNull { relativePath(it, root) }
+ .take(MAX_OPEN_FILES),
modules = modules,
)
}
@@ -61,5 +58,15 @@ class IdeContextReader(private val getContext: () -> PluginContext?) : IdeContex
companion object {
/** Max open files named in the prompt's IDE-context block. */
private const val MAX_OPEN_FILES = 8
+
+ /**
+ * [file]'s path relative to [root], or its name when no relative path exists.
+ *
+ * @return the path, or null when it is blank (the root itself), which names no file.
+ */
+ internal fun relativePath(file: File, root: File): String? =
+ runCatching { file.relativeToOrSelf(root).path }
+ .getOrDefault(file.name)
+ .takeUnless { it.isBlank() }
}
}
diff --git a/plugins/AI-Core/src/test/kotlin/com/itsaky/androidide/plugins/aicore/prompt/IdeContextReaderTest.kt b/plugins/AI-Core/src/test/kotlin/com/itsaky/androidide/plugins/aicore/prompt/IdeContextReaderTest.kt
new file mode 100644
index 00000000..f0ff92f1
--- /dev/null
+++ b/plugins/AI-Core/src/test/kotlin/com/itsaky/androidide/plugins/aicore/prompt/IdeContextReaderTest.kt
@@ -0,0 +1,23 @@
+package com.itsaky.androidide.plugins.aicore.prompt
+
+import java.io.File
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertNull
+import org.junit.Test
+
+/** Unit tests for [IdeContextReader]'s path handling. */
+class IdeContextReaderTest {
+
+ private val root = File("/project")
+
+ @Test
+ fun givenAFileUnderTheRoot_whenMadeRelative_thenThePathIsProjectRelative() {
+ assertEquals("app/Main.kt", IdeContextReader.relativePath(File("/project/app/Main.kt"), root))
+ }
+
+ @Test
+ fun givenTheRootItself_whenMadeRelative_thenNoPathIsReturned() {
+ // A blank path would otherwise become the example path and an empty "current file".
+ assertNull(IdeContextReader.relativePath(root, root))
+ }
+}