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

    1. Install AI Core and this plugin, then restart the IDE.
    2. -
    3. Open AI Core → Agent settings and select Claude.
    4. +
    5. Open Preferences → Configuration → Agent and select + Claude. This plugin's own pane appears below it.
    6. Tap Get API Key, create a key in the Claude Console, paste it and tap Save Key.
    7. Optionally pick a model, then start a chat.
    +

    Key benefits

    + +

    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)) + } +}