diff --git a/docs/PLUGIN_API_CHANGELOG.md b/docs/PLUGIN_API_CHANGELOG.md index 9c5677868c..804a212381 100644 --- a/docs/PLUGIN_API_CHANGELOG.md +++ b/docs/PLUGIN_API_CHANGELOG.md @@ -26,14 +26,72 @@ Versions are bare `YY.WW` — two-digit ISO year, two-digit ISO week (`26.30` = ## Changelog -Newest first. Every change so far is **additive** — no capability has been -removed or had its signature broken since the plugin system shipped. A future -breaking change belongs here as a `breaking` row. +Newest first. Most changes are **additive**; the ones that are not carry a +`breaking` row saying what breaks and what to do about it. Read the `breaking` +rows at or below your `min_ide_version` before you bump it. -Legend: `added` = new capability, safe to adopt · `tooling` = API-stability +Legend: `added` = new capability, safe to adopt · `breaking` = existing plugins +need a source change, a recompile, or both · `tooling` = API-stability milestone. **[verified]** = read from the checked-in ABI dump. **[reconstructed]** = diffed from `plugin-api/src` history (predates the dump; symbol-accurate). +### 26.33 — 2026-08-12 +- **added — Optional LLM backend capabilities** _(ADFA-5095)_ **[verified]** + An LLM backend declares what it supports by the interfaces it implements, so a + backend can ship as its own plugin and implement only what it can do. The + consumer asks with `instanceof` before it calls; a backend that implements none + of these is still a valid `LlmBackend`. + `LlmInferenceService.HistoryCapableBackend` (`generateStreamingWithHistory`), + `ToolCallingBackend` (`generateStreamingWithTools`), + `CancellableBackend` (`cancelStreaming`), + `ConfigurableBackend` (`getSettingsFragmentClassName` — the backend's own + settings `Fragment`, loaded with the backend's classloader). +- **added — Backend-owned prompt and sampling** _(ADFA-5095)_ **[verified]** + A backend supplies the system prompt and temperature its model needs, instead of + the consumer hardcoding them per provider. Both are `default` and return null + for "no preference"; `getDefaultTemperature()` is a boxed `Float`, so null-check + before assigning it to the primitive `LlmConfig.temperature`. + `LlmBackend.getSystemPrompt(SystemPromptRequest)`, + `LlmBackend.getDefaultTemperature()`, `SystemPromptRequest`. +- **breaking — Tool results correlated by call id and tool name** _(ADFA-5095)_ **[verified]** + A tool's output travels back into the next turn as a message of its own, so a + turn's several calls are matched by correlator rather than by position. Both + correlators travel with the result because providers key results differently — + by call id, or by function name — and a backend can only forward what it was + given. + `ChatMessage.toolResult(String, String, String)`, `ChatMessage.toolCallId` / + `toolName`, `ChatMessage.Role.TOOL`. + **What breaks:** `Role` gains a fourth constant, so an exhaustive Kotlin `when` + over it with no `else` stops compiling. A plugin already built against the + three-constant enum has the worse failure: the `when` throws + `NoWhenBranchMatchedException` with a null message, which reads as an + unattributable crash inside the plugin rather than as anything to do with + `Role`. A `TOOL` message reaches a backend that never calls `toolResult` — the + consumer builds it and passes it in the history — so handling it is not + optional for backends. **What to do:** add a `TOOL` branch (routing it as a + user turn is fine for a backend with no native function calling) and republish; + a `.cgp` that is only reinstalled, not rebuilt, stays exposed. +- **added — Preferred backend id** _(ADFA-5095)_ **[verified]** + A backend can ask which backend the user selected, so one that would otherwise + spend seconds and gigabytes preparing itself knows whether it is about to be + used — without reading another plugin's preferences. + `LlmInferenceService.getPreferredBackendId()` (`default`, null when unset). +- **breaking — Nullability annotated across the LLM surface** _(ADFA-5095)_ + Every parameter, return and field on `LlmInferenceService` and the types nested + in it now carries `@NonNull` or `@Nullable`, so the contract is stated rather + than inferred. + **What breaks:** an unannotated Java type reaches Kotlin as a platform type + (`String!`) that dereferences without a check; annotated `@Nullable` it becomes + `String?`, and every existing dereference stops compiling with "only safe (?.) + or non-null asserted (!!.) calls are allowed". This hits **callers**, not just + implementors — `LlmResponse.text` / `.error`, `ToolCallRequest.args` and + `ToolDefinition.parametersSchema` are the ones consumers touch, and + `@NonNull` across `LlmBackend` tightens what an implementor may return. + Bytecode is unchanged, so an installed `.cgp` keeps running; the break is at + compile time in the plugin repo. **What to do:** `?.`, `.orEmpty()` or an + explicit null check at each site — the annotations describe values the API + could already return. + ### 26.31 — 2026-07-29 - **tooling — Plugin API & builder resolvable by Maven coordinate on-device** _(ADFA-4911)_ The plugin API and the builder Gradle plugin are injected into the on-device diff --git a/docs/plugin-api.md b/docs/plugin-api.md index fb03b91287..ed5ebee0ee 100644 --- a/docs/plugin-api.md +++ b/docs/plugin-api.md @@ -12,6 +12,7 @@ The surface a plugin binds to is broader than one module. All of the following a - Core: `IPlugin` (lifecycle), `PluginContext`, `PluginLogger`, `ServiceRegistry`, `ResourceManager`. - Extension interfaces plugins **implement**: `UIExtension`, `EditorExtension`, `EditorTabExtension`, `DocumentationExtension`, `BuildActionExtension`, `SnippetExtension`, `ProjectExtension`, `FileOpenExtension`, `SettingsExtension`. - IDE service interfaces plugins **call** (via `ServiceRegistry.get(X::class.java)`): `IdeProjectService`, `IdeEditorService`, `IdeFileService`, `IdeEnvironmentService`, `IdeArchiveService`, `IdeBuildService`, `IdeUIService`, `IdeEditorTabService`, `IdeTooltipService`, `IdeThemeService`, `IdeFeatureFlagService`, `IdeCommandService`, `IdeTemplateService`, `IdeSnippetService`, `IdeSidebarService`. + - Cross-plugin service interfaces, where **one plugin implements what another calls** (via `SharedServices`): `LlmInferenceService` — implemented by ai-core, called by every AI plugin — together with the types nested in it that a *backend* plugin implements (`LlmBackend`, `HistoryCapableBackend`, `ToolCallingBackend`, `CancellableBackend`, `ConfigurableBackend`) and the value types either side constructs (`ChatMessage`, `LlmConfig`, `LlmResponse`, `SystemPromptRequest`, `ToolDefinition`, `ToolCallRequest`). - Data classes plugins **construct** (e.g. `MenuItem`, `TabItem`, `EditorTabItem`, `NavigationItem`, `ToolbarAction`, `FabAction`, `PluginBuildAction`, `SnippetContribution`, `PluginTooltipEntry`, `PluginSettingsEntry`). - Enums / sealed types plugins **reference**: `PluginPermission`, `ShowAsAction`, `ArchiveFormat`, `BuildActionCategory`, `ToolbarActionIds`, `CommandSpec`, `CommandResult`, `ExtractResult`. - **Wire/format contracts outside the module:** @@ -35,9 +36,10 @@ When the API is later frozen, this doc gains a formal compatibility guarantee an These look source-compatible but break already-built `.cgp` plugins: - **Data-class constructor parameters.** Adding a parameter *even with a default value* changes the synthetic constructor and `copy()` signatures — binary-incompatible for any plugin that constructs or copies the class (`MenuItem`, `PluginBuildAction`, `SnippetContribution`, …). If compatibility matters, add a secondary constructor or a builder instead. -- **Interface methods — direction matters.** +- **Interface methods — direction matters.** Ask who implements the interface before you apply a rule; the answer is not "host" just because the name ends in `Service`. - *Extension interfaces* (`UIExtension`, `BuildActionExtension`, …) are implemented **by plugins**: adding a method is breaking for them (even a defaulted one can break depending on compilation). Provide defaults and prefer additive optional hooks. - - *Service interfaces* (`Ide*Service`) are implemented **by the host** and only called by plugins: **adding** a method is safe; changing or removing a signature is breaking. + - *Host service interfaces* (`Ide*Service`) are implemented **by the host** and only called by plugins: **adding** a method is safe; changing or removing a signature is breaking. + - *Plugin-implemented service interfaces* (`LlmInferenceService` and the backend interfaces nested in it) are implemented **by a plugin** even though they are shaped like services. The extension-interface rule applies, not the host-service one: **adding** a method is breaking. A Kotlin implementor's existing method loses its `override` when a Java `default` appears above it, so the break is a compile error in the *other* repo — which the impact check below is what catches. Prefer a new interface extending the old one over a new method on it. - **Enum constants.** Removing or renaming a constant (`PluginPermission`, `ShowAsAction`, `ArchiveFormat`, `ToolbarActionIds`, `BuildActionCategory`) breaks plugins that name it; adding one can still break an exhaustive `when`. - **Types & nullability.** Flipping nullable↔non-null, changing a parameter/return type, or `val`↔`var` on an API property. - **Moving or renaming** any class/package under `com.itsaky.androidide.plugins.*` — breaks imports and `ServiceRegistry.get(...)` lookups. diff --git a/plugin-api/api/plugin-api.api b/plugin-api/api/plugin-api.api index b1ee917a06..24e2cb1765 100644 --- a/plugin-api/api/plugin-api.api +++ b/plugin-api/api/plugin-api.api @@ -1508,31 +1508,50 @@ public abstract interface class com/itsaky/androidide/plugins/services/LlmInfere public abstract fun getAvailableBackends ()Ljava/util/List; public abstract fun getBackend (Ljava/lang/String;)Lcom/itsaky/androidide/plugins/services/LlmInferenceService$LlmBackend; public abstract fun getEmbeddings (Ljava/lang/String;Ljava/lang/String;)Ljava/util/concurrent/CompletableFuture; + public fun getPreferredBackendId ()Ljava/lang/String; public abstract fun isBackendAvailable (Ljava/lang/String;)Z public abstract fun registerBackend (Lcom/itsaky/androidide/plugins/services/LlmInferenceService$LlmBackend;)V public abstract fun unregisterBackend (Ljava/lang/String;)V } +public abstract interface class com/itsaky/androidide/plugins/services/LlmInferenceService$CancellableBackend : com/itsaky/androidide/plugins/services/LlmInferenceService$LlmBackend { + public abstract fun cancelStreaming ()V +} + public class com/itsaky/androidide/plugins/services/LlmInferenceService$ChatMessage { public final field content Ljava/lang/String; public final field role Lcom/itsaky/androidide/plugins/services/LlmInferenceService$ChatMessage$Role; + public final field toolCallId Ljava/lang/String; + public final field toolName Ljava/lang/String; public fun (Lcom/itsaky/androidide/plugins/services/LlmInferenceService$ChatMessage$Role;Ljava/lang/String;)V + public static fun toolResult (Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;)Lcom/itsaky/androidide/plugins/services/LlmInferenceService$ChatMessage; } public final class com/itsaky/androidide/plugins/services/LlmInferenceService$ChatMessage$Role : java/lang/Enum { public static final field ASSISTANT Lcom/itsaky/androidide/plugins/services/LlmInferenceService$ChatMessage$Role; public static final field SYSTEM Lcom/itsaky/androidide/plugins/services/LlmInferenceService$ChatMessage$Role; + public static final field TOOL Lcom/itsaky/androidide/plugins/services/LlmInferenceService$ChatMessage$Role; public static final field USER Lcom/itsaky/androidide/plugins/services/LlmInferenceService$ChatMessage$Role; public static fun valueOf (Ljava/lang/String;)Lcom/itsaky/androidide/plugins/services/LlmInferenceService$ChatMessage$Role; public static fun values ()[Lcom/itsaky/androidide/plugins/services/LlmInferenceService$ChatMessage$Role; } +public abstract interface class com/itsaky/androidide/plugins/services/LlmInferenceService$ConfigurableBackend : com/itsaky/androidide/plugins/services/LlmInferenceService$LlmBackend { + public abstract fun getSettingsFragmentClassName ()Ljava/lang/String; +} + +public abstract interface class com/itsaky/androidide/plugins/services/LlmInferenceService$HistoryCapableBackend : com/itsaky/androidide/plugins/services/LlmInferenceService$LlmBackend { + public abstract fun generateStreamingWithHistory (Ljava/util/List;Ljava/lang/String;Lcom/itsaky/androidide/plugins/services/LlmInferenceService$LlmConfig;Lcom/itsaky/androidide/plugins/services/LlmInferenceService$StreamCallback;)V +} + public abstract interface class com/itsaky/androidide/plugins/services/LlmInferenceService$LlmBackend { public abstract fun generate (Ljava/lang/String;Lcom/itsaky/androidide/plugins/services/LlmInferenceService$LlmConfig;)Ljava/util/concurrent/CompletableFuture; public abstract fun generateStreaming (Ljava/lang/String;Lcom/itsaky/androidide/plugins/services/LlmInferenceService$LlmConfig;Lcom/itsaky/androidide/plugins/services/LlmInferenceService$StreamCallback;)V public abstract fun generateWithHistory (Ljava/util/List;Ljava/lang/String;Lcom/itsaky/androidide/plugins/services/LlmInferenceService$LlmConfig;)Ljava/util/concurrent/CompletableFuture; + public fun getDefaultTemperature ()Ljava/lang/Float; public abstract fun getId ()Ljava/lang/String; public abstract fun getName ()Ljava/lang/String; + public fun getSystemPrompt (Lcom/itsaky/androidide/plugins/services/LlmInferenceService$SystemPromptRequest;)Ljava/lang/String; public abstract fun isAvailable ()Z } @@ -1564,6 +1583,13 @@ public abstract interface class com/itsaky/androidide/plugins/services/LlmInfere public abstract fun onToken (Ljava/lang/String;)V } +public class com/itsaky/androidide/plugins/services/LlmInferenceService$SystemPromptRequest { + public final field exampleFilePath Ljava/lang/String; + public final field toolCallSyntax Ljava/lang/String; + public final field tools Ljava/util/List; + public fun (Ljava/util/List;Ljava/lang/String;Ljava/lang/String;)V +} + public class com/itsaky/androidide/plugins/services/LlmInferenceService$ToolCallRequest { public field args Ljava/util/Map; public field callId Ljava/lang/String; @@ -1571,6 +1597,10 @@ public class com/itsaky/androidide/plugins/services/LlmInferenceService$ToolCall public fun (Ljava/lang/String;Ljava/lang/String;Ljava/util/Map;)V } +public abstract interface class com/itsaky/androidide/plugins/services/LlmInferenceService$ToolCallingBackend : com/itsaky/androidide/plugins/services/LlmInferenceService$LlmBackend { + public abstract fun generateStreamingWithTools (Ljava/lang/String;Ljava/util/List;Lcom/itsaky/androidide/plugins/services/LlmInferenceService$LlmConfig;Ljava/util/List;Lcom/itsaky/androidide/plugins/services/LlmInferenceService$ToolStreamCallback;)V +} + public class com/itsaky/androidide/plugins/services/LlmInferenceService$ToolDefinition { public field description Ljava/lang/String; public field name Ljava/lang/String; diff --git a/plugin-api/src/main/java/com/itsaky/androidide/plugins/services/LlmInferenceService.java b/plugin-api/src/main/java/com/itsaky/androidide/plugins/services/LlmInferenceService.java index 558ff2fcde..f9cb3b5c8b 100644 --- a/plugin-api/src/main/java/com/itsaky/androidide/plugins/services/LlmInferenceService.java +++ b/plugin-api/src/main/java/com/itsaky/androidide/plugins/services/LlmInferenceService.java @@ -2,368 +2,692 @@ import androidx.annotation.NonNull; import androidx.annotation.Nullable; +import java.util.ArrayList; +import java.util.Collections; import java.util.List; import java.util.Map; +import java.util.Objects; import java.util.concurrent.CompletableFuture; /** - * Service for LLM inference operations. - * Provided by ai-core plugin. + * Service for LLM inference operations. Provided by ai-core plugin. + * + *

+ * {@link LlmBackend} is the one type here that plugins implement rather than call, so it carries only what every backend can answer. Anything a backend may or may not do is a separate interface extending it -- {@link HistoryCapableBackend}, {@link ToolCallingBackend}, {@link CancellableBackend}, {@link ConfigurableBackend} -- and the consumer asks with {@code instanceof} before it calls. A capability is therefore declared by the type, not by a flag a backend can set inconsistently with the methods it overrode. */ public interface LlmInferenceService { - /** - * Configuration for LLM generation - */ - class LlmConfig { - /** The LLM backend identifier (e.g., "openai", "local"). Must not be null. */ - public String backendId; - - /** The name of the model to use for generation */ - public String modelName; - - /** Temperature for generation (0.0-1.0). Default 0.7f provides balanced creativity and coherence. */ - public float temperature = 0.7f; - - /** Maximum number of tokens to generate. Default 2048 balances response length and resource usage. */ - public int maxTokens = 2048; - - /** Optional sequences that signal end of generation */ - public List stopSequences; - - /** Optional system prompt to guide model behavior */ - public String systemPrompt; - - /** Optional backend-specific parameters */ - public Map extraParams; - - /** - * Creates a configuration for LLM generation. - * - * @param backendId the LLM backend identifier (must not be null). The backend must be - * registered with the service. - * @throws IllegalArgumentException if backendId is null - */ - public LlmConfig(String backendId) { - if (backendId == null) { - throw new IllegalArgumentException("backendId must not be null"); - } - this.backendId = backendId; - } - } - - /** - * LLM response - */ - class LlmResponse { - /** Whether the generation was successful */ - public final boolean success; - - /** Generated text (null if not successful) */ - public final String text; - - /** Error message (null if successful) */ - public final String error; - - /** Number of tokens generated in the response */ - public final int tokensGenerated; - - /** Time taken to generate the response in milliseconds */ - public final long timeMs; - - public LlmResponse(boolean success, String text, String error, - int tokensGenerated, long timeMs) { - this.success = success; - this.text = text; - this.error = error; - this.tokensGenerated = tokensGenerated; - this.timeMs = timeMs; - } - - /** - * Creates a successful response. - * - * @param text the generated text - * @param tokens the number of tokens generated - * @param timeMs the time taken in milliseconds - * @return a successful LlmResponse - */ - public static LlmResponse success(String text, int tokens, long timeMs) { - return new LlmResponse(true, text, null, tokens, timeMs); - } - - /** - * Creates a failed response. - * - * @param error the error message describing why generation failed - * @return a failed LlmResponse - */ - public static LlmResponse failure(String error) { - return new LlmResponse(false, null, error, 0, 0); - } - } - - /** - * Callback for streaming responses - */ - interface StreamCallback { - /** - * Called when a token is received. - * - * @param token the generated token - */ - void onToken(String token); - - /** - * Called when generation is complete. - * - * @param response the complete response - */ - void onComplete(LlmResponse response); - - /** - * Called when an error occurs. - * - * @param error the error message - */ - void onError(String error); - } - - /** - * Message in a conversation - */ - class ChatMessage { - /** Role of the message sender */ - public enum Role { USER, ASSISTANT, SYSTEM } - - /** The role of the message sender */ - public final Role role; - - /** The text content of the message */ - public final String content; - - /** - * Creates a chat message. - * - * @param role the role of the sender - * @param content the message content - */ - public ChatMessage(Role role, String content) { - this.role = role; - this.content = content; - } - } - - /** - * LLM backend provider - */ - interface LlmBackend { - /** - * Gets the unique identifier for this backend. - * - * @return the backend identifier - */ - String getId(); - - /** - * Gets the human-readable name of this backend. - * - * @return the backend name - */ - String getName(); - - /** - * Checks if this backend is available for use. - * - * @return true if the backend is available, false otherwise - */ - boolean isAvailable(); - - /** - * Generates a completion for the given prompt. - * - * @param prompt the input prompt - * @param config the generation configuration - * @return a future that completes with the generated response - */ - CompletableFuture generate(String prompt, LlmConfig config); - - /** - * Generates a completion with streaming output. - * - * @param prompt the input prompt - * @param config the generation configuration - * @param callback the callback to receive tokens and completion events - */ - void generateStreaming(String prompt, LlmConfig config, StreamCallback callback); - - /** - * Generates a completion based on conversation history. - * - * @param history the conversation history - * @param prompt the current prompt - * @param config the generation configuration - * @return a future that completes with the generated response - */ - CompletableFuture generateWithHistory( - List history, - String prompt, - LlmConfig config - ); - } - - /** - * Registers an LLM backend with the service. - * - * @param backend the backend to register (must not be null) - */ - void registerBackend(@NonNull LlmBackend backend); - - /** - * Unregisters an LLM backend from the service. - * - * @param backendId the backend identifier (must not be null) - */ - void unregisterBackend(@NonNull String backendId); - - /** - * Gets all available LLM backends. - * - * @return a list of available backends (never null) - */ - @NonNull List getAvailableBackends(); - - /** - * Gets a specific backend by identifier. - * - * @param backendId the backend identifier (must not be null) - * @return the backend if found, or null if not registered - */ - @Nullable LlmBackend getBackend(@NonNull String backendId); - - /** - * Generates a text completion for the given prompt. - * - * @param prompt the input prompt (must not be null) - * @param config the generation configuration (must not be null) - * @return a future that completes with the generated response (never null) - */ - @NonNull CompletableFuture generateCompletion(@NonNull String prompt, @NonNull LlmConfig config); - - /** - * Generates a text completion with streaming output. - * - * @param prompt the input prompt (must not be null) - * @param config the generation configuration (must not be null) - * @param callback the callback to receive tokens and completion events (must not be null) - */ - void generateStreaming(@NonNull String prompt, @NonNull LlmConfig config, @NonNull StreamCallback callback); - - /** - * Generates a completion based on conversation history. - * - * @param history the conversation history (must not be null) - * @param prompt the current prompt (must not be null) - * @param config the generation configuration (must not be null) - * @return a future that completes with the generated response (never null) - */ - @NonNull CompletableFuture generateWithHistory(@NonNull List history, @NonNull String prompt, @NonNull LlmConfig config); - - /** - * Generates embeddings for the given text. - * - * @param text the input text to embed (must not be null) - * @param backendId the backend to use for embedding (must not be null) - * @return a future that completes with the embedding vector (never null) - */ - @NonNull CompletableFuture getEmbeddings(@NonNull String text, @NonNull String backendId); - - /** - * Tool definition for structured function calling. - * Defines a tool that the LLM can invoke. - */ - class ToolDefinition { - public String name; - public String description; - public Map parametersSchema; - - public ToolDefinition(String name, String description, Map parametersSchema) { - this.name = name; - this.description = description; - this.parametersSchema = parametersSchema; - } - } - - /** - * A tool call request made by the LLM. - * Represents the LLM's request to invoke a tool with specific arguments. - */ - class ToolCallRequest { - public String callId; - public String name; - public Map args; - - public ToolCallRequest(String callId, String name, Map args) { - this.callId = callId; - this.name = name; - this.args = args; - } - } - - /** - * Callback for streaming responses with tool calling support. - * Handles tokens, tool calls, completion, and errors. - */ - interface ToolStreamCallback { - /** - * Called when a text token is received. - */ - void onToken(String token); - - /** - * Called when the LLM makes a tool call. - */ - void onToolCall(ToolCallRequest request); - - /** - * Called when generation is complete. - */ - void onComplete(LlmResponse response); - - /** - * Called on error. - */ - void onError(String error); - } - - /** - * Generate streaming response with tool calling support. - * The LLM can call tools, and the caller responds with tool results. - * - * @param prompt the user prompt - * @param history the conversation history (can be empty) - * @param config the generation configuration - * @param tools the available tools the LLM can call - * @param callback the callback for handling tokens, tool calls, completion, and errors - */ - void generateStreamingWithTools( - @NonNull String prompt, - @NonNull List history, - @NonNull LlmConfig config, - @NonNull List tools, - @NonNull ToolStreamCallback callback - ); - - /** - * Checks if a backend is available. - * - * @param backendId the backend identifier (must not be null) - * @return true if the backend is registered and available, false otherwise - */ - boolean isBackendAvailable(@NonNull String backendId); - - /** - * Cancels any ongoing generation operation. - */ - void cancelGeneration(); + /** + * Cancels any ongoing generation operation. + */ + void cancelGeneration(); + + /** + * Generates a text completion for the given prompt. + * + * @param prompt + * the input prompt (must not be null) + * @param config + * the generation configuration (must not be null) + * @return a future that completes with the generated response (never null) + */ + @NonNull + CompletableFuture generateCompletion(@NonNull String prompt, @NonNull LlmConfig config); + + /** + * Generates a text completion with streaming output. + * + * @param prompt + * the input prompt (must not be null) + * @param config + * the generation configuration (must not be null) + * @param callback + * the callback to receive tokens and completion events (must not be null) + */ + void generateStreaming(@NonNull String prompt, @NonNull LlmConfig config, @NonNull StreamCallback callback); + + /** + * Generate streaming response with tool calling support. The LLM can call tools, and the caller responds with tool results. + * + * @param prompt + * the user prompt + * @param history + * the conversation history (can be empty) + * @param config + * the generation configuration + * @param tools + * the available tools the LLM can call + * @param callback + * the callback for handling tokens, tool calls, completion, and errors + */ + void generateStreamingWithTools( + @NonNull String prompt, + @NonNull List history, + @NonNull LlmConfig config, + @NonNull List tools, + @NonNull ToolStreamCallback callback); + + /** + * Generates a completion based on conversation history. + * + * @param history + * the conversation history (must not be null) + * @param prompt + * the current prompt (must not be null) + * @param config + * the generation configuration (must not be null) + * @return a future that completes with the generated response (never null) + */ + @NonNull + CompletableFuture generateWithHistory(@NonNull List history, @NonNull String prompt, @NonNull LlmConfig config); + + /** + * Gets all available LLM backends. + * + * @return a list of available backends (never null) + */ + @NonNull + List getAvailableBackends(); + + /** + * Gets a specific backend by identifier. + * + * @param backendId + * the backend identifier (must not be null) + * @return the backend if found, or null if not registered + */ + @Nullable + LlmBackend getBackend(@NonNull String backendId); + + /** + * Generates embeddings for the given text. + * + * @param text + * the input text to embed (must not be null) + * @param backendId + * the backend to use for embedding (must not be null) + * @return a future that completes with the embedding vector (never null) + */ + @NonNull + CompletableFuture getEmbeddings(@NonNull String text, @NonNull String backendId); + + /** + * Gets the id of the backend the user selected, independent of whether it is registered or currently usable. + * + *

+ * Which backend is active is the router's state, not any one backend's, but a backend sometimes needs it: one that would otherwise spend seconds and gigabytes preparing itself has to know whether it is the backend about to be used. Publishing it here is what keeps a backend from having to read another plugin's preferences to find out. + * + * @return the selected backend id, or null when no selection has been expressed + */ + @Nullable + default String getPreferredBackendId() { + return null; + } + + /** + * Checks if a backend is available. + * + * @param backendId + * the backend identifier (must not be null) + * @return true if the backend is registered and available, false otherwise + */ + boolean isBackendAvailable(@NonNull String backendId); + + /** + * Registers an LLM backend with the service. + * + * @param backend + * the backend to register (must not be null) + */ + void registerBackend(@NonNull LlmBackend backend); + + /** + * Unregisters an LLM backend from the service. + * + * @param backendId + * the backend identifier (must not be null) + */ + void unregisterBackend(@NonNull String backendId); + + /** + * A backend whose in-flight streaming generation can be cancelled (Stop pressed). + */ + interface CancellableBackend extends LlmBackend { + /** + * Cancels the streaming generation currently in flight, if any. + */ + void cancelStreaming(); + } + + /** + * One turn of a conversation: what the user asked, what the model answered, or what a tool returned. + */ + class ChatMessage { + /** + * Creates the message that carries a tool's output back into the next turn. + * + *

+ * This is the return path for {@link ToolStreamCallback#onToolCall}: the consumer runs the tool, wraps the outcome here, and appends it to the history of the following request. Both correlators travel with it because providers key results differently -- by call id, or by function name -- and a backend can only forward what it was given. + * + * @param toolCallId + * the {@link ToolCallRequest#callId} this result answers + * @param toolName + * the {@link ToolCallRequest#name} that was invoked + * @param content + * the tool's output, already rendered as text + * @return a message with role {@link Role#TOOL} + */ + @NonNull + public static ChatMessage toolResult(@NonNull String toolCallId, @NonNull String toolName, @NonNull String content) { + return new ChatMessage( + Role.TOOL, + Objects.requireNonNull(content, "content must not be null"), + Objects.requireNonNull(toolCallId, "toolCallId must not be null"), + Objects.requireNonNull(toolName, "toolName must not be null")); + } + + /** The role of the message sender */ + @NonNull + public final Role role; + + /** The text content of the message */ + @NonNull + public final String content; + + /** The call this message answers; non-null exactly when {@link #role} is {@link Role#TOOL}. */ + @Nullable + public final String toolCallId; + + /** The tool this message answers for; non-null exactly when {@link #role} is {@link Role#TOOL}. */ + @Nullable + public final String toolName; + + /** + * Creates a chat message from a conversation participant. + * + * @param role + * the role of the sender; not {@link Role#TOOL}, which needs the correlators only {@link #toolResult} supplies + * @param content + * the message content + * @throws IllegalArgumentException + * if role is {@link Role#TOOL} + */ + public ChatMessage(@NonNull Role role, @NonNull String content) { + if (role == Role.TOOL) { + throw new IllegalArgumentException("A TOOL message must be built with ChatMessage.toolResult(...)"); + } + this.role = Objects.requireNonNull(role, "role must not be null"); + this.content = Objects.requireNonNull(content, "content must not be null"); + this.toolCallId = null; + this.toolName = null; + } + + private ChatMessage(@NonNull Role role, @NonNull String content, @NonNull String toolCallId, @NonNull String toolName) { + this.role = role; + this.content = content; + this.toolCallId = toolCallId; + this.toolName = toolName; + } + + /** Role of the message sender */ + public enum Role { + USER, ASSISTANT, SYSTEM, TOOL + } + } + + /** + * An {@link LlmBackend} that draws its own settings screen. Kept apart from {@code LlmBackend} so that running inference stays independent of presenting a UI: a backend with nothing to configure implements nothing, and the consumer asks with {@code instanceof} before it draws. + */ + interface ConfigurableBackend extends LlmBackend { + /** + * Gets the fully-qualified name of the {@code Fragment} this backend contributes to draw its settings. The class must live in the backend's own plugin and declare a public no-argument constructor; the consumer loads it with the backend's classloader and mounts it wherever it presents backend settings. The name is passed as a string so this contract stays free of any dependency on Android UI types. + * + *

+ * The backend owns the screen outright -- including where each value is stored, which is why nothing here describes a field or a store. A consumer cannot prefill or write a backend's settings; it can only mount them. + * + * @return the fragment class name (never null) + */ + @NonNull + String getSettingsFragmentClassName(); + } + + /** + * An {@link LlmBackend} that renders earlier turns of a conversation. + * + *

+ * Implementing this is the declaration: a backend that can only prompt single-turn does not implement it, and the consumer calls {@link LlmBackend#generateStreaming} instead of silently losing the conversation -- which reads to the user as a model that cannot follow one. + */ + interface HistoryCapableBackend extends LlmBackend { + /** + * Generates a streaming reply for a multi-turn conversation. + * + * @param history + * the conversation history + * @param prompt + * the current prompt + * @param config + * the generation configuration + * @param callback + * the callback to receive tokens and completion events + */ + void generateStreamingWithHistory( + @NonNull List history, + @NonNull String prompt, + @NonNull LlmConfig config, + @NonNull StreamCallback callback); + } + + /** + * LLM backend provider + */ + interface LlmBackend { + /** + * Generates a completion for the given prompt. + * + * @param prompt + * the input prompt + * @param config + * the generation configuration + * @return a future that completes with the generated response + */ + @NonNull + CompletableFuture generate(@NonNull String prompt, @NonNull LlmConfig config); + + /** + * Generates a completion with streaming output. + * + * @param prompt + * the input prompt + * @param config + * the generation configuration + * @param callback + * the callback to receive tokens and completion events + */ + void generateStreaming(@NonNull String prompt, @NonNull LlmConfig config, @NonNull StreamCallback callback); + + /** + * Generates a completion based on conversation history. + * + * @param history + * the conversation history + * @param prompt + * the current prompt + * @param config + * the generation configuration + * @return a future that completes with the generated response + */ + @NonNull + CompletableFuture generateWithHistory( + @NonNull List history, + @NonNull String prompt, + @NonNull LlmConfig config); + + /** + * Gets the sampling temperature this backend works best at, or null to accept the consumer's own. + * + *

+ * A backend driven by a constrained grammar wants a near-greedy value so it copies arguments rather than inventing them; a cloud model following a high-autonomy prompt usually wants more room. Neither figure is the consumer's to guess. + * + *

+ * Boxed so that "no preference" is expressible. {@link LlmConfig#temperature} is a primitive, so a consumer must null-check before it assigns: {@code config.temperature = backend.getDefaultTemperature()} unboxes null and throws. + * + * @return the preferred temperature, or null for the consumer's default + */ + @Nullable + default Float getDefaultTemperature() { + return null; + } + + /** + * Gets the unique identifier for this backend. + * + * @return the backend identifier + */ + @NonNull + String getId(); + + /** + * Gets the human-readable name of this backend. + * + * @return the backend name + */ + @NonNull + String getName(); + + /** + * Gets the system prompt to send with every request to this backend, or null to accept the consumer's own. + * + *

+ * Prompt wording is model-specific -- how much autonomy a model handles, how literally it copies an example -- so it belongs with the backend that knows the model, not with the consumer that knows the tools. The consumer still owns the call syntax: reproduce {@link SystemPromptRequest#toolCallSyntax} verbatim when it is present, or the replies this prompt produces will not parse. + * + * @param request + * the tool contract and example material to compose against + * @return the system prompt, or null to use the consumer's default + */ + @Nullable + default String getSystemPrompt(@NonNull SystemPromptRequest request) { + return null; + } + + /** + * Checks if this backend is available for use. + * + * @return true if the backend is available, false otherwise + */ + boolean isAvailable(); + } + + /** + * Configuration for LLM generation + */ + class LlmConfig { + /** The LLM backend identifier (e.g., "openai", "local"). Must not be null. */ + public String backendId; + + /** The name of the model to use for generation */ + public String modelName; + + /** Temperature for generation (0.0-1.0). Default 0.7f provides balanced creativity and coherence. */ + public float temperature = 0.7f; + + /** Maximum number of tokens to generate. Default 2048 balances response length and resource usage. */ + public int maxTokens = 2048; + + /** Optional sequences that signal end of generation */ + public List stopSequences; + + /** Optional system prompt to guide model behavior */ + public String systemPrompt; + + /** Optional backend-specific parameters */ + public Map extraParams; + + /** + * Creates a configuration for LLM generation. + * + * @param backendId + * the LLM backend identifier (must not be null). The backend must be registered with the service. + * @throws IllegalArgumentException + * if backendId is null + */ + public LlmConfig(String backendId) { + if (backendId == null) { + throw new IllegalArgumentException("backendId must not be null"); + } + this.backendId = backendId; + } + } + + /** + * LLM response + */ + class LlmResponse { + /** + * Creates a failed response. + * + * @param error + * the error message describing why generation failed + * @return a failed LlmResponse + */ + @NonNull + public static LlmResponse failure(@NonNull String error) { + return new LlmResponse(false, null, error, 0, 0); + } + + /** + * Creates a successful response. + * + * @param text + * the generated text + * @param tokens + * the number of tokens generated + * @param timeMs + * the time taken in milliseconds + * @return a successful LlmResponse + */ + @NonNull + public static LlmResponse success(@NonNull String text, int tokens, long timeMs) { + return new LlmResponse(true, text, null, tokens, timeMs); + } + + /** Whether the generation was successful */ + public final boolean success; + + /** Generated text (null if not successful) */ + @Nullable + public final String text; + + /** Error message (null if successful) */ + @Nullable + public final String error; + + /** Number of tokens generated in the response */ + public final int tokensGenerated; + + /** Time taken to generate the response in milliseconds */ + public final long timeMs; + + public LlmResponse(boolean success, @Nullable String text, @Nullable String error, + int tokensGenerated, long timeMs) { + this.success = success; + this.text = text; + this.error = error; + this.tokensGenerated = tokensGenerated; + this.timeMs = timeMs; + } + } + + /** + * Callback for streaming responses + */ + interface StreamCallback { + /** + * Called when generation is complete. + * + * @param response + * the complete response + */ + void onComplete(LlmResponse response); + + /** + * Called when an error occurs. + * + * @param error + * the error message + */ + void onError(String error); + + /** + * Called when a token is received. + * + * @param token + * the generated token + */ + void onToken(String token); + } + + /** + * What a backend is given to compose a system prompt in {@link LlmBackend#getSystemPrompt}. + * + *

+ * The consumer supplies the tool contract; the backend supplies the wording. That split matters: the consumer is the side that parses the model's reply, so a backend that invents its own call syntax produces output nothing reads back -- and it fails silently, as a model that answers in prose rather than calling a tool. + */ + class SystemPromptRequest { + /** The tools the consumer will accept calls for, in the order to present them. Never null; empty when the conversation offers no tools. The list is unmodifiable, but only its spine is copied -- the {@link ToolDefinition}s in it are the consumer's, and a backend must not edit one. */ + @NonNull + public final List tools; + + /** + * The exact envelope the consumer parses back, to be reproduced verbatim in the prompt, or null when it parses none. + * + *

+ * Null is the plain-chat case, and the case of a consumer driving {@link ToolCallingBackend} through a provider's own function calling: there is no text envelope, so a prompt must not instruct the model to emit one. Reproducing an empty envelope is the failure this type exists to prevent -- the model is told to call tools in a syntax nothing reads, and answers in prose instead. + */ + @Nullable + public final String toolCallSyntax; + + /** + * A real path from the user's project for the prompt's examples, so they imply no layout or language the project does not have. + */ + @Nullable + public final String exampleFilePath; + + /** + * Creates a system prompt request. + * + * @param tools + * the tools to present to the model; copied, so later edits to the caller's list do not reach the request + * @param toolCallSyntax + * the call envelope the consumer parses, or null when it parses none + * @param exampleFilePath + * a real project path to use in examples, or null when the project has no file to point at + */ + public SystemPromptRequest(@Nullable List tools, @Nullable String toolCallSyntax, + @Nullable String exampleFilePath) { + this.tools = tools == null + ? Collections.emptyList() + : Collections.unmodifiableList(new ArrayList<>(tools)); + this.toolCallSyntax = toolCallSyntax; + this.exampleFilePath = exampleFilePath; + } + } + + /** + * An {@link LlmBackend} that reports the model's tool calls as structured calls. + * + *

+ * Implementing this is the declaration, and it means {@link ToolStreamCallback#onToolCall} will fire for a call the model makes. A backend that merely wants earlier turns implements {@link HistoryCapableBackend} instead: accepting tools and never calling one leaves the consumer waiting on an action the model was never able to take. + */ + interface ToolCallingBackend extends LlmBackend { + /** + * Generates a completion with streaming output and tool calling support. + * + * @param prompt + * the input prompt + * @param history + * the conversation history, including any {@link ChatMessage#toolResult} from earlier turns (can be empty) + * @param config + * the generation configuration + * @param tools + * the available tools the LLM can call + * @param callback + * the callback to receive tokens, tool calls and completion events + */ + void generateStreamingWithTools( + @NonNull String prompt, + @NonNull List history, + @NonNull LlmConfig config, + @NonNull List tools, + @NonNull ToolStreamCallback callback); + } + + /** + * A tool call request made by the LLM. Represents the LLM's request to invoke a tool with specific arguments. + * + *

+ * The backend that reports a call owns the instance; treat it as read-only once {@link ToolStreamCallback#onToolCall} has been given it. The fields are not final and {@link #args} is held by reference, because both shipped that way in 26.28 and tightening them would break an already-built plugin that assigns them. Rewriting one after the fact means the consumer runs a call the model did not make. + */ + class ToolCallRequest { + /** Identifier correlating this call with the result the consumer sends back in {@link ChatMessage#toolResult} */ + @NonNull + public String callId; + + /** Name of the tool to invoke; matches a {@link ToolDefinition#name} the consumer offered */ + @NonNull + public String name; + + /** Arguments the model supplied, keyed by parameter name; null when the tool takes none */ + @Nullable + public Map args; + + /** + * Creates a tool call request. + * + * @param callId + * the identifier correlating this call with its result + * @param name + * the name of the tool to invoke + * @param args + * the arguments the model supplied, or null for none; held by reference, so do not edit the map afterwards + */ + public ToolCallRequest(@NonNull String callId, @NonNull String name, @Nullable Map args) { + this.callId = callId; + this.name = name; + this.args = args; + } + } + + /** + * Tool definition for structured function calling. Defines a tool that the LLM can invoke. + * + *

+ * The consumer that offers a tool owns the instance; a backend given one in {@link SystemPromptRequest#tools} must treat it as read-only. The fields are not final and {@link #parametersSchema} is held by reference, because both shipped that way in 26.28 and tightening them would break an already-built plugin that assigns them. Renaming a tool or emptying its schema after the prompt is composed leaves the consumer parsing replies against a contract it no longer offered -- and {@link SystemPromptRequest} copies only the list spine, so its copy points at these same instances. + */ + class ToolDefinition { + /** The name the model must use to call this tool */ + @NonNull + public String name; + + /** What the tool does, in wording meant for the model rather than the user */ + @NonNull + public String description; + + /** JSON-schema-shaped description of the parameters; null when the tool takes none */ + @Nullable + public Map parametersSchema; + + /** + * Creates a tool definition. + * + * @param name + * the name the model must use to call the tool + * @param description + * what the tool does + * @param parametersSchema + * the parameter schema, or null when the tool takes no parameters; held by reference, so do not edit the map afterwards + */ + public ToolDefinition(@NonNull String name, @NonNull String description, + @Nullable Map parametersSchema) { + this.name = name; + this.description = description; + this.parametersSchema = parametersSchema; + } + } + + /** + * Callback for streaming responses with tool calling support. Handles tokens, tool calls, completion, and errors. + */ + interface ToolStreamCallback { + /** + * Called when generation is complete. + * + * @param response + * the complete response + */ + void onComplete(LlmResponse response); + + /** + * Called when an error occurs. + * + * @param error + * the error message + */ + void onError(String error); + + /** + * Called when a text token is received. + * + * @param token + * the generated token + */ + void onToken(String token); + + /** + * Called when the LLM makes a tool call. The consumer runs the tool and appends the outcome to the next request's history as a {@link ChatMessage#toolResult}, which carries {@link ToolCallRequest#callId} back so a turn's several calls are correlated by id rather than by position. + * + * @param request + * the tool the model wants called, and the arguments it supplied + */ + void onToolCall(ToolCallRequest request); + } } diff --git a/plugin-api/src/test/java/com/itsaky/androidide/plugins/services/IdeFileServiceTest.java b/plugin-api/src/test/java/com/itsaky/androidide/plugins/services/IdeFileServiceTest.java deleted file mode 100644 index aac3124068..0000000000 --- a/plugin-api/src/test/java/com/itsaky/androidide/plugins/services/IdeFileServiceTest.java +++ /dev/null @@ -1,29 +0,0 @@ -package com.itsaky.androidide.plugins.services; - -import org.junit.Test; -import static org.junit.Assert.*; - -public class IdeFileServiceTest { - - @Test - public void testFileOperationResultSuccess() { - IdeFileService.FileOperationResult result = - IdeFileService.FileOperationResult.success("File read", "content"); - - assertTrue(result.success); - assertEquals("File read", result.message); - assertEquals("content", result.data); - assertNull(result.error); - } - - @Test - public void testFileOperationResultFailure() { - IdeFileService.FileOperationResult result = - IdeFileService.FileOperationResult.failure("File not found"); - - assertFalse(result.success); - assertEquals("Operation failed", result.message); - assertNull(result.data); - assertEquals("File not found", result.error); - } -} diff --git a/plugin-api/src/test/java/com/itsaky/androidide/plugins/services/IdeProjectServiceTest.java b/plugin-api/src/test/java/com/itsaky/androidide/plugins/services/IdeProjectServiceTest.java deleted file mode 100644 index 4f0cd0dba6..0000000000 --- a/plugin-api/src/test/java/com/itsaky/androidide/plugins/services/IdeProjectServiceTest.java +++ /dev/null @@ -1,29 +0,0 @@ -package com.itsaky.androidide.plugins.services; - -import org.junit.Test; -import static org.junit.Assert.*; - -public class IdeProjectServiceTest { - - @Test - public void testProjectOperationResultSuccess() { - IdeProjectService.ProjectOperationResult result = - IdeProjectService.ProjectOperationResult.success("Sync started", "data"); - - assertTrue(result.success); - assertEquals("Sync started", result.message); - assertEquals("data", result.data); - assertNull(result.error); - } - - @Test - public void testProjectOperationResultFailure() { - IdeProjectService.ProjectOperationResult result = - IdeProjectService.ProjectOperationResult.failure("Build failed"); - - assertFalse(result.success); - assertEquals("Operation failed", result.message); - assertNull(result.data); - assertEquals("Build failed", result.error); - } -} diff --git a/plugin-api/src/test/java/com/itsaky/androidide/plugins/services/IdeResourceServiceTest.java b/plugin-api/src/test/java/com/itsaky/androidide/plugins/services/IdeResourceServiceTest.java deleted file mode 100644 index f5d0b62479..0000000000 --- a/plugin-api/src/test/java/com/itsaky/androidide/plugins/services/IdeResourceServiceTest.java +++ /dev/null @@ -1,27 +0,0 @@ -package com.itsaky.androidide.plugins.services; - -import org.junit.Test; -import static org.junit.Assert.*; - -public class IdeResourceServiceTest { - - @Test - public void testResourceOperationResultSuccess() { - IdeResourceService.ResourceOperationResult result = - IdeResourceService.ResourceOperationResult.success("Resource added"); - - assertTrue(result.success); - assertEquals("Resource added", result.message); - assertNull(result.error); - } - - @Test - public void testResourceOperationResultFailure() { - IdeResourceService.ResourceOperationResult result = - IdeResourceService.ResourceOperationResult.failure("Resource exists"); - - assertFalse(result.success); - assertEquals("Operation failed", result.message); - assertEquals("Resource exists", result.error); - } -} diff --git a/plugin-api/src/test/java/com/itsaky/androidide/plugins/services/LlmInferenceServiceTest.java b/plugin-api/src/test/java/com/itsaky/androidide/plugins/services/LlmInferenceServiceTest.java new file mode 100644 index 0000000000..58ad49fb9a --- /dev/null +++ b/plugin-api/src/test/java/com/itsaky/androidide/plugins/services/LlmInferenceServiceTest.java @@ -0,0 +1,160 @@ +package com.itsaky.androidide.plugins.services; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertTrue; +import static org.junit.Assert.fail; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import org.junit.Test; + +/** + * Covers the validating, coalescing and copying branches of the value types plugins construct. Every consumer of this jar is an out-of-tree plugin, so a constructor that accepts a bad state here surfaces as a runtime failure nothing in this repo compiles against. + */ +public class LlmInferenceServiceTest { + + @Test + public void chatMessageCarriesNoCorrelatorsForAConversationTurn() { + LlmInferenceService.ChatMessage message = new LlmInferenceService.ChatMessage(LlmInferenceService.ChatMessage.Role.USER, "hello"); + + assertEquals(LlmInferenceService.ChatMessage.Role.USER, message.role); + assertEquals("hello", message.content); + assertNull(message.toolCallId); + assertNull(message.toolName); + } + + @Test + public void chatMessageRejectsANullRole() { + try { + new LlmInferenceService.ChatMessage(null, "hello"); + fail("expected NullPointerException"); + } catch (NullPointerException expected) { + // the role is what selects the shape; a null one has no shape + } + } + + @Test + public void chatMessageRejectsAToolRoleWithoutCorrelators() { + try { + new LlmInferenceService.ChatMessage(LlmInferenceService.ChatMessage.Role.TOOL, "result"); + fail("expected IllegalArgumentException"); + } catch (IllegalArgumentException expected) { + assertTrue(expected.getMessage().contains("toolResult")); + } + } + + @Test + public void llmConfigRejectsAMissingBackendId() { + try { + new LlmInferenceService.LlmConfig(null); + fail("expected IllegalArgumentException"); + } catch (IllegalArgumentException expected) { + assertTrue(expected.getMessage().contains("backendId")); + } + } + + @Test + public void llmResponseFailureCarriesErrorAndNoText() { + LlmInferenceService.LlmResponse response = LlmInferenceService.LlmResponse.failure("no model"); + + assertFalse(response.success); + assertNull(response.text); + assertEquals("no model", response.error); + } + + @Test + public void llmResponseSuccessCarriesTextAndNoError() { + LlmInferenceService.LlmResponse response = LlmInferenceService.LlmResponse.success("done", 12, 340L); + + assertTrue(response.success); + assertEquals("done", response.text); + assertNull(response.error); + assertEquals(12, response.tokensGenerated); + assertEquals(340L, response.timeMs); + } + + @Test + public void systemPromptRequestAcceptsNoCallSyntax() { + LlmInferenceService.SystemPromptRequest request = new LlmInferenceService.SystemPromptRequest(Collections.emptyList(), null, null); + + assertNull(request.toolCallSyntax); + assertNull(request.exampleFilePath); + } + + @Test + public void systemPromptRequestCoalescesNullToolsToAnEmptyList() { + LlmInferenceService.SystemPromptRequest request = new LlmInferenceService.SystemPromptRequest(null, "", "app/src/Main.kt"); + + assertTrue(request.tools.isEmpty()); + } + + @Test + public void systemPromptRequestCopiesTheToolList() { + List tools = new ArrayList<>(); + tools.add(new LlmInferenceService.ToolDefinition("read_file", "Reads a file", null)); + + LlmInferenceService.SystemPromptRequest request = new LlmInferenceService.SystemPromptRequest(tools, "", null); + tools.clear(); + + assertEquals(1, request.tools.size()); + assertEquals("read_file", request.tools.get(0).name); + } + + @Test + public void systemPromptRequestPublishesAnUnmodifiableToolList() { + LlmInferenceService.SystemPromptRequest request = new LlmInferenceService.SystemPromptRequest(Collections.emptyList(), "", null); + + try { + request.tools.add(new LlmInferenceService.ToolDefinition("x", "y", null)); + fail("expected UnsupportedOperationException"); + } catch (UnsupportedOperationException expected) { + // a backend must not add tools the consumer will not accept calls for + } + } + + @Test + public void toolCallRequestKeepsWhatTheModelAskedFor() { + Map args = new LinkedHashMap<>(); + args.put("path", "app/src/Main.kt"); + + LlmInferenceService.ToolCallRequest request = new LlmInferenceService.ToolCallRequest("call-1", "read_file", args); + + assertEquals("call-1", request.callId); + assertEquals("read_file", request.name); + assertEquals("app/src/Main.kt", request.args.get("path")); + } + + @Test + public void toolDefinitionAcceptsNoParameters() { + LlmInferenceService.ToolDefinition definition = new LlmInferenceService.ToolDefinition("build", "Builds the project", null); + + assertEquals("build", definition.name); + assertEquals("Builds the project", definition.description); + assertNull(definition.parametersSchema); + } + + @Test + public void toolResultCarriesBothCorrelators() { + LlmInferenceService.ChatMessage result = LlmInferenceService.ChatMessage.toolResult("call-1", "read_file", "file contents"); + + assertEquals(LlmInferenceService.ChatMessage.Role.TOOL, result.role); + assertEquals("file contents", result.content); + assertEquals("call-1", result.toolCallId); + assertEquals("read_file", result.toolName); + } + + @Test + public void toolResultRejectsAMissingCallId() { + try { + LlmInferenceService.ChatMessage.toolResult(null, "read_file", "file contents"); + fail("expected NullPointerException"); + } catch (NullPointerException expected) { + // without a call id the result cannot be matched to the call it answers + } + } +} diff --git a/plugin-api/src/test/kotlin/com/itsaky/androidide/plugins/PluginContextTest.kt b/plugin-api/src/test/kotlin/com/itsaky/androidide/plugins/PluginContextTest.kt index 23c3c33421..9bef521e22 100644 --- a/plugin-api/src/test/kotlin/com/itsaky/androidide/plugins/PluginContextTest.kt +++ b/plugin-api/src/test/kotlin/com/itsaky/androidide/plugins/PluginContextTest.kt @@ -1,383 +1,438 @@ package com.itsaky.androidide.plugins +import android.content.SharedPreferences +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue import org.junit.Test -import org.junit.Assert.* import java.io.File import java.io.InputStream class PluginContextTest { + /** + * Mock implementation of PluginContext for testing + */ + private class TestPluginContext : PluginContext { + private val serviceRegistry = TestServiceRegistry() + private val pluginLogger = TestPluginLogger() + private val resourceManager = TestResourceManager() + + override val androidContext: android.content.Context + get() = throw UnsupportedOperationException() + override val services: ServiceRegistry + get() = serviceRegistry + override val eventBus: Any + get() = Any() + override val logger: PluginLogger + get() = pluginLogger + override val resources: ResourceManager + get() = resourceManager + override val pluginId: String + get() = "test-plugin" + + private val pluginServices = mutableMapOf() + private val activePlugins = mutableSetOf() + private val pluginVersions = mutableMapOf() + private val lifecycleListeners = mutableListOf() + + override fun getPluginService( + pluginId: String, + serviceClass: Class, + ): T? = pluginServices[pluginId] as? T - /** - * Mock implementation of PluginContext for testing - */ - private class TestPluginContext : PluginContext { - private val serviceRegistry = TestServiceRegistry() - private val pluginLogger = TestPluginLogger() - private val resourceManager = TestResourceManager() - - override val androidContext: android.content.Context - get() = throw UnsupportedOperationException() - override val services: ServiceRegistry - get() = serviceRegistry - override val eventBus: Any - get() = Any() - override val logger: PluginLogger - get() = pluginLogger - override val resources: ResourceManager - get() = resourceManager - override val pluginId: String - get() = "test-plugin" - - private val pluginServices = mutableMapOf() - private val activePlugins = mutableSetOf() - private val pluginVersions = mutableMapOf() - private val lifecycleListeners = mutableListOf() + override fun isPluginActive(pluginId: String): Boolean = activePlugins.contains(pluginId) + + override fun getPluginVersion(pluginId: String): String? = pluginVersions[pluginId] - override fun getPluginService(pluginId: String, serviceClass: Class): T? { - return pluginServices[pluginId] as? T - } - - override fun isPluginActive(pluginId: String): Boolean { - return activePlugins.contains(pluginId) - } - - override fun getPluginVersion(pluginId: String): String? { - return pluginVersions[pluginId] - } - - override fun registerService(serviceClass: Class, serviceImpl: T) { - // Delegate to the backing registry, mirroring how production PluginContextImpl - // routes registerService() to its shared ServiceRegistry. - serviceRegistry.register(serviceClass, serviceImpl) - } - - override fun unregisterService(serviceClass: Class) { - serviceRegistry.unregister(serviceClass) - } - - override fun getProvidedServices(): List { - return emptyList() - } - - override fun getPluginDataDir(): File { - return File("/data/plugins/test-plugin") - } - - override fun addPluginLifecycleListener(listener: PluginLifecycleListener) { - lifecycleListeners.add(listener) - } - - override fun removePluginLifecycleListener(listener: PluginLifecycleListener) { - lifecycleListeners.remove(listener) - } - - fun addActivePlugin(pluginId: String) { - activePlugins.add(pluginId) - } - - fun setPluginVersion(pluginId: String, version: String) { - pluginVersions[pluginId] = version - } - - fun registerPluginService(pluginId: String, service: Any) { - pluginServices[pluginId] = service - } - - fun notifyPluginActivated(pluginId: String) { - lifecycleListeners.forEach { it.onPluginActivated(pluginId) } - } - - fun notifyPluginDeactivated(pluginId: String) { - lifecycleListeners.forEach { it.onPluginDeactivated(pluginId) } - } - - fun notifyPluginUninstalled(pluginId: String) { - lifecycleListeners.forEach { it.onPluginUninstalled(pluginId) } - } - - fun getListenerCount(): Int = lifecycleListeners.size - } - - private class TestServiceRegistry : ServiceRegistry { - private val services = mutableMapOf, MutableList>() - - override fun register(serviceClass: Class, implementation: T) { - services.computeIfAbsent(serviceClass) { mutableListOf() }.add(implementation as Any) - } - - override fun get(serviceClass: Class): T? { - return services[serviceClass]?.firstOrNull() as? T - } - - override fun getAll(serviceClass: Class): List { - return (services[serviceClass] ?: emptyList()).map { it as T } - } - - override fun unregister(serviceClass: Class<*>) { - services.remove(serviceClass) - } - } - - private class TestResourceManager : ResourceManager { - override fun getPluginDirectory(): File = File("/plugins/test") - - override fun getPluginFile(path: String): File = File("/plugins/test/$path") - - override fun getPluginResource(name: String): ByteArray? = null - - override fun openPluginResource(name: String): InputStream? = null - - override fun openPluginAsset(path: String): InputStream? = null - } - - private class TestPluginLogger : PluginLogger { - override val pluginId: String = "test-plugin" - - override fun debug(message: String) {} - override fun debug(message: String, error: Throwable) {} - override fun info(message: String) {} - override fun info(message: String, error: Throwable) {} - override fun warn(message: String) {} - override fun warn(message: String, error: Throwable) {} - override fun error(message: String) {} - override fun error(message: String, error: Throwable) {} - } - - @Test - fun testGetPluginServiceReturnsNullWhenNotFound() { - val context = TestPluginContext() - val result = context.getPluginService("unknown-plugin", String::class.java) - assertNull("getPluginService should return null when service not found", result) - } - - @Test - fun testGetPluginServiceReturnsServiceWhenRegistered() { - val context = TestPluginContext() - val testService = "test-service" - context.registerPluginService("ai-core", testService) - - val result = context.getPluginService("ai-core", String::class.java) - assertNotNull("getPluginService should return registered service", result) - assertEquals("Service should match registered value", testService, result) - } - - @Test - fun testIsPluginActiveReturnsFalseForInactivePlugin() { - val context = TestPluginContext() - val result = context.isPluginActive("unknown-plugin") - assertFalse("isPluginActive should return false for inactive plugin", result) - } - - @Test - fun testIsPluginActiveReturnsTrueForActivePlugin() { - val context = TestPluginContext() - context.addActivePlugin("ai-core") - val result = context.isPluginActive("ai-core") - assertTrue("isPluginActive should return true for active plugin", result) - } - - @Test - fun testGetPluginVersionReturnsNullWhenNotFound() { - val context = TestPluginContext() - val result = context.getPluginVersion("unknown-plugin") - assertNull("getPluginVersion should return null when version not found", result) - } - - @Test - fun testGetPluginVersionReturnsVersionWhenSet() { - val context = TestPluginContext() - context.setPluginVersion("ai-core", "1.0.0") - val result = context.getPluginVersion("ai-core") - assertNotNull("getPluginVersion should return version when set", result) - assertEquals("Version should match set value", "1.0.0", result) - } - - @Test - fun testRegisterServiceAddsServiceToRegistry() { - val context = TestPluginContext() - val testService = "test-service" - context.registerService(String::class.java, testService) - - val retrieved = context.services.get(String::class.java) - assertNotNull("Service should be retrievable after registration", retrieved) - assertEquals("Service should match registered value", testService, retrieved) - } - - @Test - fun testUnregisterServiceRemovesServiceFromRegistry() { - val context = TestPluginContext() - val testService = "test-service" - context.registerService(String::class.java, testService) - - context.unregisterService(String::class.java) - val retrieved = context.services.get(String::class.java) - assertNull("Service should be null after unregistration", retrieved) - } - - @Test - fun testGetProvidedServicesReturnsEmptyList() { - val context = TestPluginContext() - val services = context.getProvidedServices() - assertNotNull("getProvidedServices should not return null", services) - assertEquals("getProvidedServices should return empty list initially", 0, services.size) - } - - @Test - fun testGetPluginDataDirReturnsValidDirectory() { - val context = TestPluginContext() - val dir = context.getPluginDataDir() - assertNotNull("getPluginDataDir should not return null", dir) - assertTrue("Plugin data dir should contain plugin ID", dir.path.contains("test-plugin")) - } - - @Test - fun testAddPluginLifecycleListenerAddsListener() { - val context = TestPluginContext() - val listener = object : PluginLifecycleListener { - override fun onPluginActivated(pluginId: String) {} - override fun onPluginDeactivated(pluginId: String) {} - override fun onPluginUninstalled(pluginId: String) {} - } - - assertEquals("Should have no listeners initially", 0, context.getListenerCount()) - context.addPluginLifecycleListener(listener) - assertEquals("Should have one listener after adding", 1, context.getListenerCount()) - } - - @Test - fun testRemovePluginLifecycleListenerRemovesListener() { - val context = TestPluginContext() - val listener = object : PluginLifecycleListener { - override fun onPluginActivated(pluginId: String) {} - override fun onPluginDeactivated(pluginId: String) {} - override fun onPluginUninstalled(pluginId: String) {} - } - - context.addPluginLifecycleListener(listener) - assertEquals("Should have one listener after adding", 1, context.getListenerCount()) - context.removePluginLifecycleListener(listener) - assertEquals("Should have no listeners after removing", 0, context.getListenerCount()) - } - - @Test - fun testLifecycleListenerNotificationOnPluginActivated() { - val context = TestPluginContext() - var notificationReceived: String? = null - - val listener = object : PluginLifecycleListener { - override fun onPluginActivated(pluginId: String) { - notificationReceived = pluginId - } - override fun onPluginDeactivated(pluginId: String) {} - override fun onPluginUninstalled(pluginId: String) {} - } - - context.addPluginLifecycleListener(listener) - context.notifyPluginActivated("ai-core") - assertEquals("Should receive onPluginActivated notification", "ai-core", notificationReceived) - } - - @Test - fun testLifecycleListenerNotificationOnPluginDeactivated() { - val context = TestPluginContext() - var notificationReceived: String? = null - - val listener = object : PluginLifecycleListener { - override fun onPluginActivated(pluginId: String) {} - override fun onPluginDeactivated(pluginId: String) { - notificationReceived = pluginId - } - override fun onPluginUninstalled(pluginId: String) {} - } - - context.addPluginLifecycleListener(listener) - context.notifyPluginDeactivated("ai-chat-agent") - assertEquals("Should receive onPluginDeactivated notification", "ai-chat-agent", notificationReceived) - } - - @Test - fun testLifecycleListenerNotificationOnPluginUninstalled() { - val context = TestPluginContext() - var notificationReceived: String? = null - - val listener = object : PluginLifecycleListener { - override fun onPluginActivated(pluginId: String) {} - override fun onPluginDeactivated(pluginId: String) {} - override fun onPluginUninstalled(pluginId: String) { - notificationReceived = pluginId - } - } - - context.addPluginLifecycleListener(listener) - context.notifyPluginUninstalled("ai-tools") - assertEquals("Should receive onPluginUninstalled notification", "ai-tools", notificationReceived) - } - - @Test - fun testMultipleLifecycleListenersReceiveNotifications() { - val context = TestPluginContext() - val activatedPlugins = mutableListOf() - - val listener1 = object : PluginLifecycleListener { - override fun onPluginActivated(pluginId: String) { - activatedPlugins.add("listener1:$pluginId") - } - override fun onPluginDeactivated(pluginId: String) {} - override fun onPluginUninstalled(pluginId: String) {} - } - - val listener2 = object : PluginLifecycleListener { - override fun onPluginActivated(pluginId: String) { - activatedPlugins.add("listener2:$pluginId") - } - override fun onPluginDeactivated(pluginId: String) {} - override fun onPluginUninstalled(pluginId: String) {} - } - - context.addPluginLifecycleListener(listener1) - context.addPluginLifecycleListener(listener2) - context.notifyPluginActivated("ai-core") - - assertEquals("Both listeners should receive notification", 2, activatedPlugins.size) - assertTrue("First listener should be notified", activatedPlugins.contains("listener1:ai-core")) - assertTrue("Second listener should be notified", activatedPlugins.contains("listener2:ai-core")) - } - - @Test - fun testServiceRegistryGetAllReturnsEmptyListWhenNoServicesRegistered() { - val registry = TestServiceRegistry() - val services = registry.getAll(String::class.java) - assertNotNull("getAll should not return null", services) - assertEquals("getAll should return empty list when no services registered", 0, services.size) - } - - @Test - fun testServiceRegistryGetAllReturnsAllRegisteredServices() { - val registry = TestServiceRegistry() - val service1 = "service1" - val service2 = "service2" - - registry.register(String::class.java, service1) - registry.register(String::class.java, service2) - - val services = registry.getAll(String::class.java) - assertNotNull("getAll should not return null", services) - assertEquals("getAll should return all registered services", 2, services.size) - assertTrue("Should contain first service", services.contains(service1)) - assertTrue("Should contain second service", services.contains(service2)) - } - - @Test - fun testResourceManagerReturnsNullForMissingResource() { - val manager = TestResourceManager() - val resource = manager.getPluginResource("missing.dat") - assertNull("getPluginResource should return null for missing resource", resource) - } - - @Test - fun testResourceManagerReturnsNullForMissingAsset() { - val manager = TestResourceManager() - val asset = manager.openPluginAsset("missing/asset.bin") - assertNull("openPluginAsset should return null for missing asset", asset) - } + override fun registerService( + serviceClass: Class, + serviceImpl: T, + ) { + // Delegate to the backing registry, mirroring how production PluginContextImpl + // routes registerService() to its shared ServiceRegistry. + serviceRegistry.register(serviceClass, serviceImpl) + } + + override fun unregisterService(serviceClass: Class) { + serviceRegistry.unregister(serviceClass) + } + + override fun getProvidedServices(): List = emptyList() + + override fun getPluginDataDir(): File = File("/data/plugins/test-plugin") + + override fun getAppFilesDir(): File = File("/data/files") + + override fun getPluginFilesDir(): File = File("/data/files/plugins/test-plugin") + + // SharedPreferences is an Android type with no JVM implementation to stub + // here; the preference-backed paths are covered by instrumented tests. + override fun getAppSharedPreferences(prefsName: String): SharedPreferences? = null + + override fun getPluginSharedPreferences(prefsName: String): SharedPreferences = throw UnsupportedOperationException() + + override fun addPluginLifecycleListener(listener: PluginLifecycleListener) { + lifecycleListeners.add(listener) + } + + override fun removePluginLifecycleListener(listener: PluginLifecycleListener) { + lifecycleListeners.remove(listener) + } + + fun addActivePlugin(pluginId: String) { + activePlugins.add(pluginId) + } + + fun setPluginVersion( + pluginId: String, + version: String, + ) { + pluginVersions[pluginId] = version + } + + fun registerPluginService( + pluginId: String, + service: Any, + ) { + pluginServices[pluginId] = service + } + + fun notifyPluginActivated(pluginId: String) { + lifecycleListeners.forEach { it.onPluginActivated(pluginId) } + } + + fun notifyPluginDeactivated(pluginId: String) { + lifecycleListeners.forEach { it.onPluginDeactivated(pluginId) } + } + + fun notifyPluginUninstalled(pluginId: String) { + lifecycleListeners.forEach { it.onPluginUninstalled(pluginId) } + } + + fun getListenerCount(): Int = lifecycleListeners.size + } + + private class TestServiceRegistry : ServiceRegistry { + private val services = mutableMapOf, MutableList>() + + override fun register( + serviceClass: Class, + implementation: T, + ) { + services.computeIfAbsent(serviceClass) { mutableListOf() }.add(implementation as Any) + } + + override fun get(serviceClass: Class): T? = services[serviceClass]?.firstOrNull() as? T + + override fun getAll(serviceClass: Class): List = (services[serviceClass] ?: emptyList()).map { it as T } + + override fun unregister(serviceClass: Class<*>) { + services.remove(serviceClass) + } + } + + private class TestResourceManager : ResourceManager { + override fun getPluginDirectory(): File = File("/plugins/test") + + override fun getPluginFile(path: String): File = File("/plugins/test/$path") + + override fun getPluginResource(name: String): ByteArray? = null + + override fun openPluginResource(name: String): InputStream? = null + + override fun openPluginAsset(path: String): InputStream? = null + } + + private class TestPluginLogger : PluginLogger { + override val pluginId: String = "test-plugin" + + override fun debug(message: String) {} + + override fun debug( + message: String, + error: Throwable, + ) {} + + override fun info(message: String) {} + + override fun info( + message: String, + error: Throwable, + ) {} + + override fun warn(message: String) {} + + override fun warn( + message: String, + error: Throwable, + ) {} + + override fun error(message: String) {} + + override fun error( + message: String, + error: Throwable, + ) {} + } + + @Test + fun testGetPluginServiceReturnsNullWhenNotFound() { + val context = TestPluginContext() + val result = context.getPluginService("unknown-plugin", String::class.java) + assertNull("getPluginService should return null when service not found", result) + } + + @Test + fun testGetPluginServiceReturnsServiceWhenRegistered() { + val context = TestPluginContext() + val testService = "test-service" + context.registerPluginService("ai-core", testService) + + val result = context.getPluginService("ai-core", String::class.java) + assertNotNull("getPluginService should return registered service", result) + assertEquals("Service should match registered value", testService, result) + } + + @Test + fun testIsPluginActiveReturnsFalseForInactivePlugin() { + val context = TestPluginContext() + val result = context.isPluginActive("unknown-plugin") + assertFalse("isPluginActive should return false for inactive plugin", result) + } + + @Test + fun testIsPluginActiveReturnsTrueForActivePlugin() { + val context = TestPluginContext() + context.addActivePlugin("ai-core") + val result = context.isPluginActive("ai-core") + assertTrue("isPluginActive should return true for active plugin", result) + } + + @Test + fun testGetPluginVersionReturnsNullWhenNotFound() { + val context = TestPluginContext() + val result = context.getPluginVersion("unknown-plugin") + assertNull("getPluginVersion should return null when version not found", result) + } + + @Test + fun testGetPluginVersionReturnsVersionWhenSet() { + val context = TestPluginContext() + context.setPluginVersion("ai-core", "1.0.0") + val result = context.getPluginVersion("ai-core") + assertNotNull("getPluginVersion should return version when set", result) + assertEquals("Version should match set value", "1.0.0", result) + } + + @Test + fun testRegisterServiceAddsServiceToRegistry() { + val context = TestPluginContext() + val testService = "test-service" + context.registerService(String::class.java, testService) + + val retrieved = context.services.get(String::class.java) + assertNotNull("Service should be retrievable after registration", retrieved) + assertEquals("Service should match registered value", testService, retrieved) + } + + @Test + fun testUnregisterServiceRemovesServiceFromRegistry() { + val context = TestPluginContext() + val testService = "test-service" + context.registerService(String::class.java, testService) + + context.unregisterService(String::class.java) + val retrieved = context.services.get(String::class.java) + assertNull("Service should be null after unregistration", retrieved) + } + + @Test + fun testGetProvidedServicesReturnsEmptyList() { + val context = TestPluginContext() + val services = context.getProvidedServices() + assertNotNull("getProvidedServices should not return null", services) + assertEquals("getProvidedServices should return empty list initially", 0, services.size) + } + + @Test + fun testGetPluginDataDirReturnsValidDirectory() { + val context = TestPluginContext() + val dir = context.getPluginDataDir() + assertNotNull("getPluginDataDir should not return null", dir) + assertTrue("Plugin data dir should contain plugin ID", dir.path.contains("test-plugin")) + } + + @Test + fun testAddPluginLifecycleListenerAddsListener() { + val context = TestPluginContext() + val listener = + object : PluginLifecycleListener { + override fun onPluginActivated(pluginId: String) {} + + override fun onPluginDeactivated(pluginId: String) {} + + override fun onPluginUninstalled(pluginId: String) {} + } + + assertEquals("Should have no listeners initially", 0, context.getListenerCount()) + context.addPluginLifecycleListener(listener) + assertEquals("Should have one listener after adding", 1, context.getListenerCount()) + } + + @Test + fun testRemovePluginLifecycleListenerRemovesListener() { + val context = TestPluginContext() + val listener = + object : PluginLifecycleListener { + override fun onPluginActivated(pluginId: String) {} + + override fun onPluginDeactivated(pluginId: String) {} + + override fun onPluginUninstalled(pluginId: String) {} + } + + context.addPluginLifecycleListener(listener) + assertEquals("Should have one listener after adding", 1, context.getListenerCount()) + context.removePluginLifecycleListener(listener) + assertEquals("Should have no listeners after removing", 0, context.getListenerCount()) + } + + @Test + fun testLifecycleListenerNotificationOnPluginActivated() { + val context = TestPluginContext() + var notificationReceived: String? = null + + val listener = + object : PluginLifecycleListener { + override fun onPluginActivated(pluginId: String) { + notificationReceived = pluginId + } + + override fun onPluginDeactivated(pluginId: String) {} + + override fun onPluginUninstalled(pluginId: String) {} + } + + context.addPluginLifecycleListener(listener) + context.notifyPluginActivated("ai-core") + assertEquals("Should receive onPluginActivated notification", "ai-core", notificationReceived) + } + + @Test + fun testLifecycleListenerNotificationOnPluginDeactivated() { + val context = TestPluginContext() + var notificationReceived: String? = null + + val listener = + object : PluginLifecycleListener { + override fun onPluginActivated(pluginId: String) {} + + override fun onPluginDeactivated(pluginId: String) { + notificationReceived = pluginId + } + + override fun onPluginUninstalled(pluginId: String) {} + } + + context.addPluginLifecycleListener(listener) + context.notifyPluginDeactivated("ai-chat-agent") + assertEquals("Should receive onPluginDeactivated notification", "ai-chat-agent", notificationReceived) + } + + @Test + fun testLifecycleListenerNotificationOnPluginUninstalled() { + val context = TestPluginContext() + var notificationReceived: String? = null + + val listener = + object : PluginLifecycleListener { + override fun onPluginActivated(pluginId: String) {} + + override fun onPluginDeactivated(pluginId: String) {} + + override fun onPluginUninstalled(pluginId: String) { + notificationReceived = pluginId + } + } + + context.addPluginLifecycleListener(listener) + context.notifyPluginUninstalled("ai-tools") + assertEquals("Should receive onPluginUninstalled notification", "ai-tools", notificationReceived) + } + + @Test + fun testMultipleLifecycleListenersReceiveNotifications() { + val context = TestPluginContext() + val activatedPlugins = mutableListOf() + + val listener1 = + object : PluginLifecycleListener { + override fun onPluginActivated(pluginId: String) { + activatedPlugins.add("listener1:$pluginId") + } + + override fun onPluginDeactivated(pluginId: String) {} + + override fun onPluginUninstalled(pluginId: String) {} + } + + val listener2 = + object : PluginLifecycleListener { + override fun onPluginActivated(pluginId: String) { + activatedPlugins.add("listener2:$pluginId") + } + + override fun onPluginDeactivated(pluginId: String) {} + + override fun onPluginUninstalled(pluginId: String) {} + } + + context.addPluginLifecycleListener(listener1) + context.addPluginLifecycleListener(listener2) + context.notifyPluginActivated("ai-core") + + assertEquals("Both listeners should receive notification", 2, activatedPlugins.size) + assertTrue("First listener should be notified", activatedPlugins.contains("listener1:ai-core")) + assertTrue("Second listener should be notified", activatedPlugins.contains("listener2:ai-core")) + } + + @Test + fun testServiceRegistryGetAllReturnsEmptyListWhenNoServicesRegistered() { + val registry = TestServiceRegistry() + val services = registry.getAll(String::class.java) + assertNotNull("getAll should not return null", services) + assertEquals("getAll should return empty list when no services registered", 0, services.size) + } + + @Test + fun testServiceRegistryGetAllReturnsAllRegisteredServices() { + val registry = TestServiceRegistry() + val service1 = "service1" + val service2 = "service2" + + registry.register(String::class.java, service1) + registry.register(String::class.java, service2) + + val services = registry.getAll(String::class.java) + assertNotNull("getAll should not return null", services) + assertEquals("getAll should return all registered services", 2, services.size) + assertTrue("Should contain first service", services.contains(service1)) + assertTrue("Should contain second service", services.contains(service2)) + } + + @Test + fun testResourceManagerReturnsNullForMissingResource() { + val manager = TestResourceManager() + val resource = manager.getPluginResource("missing.dat") + assertNull("getPluginResource should return null for missing resource", resource) + } + + @Test + fun testResourceManagerReturnsNullForMissingAsset() { + val manager = TestResourceManager() + val asset = manager.openPluginAsset("missing/asset.bin") + assertNull("openPluginAsset should return null for missing asset", asset) + } }