diff --git a/docs/sdk/kotlin/client/overview.mdx b/docs/sdk/kotlin/client/overview.mdx index 6a582eb11..b89f6662e 100644 --- a/docs/sdk/kotlin/client/overview.mdx +++ b/docs/sdk/kotlin/client/overview.mdx @@ -65,12 +65,17 @@ Real-time event streaming using Kotlin Flows: - Server-sent events (SSE) parsing - Automatic reconnection handling - Backpressure management +- Automatic expansion of `TEXT_MESSAGE_CHUNK` / `TOOL_CALL_CHUNK` events into start/content/end triads +- Thinking telemetry exposed through `AgentState.thinking` ### State Management Comprehensive state synchronization: - JSON Patch-based state updates - Automatic state validation - Error state handling +- Tool call results surfaced as `ToolMessage` entries without additional wiring +- Access to raw/custom protocol events via `AgentState.rawEvents` and `AgentState.customEvents` +- Thinking streams exposed through `AgentState.thinking` ### Tool Integration Client-side tool execution framework: @@ -108,6 +113,21 @@ agent.sendMessage("Hello!").collect { state -> } ``` +### Reading Thinking Telemetry + +```kotlin +agent.sendMessage("Plan the next steps").collect { state -> + state.thinking?.let { thinking -> + if (thinking.isThinking) { + val thought = thinking.messages.lastOrNull().orEmpty() + println("🤔 Agent thinking: $thought") + } else if (thinking.messages.isNotEmpty()) { + println("💡 Agent finished thinking: ${thinking.messages.joinToString()}") + } + } +} +``` + ### Convenience Builders The SDK provides convenience builders for common configurations: @@ -139,6 +159,12 @@ val chatAgent = StatefulAgUiAgent("https://api.example.com/agent") { chatAgent.chat("My name is Alice").collect { } chatAgent.chat("What's my name?").collect { state -> // Agent knows the name from previous message + state.customEvents?.forEach { custom -> + println("Custom event ${custom.name}: ${custom.value}") + } + state.rawEvents?.forEach { raw -> + println("Raw payload: ${raw.event}") + } } ``` @@ -170,4 +196,4 @@ val agent = AgUiAgent("https://api.example.com/agent") { - Initial state setup - State validation rules - Update strategies -- Persistence options \ No newline at end of file +- Persistence options diff --git a/docs/sdk/kotlin/overview.mdx b/docs/sdk/kotlin/overview.mdx index 15c766240..c63d8d2ed 100644 --- a/docs/sdk/kotlin/overview.mdx +++ b/docs/sdk/kotlin/overview.mdx @@ -106,6 +106,13 @@ chatAgent.chat("What's my name?").collect { state -> } ``` +Chunked protocol events (`TEXT_MESSAGE_CHUNK`, `TOOL_CALL_CHUNK`) are automatically rewritten into +their corresponding start/content/end sequences, so Kotlin clients see the same structured events +as non-chunked streams. + +Thinking telemetry (`THINKING_*` events) is surfaced alongside normal messages, allowing UIs to indicate +when an agent is reasoning internally before responding. + ### Client-Side Tool Integration ```kotlin @@ -172,4 +179,22 @@ println("Messages: ${currentState.messages.size}") agent.sendMessage("Hello").collect { state -> println("Updated state: ${state.messages.last()}") } -``` \ No newline at end of file + +// RAW and CUSTOM protocol events are surfaced for inspection +state.rawEvents?.forEach { raw -> + println("Raw event from ${raw.source ?: "unknown"}: ${raw.event}") +} +state.customEvents?.forEach { custom -> + println("Custom event ${custom.name}: ${custom.value}") +} + +// Thinking telemetry stream +state.thinking?.let { thinking -> + if (thinking.isThinking) { + val latest = thinking.messages.lastOrNull().orEmpty() + println("Agent is thinking: $latest") + } else if (thinking.messages.isNotEmpty()) { + println("Agent finished thinking: ${thinking.messages.joinToString()}") + } +} +``` diff --git a/sdks/community/kotlin/.gitignore b/sdks/community/kotlin/.gitignore index 2add404a0..2539f0142 100644 --- a/sdks/community/kotlin/.gitignore +++ b/sdks/community/kotlin/.gitignore @@ -42,9 +42,10 @@ captures/ *.perspectivev3 **/*.xcworkspace/xcuserdata/ **/*.xcodeproj/xcuserdata/ -*.xccheckout -*.xcscmblueprint -DerivedData/ +*.xccheckout +*.xcscmblueprint +*.xcsettings +DerivedData/ *.hmap *.ipa *.dSYM.zip @@ -101,4 +102,4 @@ temp/ *.rar # Virtual machine crash logs -hs_err_pid* \ No newline at end of file +hs_err_pid* diff --git a/sdks/community/kotlin/CHANGELOG.md b/sdks/community/kotlin/CHANGELOG.md index e3a5e32df..81002bef0 100644 --- a/sdks/community/kotlin/CHANGELOG.md +++ b/sdks/community/kotlin/CHANGELOG.md @@ -4,10 +4,23 @@ - Smaller binary sizes due to better optimization - Improved coroutine performance with latest kotlinx.coroutines# Changelog -All notable changes to ag-ui-4k will be documented in this file. - -The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), -and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +All notable changes to ag-ui-4k will be documented in this file. + +The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), +and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). + +## [Unreleased] + +### Added +- Agent subscriber system for opt-in lifecycle and event interception. +- Text message role fidelity in chunk transformation and state application. + +### Changed +- Default apply pipeline now routes every event through subscribers before mutating state. +- State application respects developer/system/user roles when constructing streaming messages. + +### Tests +- Expanded chunk transformation and state application coverage for role propagation and subscriber behavior. ## [0.1.0] - 2025-06-14 diff --git a/sdks/community/kotlin/OVERVIEW.md b/sdks/community/kotlin/OVERVIEW.md index 8650adcdc..fa8422b12 100644 --- a/sdks/community/kotlin/OVERVIEW.md +++ b/sdks/community/kotlin/OVERVIEW.md @@ -21,4 +21,9 @@ AG-UI Kotlin SDK follows the design patterns of the TypeScript SDK while leverag - **kotlin-client**: HTTP transport, state management, and high-level agent APIs - **kotlin-tools**: Tool execution framework with registry and circuit breakers -The SDK maintains conceptual parity with the TypeScript implementation while providing native Kotlin idioms like sealed classes, suspend functions, and Kotlin Flows for streaming responses. +The SDK maintains conceptual parity with the TypeScript implementation while providing native Kotlin idioms like sealed classes, suspend functions, and Kotlin Flows for streaming responses. + +## Lifecycle subscribers and role fidelity + +- **AgentSubscriber hooks** – Agents now expose a subscription API so applications can observe run initialization, per-event delivery, and state mutations before the built-in handlers execute. This enables cross-cutting concerns like analytics, tracing, or custom persistence without forking the pipeline. +- **Role-aware text streaming** – Text message events preserve their declared roles (developer, system, assistant, user) throughout chunk transformation and state application, ensuring downstream UI state mirrors the protocol payloads exactly. diff --git a/sdks/community/kotlin/README.md b/sdks/community/kotlin/README.md index 5a32254e9..96d533594 100644 --- a/sdks/community/kotlin/README.md +++ b/sdks/community/kotlin/README.md @@ -21,7 +21,7 @@ The comprehensive documentation covers: ```kotlin dependencies { - implementation("com.agui:kotlin-client:0.2.1") + implementation("com.agui:kotlin-client:0.2.3") } ``` diff --git a/sdks/community/kotlin/build.gradle.kts b/sdks/community/kotlin/build.gradle.kts index 8bcabc48c..40e89eb18 100644 --- a/sdks/community/kotlin/build.gradle.kts +++ b/sdks/community/kotlin/build.gradle.kts @@ -1,8 +1,8 @@ import org.jetbrains.kotlin.gradle.targets.js.dsl.ExperimentalWasmDsl -plugins { - kotlin("multiplatform") version "2.1.21" - kotlin("plugin.serialization") version "2.1.21" +plugins { + kotlin("multiplatform") version "2.2.20" + kotlin("plugin.serialization") version "2.2.20" id("com.android.library") version "8.2.2" id("io.gitlab.arturbosch.detekt") version "1.23.4" id("maven-publish") @@ -34,8 +34,8 @@ kotlin { freeCompilerArgs.add("-Xopt-in=kotlin.RequiresOptIn") freeCompilerArgs.add("-Xopt-in=kotlinx.coroutines.ExperimentalCoroutinesApi") freeCompilerArgs.add("-Xopt-in=kotlinx.serialization.ExperimentalSerializationApi") - languageVersion.set(org.jetbrains.kotlin.gradle.dsl.KotlinVersion.KOTLIN_2_1) - apiVersion.set(org.jetbrains.kotlin.gradle.dsl.KotlinVersion.KOTLIN_2_1) + languageVersion.set(org.jetbrains.kotlin.gradle.dsl.KotlinVersion.KOTLIN_2_2) + apiVersion.set(org.jetbrains.kotlin.gradle.dsl.KotlinVersion.KOTLIN_2_2) } } } @@ -226,4 +226,4 @@ tasks.withType().configureEach { sarif.required.set(true) md.required.set(true) } -} \ No newline at end of file +} diff --git a/sdks/community/kotlin/examples/chatapp-java/README.md b/sdks/community/kotlin/examples/chatapp-java/README.md index bd7a34ff3..e9ade0852 100644 --- a/sdks/community/kotlin/examples/chatapp-java/README.md +++ b/sdks/community/kotlin/examples/chatapp-java/README.md @@ -1,169 +1,80 @@ -# AG-UI Kotlin SDK Java Chat App +# AG-UI Android (Views) Sample -A Java-based Android chat application demonstrating how to use the Kotlin Multiplatform AG-UI libraries from pure Java code using Android View system and Material 3 design. +Android View-based chat client that consumes the shared Kotlin Multiplatform core (`chatapp-shared`) while keeping the UI in the traditional XML/ViewBinding stack. The screens remain Java-friendly, but the business logic, agent storage, and streaming behaviour now come directly from the shared Kotlin module used by the Compose and SwiftUI samples. -## Features +## Highlights -- 🏗️ **Pure Java Implementation**: Demonstrates Kotlin/Java interop with KMP libraries -- 🎨 **Material 3 Design**: Uses Material Design Components for Android (not Compose) -- 📱 **Android View System**: Traditional Android View-based UI instead of Compose -- 🔄 **RxJava Integration**: Converts Kotlin Flow to RxJava Observable for Java consumption -- 🔐 **Authentication Support**: Bearer Token, API Key, and no-auth options -- 💬 **Real-time Streaming**: Character-by-character streaming responses -- ⚙️ **Settings Screen**: Configure agent URL, authentication, and system prompts -- 📱 **MVVM Architecture**: Uses Android Architecture Components (ViewModel, LiveData) +- ♻️ **Shared Core** – Reuses the `chatapp-shared` module for repositories, auth, chat orchestration, tool confirmation, and storage. +- 🧱 **Views + ViewModel** – UI stays on XML/ViewBinding with `ChatActivity` in Java calling into a Kotlin `ChatViewModel`/`ChatController` bridge. +- 🧑‍🤝‍🧑 **Multi-agent settings** – Same agent CRUD experience as other samples, backed by `AgentRepository` and exposed through a new Kotlin `MultiAgentRepository` wrapper. +- ⚙️ **Zero RxJava** – Pure coroutines/LiveData interop; no bespoke Java adapters around the AG‑UI flows. +- 🧩 **Kotlin + Java interop** – Kotlin files provide the glue (ViewModel, repository, list adapter), while the chat screen remains Java to demonstrate interop ergonomics. -## Architecture - -This example demonstrates how to use the AG-UI Kotlin SDK from Java without any modifications to the KMP libraries: +## Project Layout ``` -Java Application Layer -├── UI (Activities, Fragments) -├── ViewModels (Java + Architecture Components) -├── Repository (SharedPreferences) -└── Java Adapter Layer - ├── AgUiJavaAdapter (Flow → RxJava conversion) - ├── AgUiAgentBuilder (Java-friendly builder) - └── EventProcessor (Type-safe event handling) - -Kotlin Multiplatform Libraries (Unchanged) -├── kotlin-client (AgUiAgent, StatefulAgUiAgent) -├── kotlin-core (Message types, Events) -└── kotlin-tools (Tool execution framework) +chatapp-java/ +├── app/ +│ ├── src/main/java/com/agui/chatapp/java/ +│ │ ├── ChatJavaApplication.kt // Initialises shared platform settings +│ │ ├── repository/ // Kotlin wrapper around chatapp-shared AgentRepository +│ │ ├── viewmodel/ // Kotlin ChatViewModel exposing LiveData to Java UI +│ │ ├── ui/ // ChatActivity (Java) + SettingsActivity (Kotlin) +│ │ │ └── adapter/ // MessageAdapter (Java) + AgentListAdapter (Kotlin) +│ │ └── model/ChatMessage.kt // UI-friendly view state built from DisplayMessage +│ └── src/main/res/... // Unchanged Material 3 XML layouts +├── settings.gradle // Includes :chatapp-shared via composite build +└── build.gradle // Adds Kotlin plugin + AGP 8.12 ``` -## Key Integration Components - -### 1. Java Adapter Layer -- **AgUiJavaAdapter**: Converts Kotlin Flow to RxJava Observable -- **AgUiAgentBuilder**: Provides Java builder pattern for agent configuration -- **EventProcessor**: Type-safe event handling for sealed classes - -### 2. Interop Libraries Used -- `kotlinx-coroutines-reactive`: Converts Flow to RxJava -- `kotlinx-coroutines-jdk8`: CompletableFuture integration -- `rxjava3`: Reactive streams for Java - -### 3. Material 3 Components -- MaterialToolbar -- MaterialCardView for message bubbles -- TextInputLayout with Material styling -- MaterialButton and MaterialSwitch -- LinearProgressIndicator -- Snackbar for notifications - -## Building and Running - -### Prerequisites -- Android Studio Arctic Fox or later -- JDK 21 -- Android SDK 35 (API level 35) -- AG-UI Kotlin SDK libraries published to Maven Local - -### Build Steps - -1. **Publish KMP libraries locally** (from `/library` directory): - ```bash - ./gradlew publishToMavenLocal - ``` - -2. **Open the project** in Android Studio: - ```bash - # From this directory - open . - # Or import the project in Android Studio - ``` - -3. **Build and run**: - ```bash - ./gradlew :app:assembleDebug - ./gradlew :app:installDebug - ``` - -## Usage - -1. **Launch the app** - you'll see a prompt to configure an agent -2. **Tap "Settings"** to configure your agent: - - Enter your agent URL - - Select authentication type (None, Bearer Token, or API Key) - - Enter authentication credentials if needed - - Optionally set a system prompt - - Test the connection - - Save settings -3. **Return to chat** and start messaging with your agent -4. **View real-time responses** with character-by-character streaming - -## Code Structure +## How the pieces fit ``` -app/src/main/java/com/agui/chatapp/java/ -├── adapter/ # Kotlin-Java interop layer -│ ├── AgUiJavaAdapter.java # Flow → RxJava converter -│ ├── AgUiAgentBuilder.java # Java-friendly builder -│ ├── EventCallback.java # Callback interface -│ └── EventProcessor.java # Event type dispatcher -├── model/ -│ └── ChatMessage.java # UI model wrapping Message -├── repository/ -│ └── AgentRepository.java # SharedPreferences storage -├── ui/ -│ ├── ChatActivity.java # Main chat screen -│ ├── SettingsActivity.java # Agent configuration -│ └── adapter/ -│ └── MessageAdapter.java # RecyclerView adapter -└── viewmodel/ - └── ChatViewModel.java # MVVM ViewModel + Java UI (ChatActivity) ─────────┐ + ↑ │ LiveData + │ uses ViewModelProvider │ + │ ▼ +Kotlin ChatViewModel (AndroidViewModel) ── ChatController (chatapp-shared) + │ │ + │ coroutines/flows │ + ▼ ▼ +Kotlin MultiAgentRepository ─── AgentRepository (chatapp-shared) ``` -## Key Integration Techniques +- `ChatController` handles streaming, tool confirmation, auth, and message state. +- `AgentRepository` (shared) owns persistent agents; `MultiAgentRepository` wraps it with LiveData/CompletableFuture for the Java UI. +- `ChatMessage.kt` converts `DisplayMessage` objects into a RecyclerView-friendly model. -### 1. Flow to RxJava Conversion -```java -// Convert Kotlin Flow to RxJava Observable -Flow kotlinFlow = agent.chat(message); -Observable javaObservable = - Observable.fromPublisher(ReactiveFlowKt.asPublisher(kotlinFlow)); -``` +## Prerequisites -### 2. Builder Pattern for Configuration -```java -// Java-friendly builder instead of Kotlin DSL -AgUiAgent agent = AgUiAgentBuilder.create(url) - .bearerToken("token") - .systemPrompt("You are helpful") - .debug(true) - .buildStateful(); -``` +- Android SDK / command-line tools installed (`sdk.dir` in `local.properties` or `ANDROID_HOME` env var). +- JDK 21. +- Kotlin Gradle plugin 2.2.20 (pulled automatically via plugin management). + +## Building -### 3. Type-Safe Event Handling -```java -// Handle Kotlin sealed classes in Java -EventProcessor.processEvent(event, new EventProcessor.EventHandler() { - @Override - public void onTextMessageContent(TextMessageContentEvent event) { - updateMessage(event.getMessageId(), event.getDelta()); - } - // ... other event handlers -}); +```bash +# From chatapp-java/ +./gradlew :chatapp-shared:assemble # optional: prebuild shared core +./gradlew :app:assembleDebug # build the Android sample ``` -## Dependencies +> ℹ️ The shared core expects an Android context. `ChatJavaApplication` calls `initializeAndroid(this)` on startup. -The app demonstrates pure Java consumption of KMP libraries: +## Updating agents from the UI -- **KMP Libraries**: `kotlin-client`, `kotlin-core`, `kotlin-tools` -- **Interop**: `kotlinx-coroutines-reactive`, `kotlinx-coroutines-jdk8` -- **Java Reactive**: `rxjava3`, `rxandroid` -- **Android**: Architecture Components, Material 3 -- **Storage**: SharedPreferences for persistence +- Open Settings ➜ add/edit/delete agents (auth types: None, API Key, Bearer, Basic). +- Activating an agent calls `AgentRepository.setActiveAgent`, which immediately reconnects the `ChatController`. +- System prompts and auth headers are stored via multiplatform `Settings` (shared Preferences on Android). -## Benefits +## What changed vs. the original Java sample -- **No KMP Library Changes**: Uses libraries as-is without modifications -- **Java Team Friendly**: Standard Java patterns and Android Views -- **Material 3**: Modern design with traditional View system -- **Full Feature Parity**: Supports all KMP library features -- **Type Safety**: Maintains type safety across language boundaries +| Area | Before | Now | +|--------------------|--------------------------------------|-----| +| Business logic | Hand-rolled Java repository & adapter| Shared `chatapp-shared` module | +| Streaming bridge | RxJava wrapper over Flow | Direct `ChatController` + LiveData | +| Auth models | Custom Java `AuthMethod` | Shared KMP `AuthMethod` | +| Agent storage | SharedPreferences manual schema | Shared multiplatform `AgentRepository` | +| UI stack | Java Activities | Chat screen still Java; settings + adapters moved to Kotlin for convenience | -This example proves that teams can adopt AG-UI Kotlin SDK incrementally, starting with Java and migrating to Kotlin/Compose when ready, without requiring changes to the core libraries. \ No newline at end of file +The sample now mirrors the Compose/SwiftUI architecture while keeping a classical Android view layer for teams that are not ready for Compose. diff --git a/sdks/community/kotlin/examples/chatapp-java/app/build.gradle b/sdks/community/kotlin/examples/chatapp-java/app/build.gradle index 700e3082f..fcd525cc6 100644 --- a/sdks/community/kotlin/examples/chatapp-java/app/build.gradle +++ b/sdks/community/kotlin/examples/chatapp-java/app/build.gradle @@ -1,5 +1,6 @@ plugins { id 'com.android.application' + id 'org.jetbrains.kotlin.android' } android { @@ -40,27 +41,26 @@ android { sourceCompatibility JavaVersion.VERSION_17 targetCompatibility JavaVersion.VERSION_17 } - + + kotlinOptions { + jvmTarget = '17' + } + buildFeatures { viewBinding true } } dependencies { - // KMP Libraries (unchanged) - implementation 'com.agui:kotlin-client:0.2.1' - implementation 'com.agui:kotlin-core:0.2.1' - implementation 'com.agui:kotlin-tools:0.2.1' - - // Kotlin-Java interop bridges - implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-jdk8:1.10.2' - implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-reactive:1.10.2' + implementation project(':chatapp-shared') + implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-android:1.10.2' - - // RxJava for Flow interop - implementation 'io.reactivex.rxjava3:rxjava:3.1.8' - implementation 'io.reactivex.rxjava3:rxandroid:3.0.2' - + implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-core:1.10.2' + implementation libs.kotlinx.datetime + implementation libs.multiplatform.settings + implementation 'androidx.lifecycle:lifecycle-runtime-ktx:2.8.6' + implementation 'androidx.lifecycle:lifecycle-livedata-ktx:2.8.6' + // Android Architecture Components implementation 'androidx.appcompat:appcompat:1.7.0' implementation 'androidx.activity:activity:1.9.2' @@ -74,11 +74,12 @@ dependencies { // Material Design 3 implementation 'com.google.android.material:material:1.12.0' - // JSON handling + // Markdown rendering + implementation 'io.noties.markwon:core:4.6.2' + + // JSON handling (legacy Java UI helpers) implementation 'org.jetbrains.kotlinx:kotlinx-serialization-json:1.8.1' - - // Logging - using library's logback-android implementation - + // Testing testImplementation 'junit:junit:4.13.2' testImplementation 'org.mockito:mockito-core:5.8.0' @@ -92,4 +93,4 @@ dependencies { androidTestImplementation 'androidx.test:runner:1.6.2' androidTestImplementation 'androidx.test:rules:1.6.1' androidTestImplementation 'androidx.test.ext:truth:1.6.0' -} \ No newline at end of file +} diff --git a/sdks/community/kotlin/examples/chatapp-java/app/src/main/AndroidManifest.xml b/sdks/community/kotlin/examples/chatapp-java/app/src/main/AndroidManifest.xml index ec250a420..e0e9efb0b 100644 --- a/sdks/community/kotlin/examples/chatapp-java/app/src/main/AndroidManifest.xml +++ b/sdks/community/kotlin/examples/chatapp-java/app/src/main/AndroidManifest.xml @@ -6,6 +6,7 @@ - \ No newline at end of file + diff --git a/sdks/community/kotlin/examples/chatapp-java/app/src/main/java/com/agui/chatapp/java/ChatJavaApplication.java b/sdks/community/kotlin/examples/chatapp-java/app/src/main/java/com/agui/chatapp/java/ChatJavaApplication.java new file mode 100644 index 000000000..ea27b204a --- /dev/null +++ b/sdks/community/kotlin/examples/chatapp-java/app/src/main/java/com/agui/chatapp/java/ChatJavaApplication.java @@ -0,0 +1,12 @@ +package com.agui.chatapp.java; + +import android.app.Application; +import com.agui.example.chatapp.util.AndroidPlatformKt; + +public class ChatJavaApplication extends Application { + @Override + public void onCreate() { + super.onCreate(); + AndroidPlatformKt.initializeAndroid(this); + } +} diff --git a/sdks/community/kotlin/examples/chatapp-java/app/src/main/java/com/agui/chatapp/java/adapter/AgUiAgentBuilder.java b/sdks/community/kotlin/examples/chatapp-java/app/src/main/java/com/agui/chatapp/java/adapter/AgUiAgentBuilder.java deleted file mode 100644 index c1072ab8d..000000000 --- a/sdks/community/kotlin/examples/chatapp-java/app/src/main/java/com/agui/chatapp/java/adapter/AgUiAgentBuilder.java +++ /dev/null @@ -1,150 +0,0 @@ -package com.agui.chatapp.java.adapter; - -import com.agui.client.AgUiAgentConfig; -import com.agui.client.StatefulAgUiAgent; -import com.agui.tools.ToolRegistry; - -/** - * Java-friendly builder for creating AG-UI agents. - * Provides a fluent API alternative to Kotlin DSL. - */ -public class AgUiAgentBuilder { - private final String url; - private final AgUiAgentConfig config; - - private AgUiAgentBuilder(String url) { - this.url = url; - this.config = new AgUiAgentConfig(); - } - - /** - * Create a new builder for the specified agent URL - * @param url The agent endpoint URL - * @return A new builder instance - */ - public static AgUiAgentBuilder create(String url) { - return new AgUiAgentBuilder(url); - } - - /** - * Set the bearer token for authentication - * @param token The bearer token - * @return This builder for chaining - */ - public AgUiAgentBuilder bearerToken(String token) { - config.setBearerToken(token); - return this; - } - - /** - * Set the API key for authentication - * @param apiKey The API key - * @return This builder for chaining - */ - public AgUiAgentBuilder apiKey(String apiKey) { - config.setApiKey(apiKey); - return this; - } - - /** - * Set the API key header name - * @param headerName The header name for the API key - * @return This builder for chaining - */ - public AgUiAgentBuilder apiKeyHeader(String headerName) { - config.setApiKeyHeader(headerName); - return this; - } - - /** - * Set the system prompt - * @param prompt The system prompt - * @return This builder for chaining - */ - public AgUiAgentBuilder systemPrompt(String prompt) { - config.setSystemPrompt(prompt); - return this; - } - - /** - * Enable debug mode - * @param debug Whether to enable debug mode - * @return This builder for chaining - */ - public AgUiAgentBuilder debug(boolean debug) { - config.setDebug(debug); - return this; - } - - /** - * Set the tool registry - * @param toolRegistry The tool registry to use - * @return This builder for chaining - */ - public AgUiAgentBuilder toolRegistry(ToolRegistry toolRegistry) { - config.setToolRegistry(toolRegistry); - return this; - } - - /** - * Set the user ID - * @param userId The user ID for message attribution - * @return This builder for chaining - */ - public AgUiAgentBuilder userId(String userId) { - config.setUserId(userId); - return this; - } - - /** - * Set request timeout in milliseconds - * @param timeoutMs Timeout in milliseconds - * @return This builder for chaining - */ - public AgUiAgentBuilder requestTimeout(long timeoutMs) { - config.setRequestTimeout(timeoutMs); - return this; - } - - /** - * Set connection timeout in milliseconds - * @param timeoutMs Timeout in milliseconds - * @return This builder for chaining - */ - public AgUiAgentBuilder connectTimeout(long timeoutMs) { - config.setConnectTimeout(timeoutMs); - return this; - } - - /** - * Add a custom header - * @param name Header name - * @param value Header value - * @return This builder for chaining - */ - public AgUiAgentBuilder addHeader(String name, String value) { - config.getHeaders().put(name, value); - return this; - } - - /** - * Build a stateful agent with the configured settings - * @return A new StatefulAgUiAgent instance - */ - public StatefulAgUiAgent buildStateful() { - return new StatefulAgUiAgent(url, config -> { - // Copy configuration properties - config.setBearerToken(this.config.getBearerToken()); - config.setApiKey(this.config.getApiKey()); - config.setApiKeyHeader(this.config.getApiKeyHeader()); - config.setSystemPrompt(this.config.getSystemPrompt()); - config.setDebug(this.config.getDebug()); - config.setToolRegistry(this.config.getToolRegistry()); - config.setUserId(this.config.getUserId()); - config.setRequestTimeout(this.config.getRequestTimeout()); - config.setConnectTimeout(this.config.getConnectTimeout()); - config.getHeaders().putAll(this.config.getHeaders()); - return null; // Kotlin Unit return - }); - } -} \ No newline at end of file diff --git a/sdks/community/kotlin/examples/chatapp-java/app/src/main/java/com/agui/chatapp/java/adapter/AgUiJavaAdapter.java b/sdks/community/kotlin/examples/chatapp-java/app/src/main/java/com/agui/chatapp/java/adapter/AgUiJavaAdapter.java deleted file mode 100644 index f45f478ed..000000000 --- a/sdks/community/kotlin/examples/chatapp-java/app/src/main/java/com/agui/chatapp/java/adapter/AgUiJavaAdapter.java +++ /dev/null @@ -1,138 +0,0 @@ -package com.agui.chatapp.java.adapter; - -import com.agui.client.StatefulAgUiAgent; -import com.agui.core.types.BaseEvent; - -import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers; -import io.reactivex.rxjava3.core.Observable; -import io.reactivex.rxjava3.disposables.Disposable; -import io.reactivex.rxjava3.schedulers.Schedulers; -import kotlinx.coroutines.flow.Flow; -import kotlinx.coroutines.reactive.ReactiveFlowKt; - -import java.util.concurrent.CompletableFuture; - -/** - * Java adapter for AG-UI agent that provides callback-based and RxJava interfaces - * for interacting with Kotlin Flow-based agent APIs. - */ -public class AgUiJavaAdapter { - private final StatefulAgUiAgent agent; - - public AgUiJavaAdapter(StatefulAgUiAgent agent) { - this.agent = agent; - } - - /** - * Send a message to the agent using callback interface - * @param message The message to send - * @param callback Callback for handling events - * @return Disposable for canceling the subscription - */ - public Disposable sendMessage(String message, EventCallback callback) { - return sendMessageObservable(message) - .subscribeOn(Schedulers.io()) - .observeOn(AndroidSchedulers.mainThread()) - .subscribe( - callback::onEvent, - callback::onError, - callback::onComplete - ); - } - - /** - * Send a message to the agent and return an RxJava Observable - * @param message The message to send - * @return Observable of events - */ - public Observable sendMessageObservable(String message) { - // Get the Kotlin Flow from the agent - Flow kotlinFlow = agent.chat(message, "default"); - - // Convert Kotlin Flow to RxJava Observable using kotlinx-coroutines-reactive - return Observable.fromPublisher(ReactiveFlowKt.asPublisher(kotlinFlow)); - } - - /** - * Send a message with custom thread ID - * @param message The message to send - * @param threadId Custom thread ID - * @param callback Callback for handling events - * @return Disposable for canceling the subscription - */ - public Disposable sendMessage(String message, String threadId, EventCallback callback) { - return sendMessageObservable(message, threadId) - .subscribeOn(Schedulers.io()) - .observeOn(AndroidSchedulers.mainThread()) - .subscribe( - callback::onEvent, - callback::onError, - callback::onComplete - ); - } - - /** - * Send a message with custom thread ID and return an Observable - * @param message The message to send - * @param threadId Custom thread ID - * @return Observable of events - */ - public Observable sendMessageObservable(String message, String threadId) { - Flow kotlinFlow = agent.chat(message, threadId); - return Observable.fromPublisher(ReactiveFlowKt.asPublisher(kotlinFlow)); - } - - /** - * Test connection to the agent - * @return CompletableFuture that completes when connection test is done - */ - public CompletableFuture testConnection() { - CompletableFuture future = new CompletableFuture<>(); - - sendMessage("Hello", new EventCallback() { - @Override - public void onEvent(BaseEvent event) { - // If we receive any event, connection is working - if (!future.isDone()) { - future.complete(true); - } - } - - @Override - public void onError(Throwable error) { - future.complete(false); - } - - @Override - public void onComplete() { - if (!future.isDone()) { - future.complete(true); - } - } - }); - - return future; - } - - /** - * Close the agent and release resources - */ - public void close() { - agent.close(); - } - - /** - * Get the current thread ID - * @return Current thread ID - */ - public String getCurrentThreadId() { - return "default"; // StatefulAgUiAgent manages thread internally - } - - /** - * Clear conversation history - */ - public void clearHistory() { - agent.clearHistory(null); // Clear all threads - } -} \ No newline at end of file diff --git a/sdks/community/kotlin/examples/chatapp-java/app/src/main/java/com/agui/chatapp/java/adapter/EventCallback.java b/sdks/community/kotlin/examples/chatapp-java/app/src/main/java/com/agui/chatapp/java/adapter/EventCallback.java deleted file mode 100644 index d0d005702..000000000 --- a/sdks/community/kotlin/examples/chatapp-java/app/src/main/java/com/agui/chatapp/java/adapter/EventCallback.java +++ /dev/null @@ -1,27 +0,0 @@ -package com.agui.chatapp.java.adapter; - -import com.agui.core.types.BaseEvent; - -/** - * Java callback interface for handling AG-UI events. - * Provides a Java-friendly alternative to Kotlin Flow. - */ -public interface EventCallback { - - /** - * Called when a new event is received from the agent - * @param event The event received from the agent - */ - void onEvent(BaseEvent event); - - /** - * Called when an error occurs during event processing - * @param error The error that occurred - */ - void onError(Throwable error); - - /** - * Called when the event stream completes - */ - void onComplete(); -} \ No newline at end of file diff --git a/sdks/community/kotlin/examples/chatapp-java/app/src/main/java/com/agui/chatapp/java/adapter/EventProcessor.java b/sdks/community/kotlin/examples/chatapp-java/app/src/main/java/com/agui/chatapp/java/adapter/EventProcessor.java deleted file mode 100644 index 5199ba8ca..000000000 --- a/sdks/community/kotlin/examples/chatapp-java/app/src/main/java/com/agui/chatapp/java/adapter/EventProcessor.java +++ /dev/null @@ -1,147 +0,0 @@ -package com.agui.chatapp.java.adapter; - -import com.agui.core.types.*; - -/** - * Utility class for processing AG-UI events in Java. - * Provides type-safe event handling and conversion utilities. - */ -public class EventProcessor { - - /** - * Interface for handling different event types - */ - public interface EventHandler { - void onRunStarted(RunStartedEvent event); - void onRunFinished(RunFinishedEvent event); - void onRunError(RunErrorEvent event); - void onStepStarted(StepStartedEvent event); - void onStepFinished(StepFinishedEvent event); - void onTextMessageStart(TextMessageStartEvent event); - void onTextMessageContent(TextMessageContentEvent event); - void onTextMessageEnd(TextMessageEndEvent event); - void onToolCallStart(ToolCallStartEvent event); - void onToolCallArgs(ToolCallArgsEvent event); - void onToolCallEnd(ToolCallEndEvent event); - void onStateSnapshot(StateSnapshotEvent event); - void onStateDelta(StateDeltaEvent event); - void onMessagesSnapshot(MessagesSnapshotEvent event); - void onRawEvent(RawEvent event); - void onCustomEvent(CustomEvent event); - void onUnknownEvent(BaseEvent event); - } - - /** - * Process an event using the provided handler - * @param event The event to process - * @param handler The handler to use for processing - */ - public static void processEvent(BaseEvent event, EventHandler handler) { - if (event instanceof RunStartedEvent) { - handler.onRunStarted((RunStartedEvent) event); - } else if (event instanceof RunFinishedEvent) { - handler.onRunFinished((RunFinishedEvent) event); - } else if (event instanceof RunErrorEvent) { - handler.onRunError((RunErrorEvent) event); - } else if (event instanceof StepStartedEvent) { - handler.onStepStarted((StepStartedEvent) event); - } else if (event instanceof StepFinishedEvent) { - handler.onStepFinished((StepFinishedEvent) event); - } else if (event instanceof TextMessageStartEvent) { - handler.onTextMessageStart((TextMessageStartEvent) event); - } else if (event instanceof TextMessageContentEvent) { - handler.onTextMessageContent((TextMessageContentEvent) event); - } else if (event instanceof TextMessageEndEvent) { - handler.onTextMessageEnd((TextMessageEndEvent) event); - } else if (event instanceof ToolCallStartEvent) { - handler.onToolCallStart((ToolCallStartEvent) event); - } else if (event instanceof ToolCallArgsEvent) { - handler.onToolCallArgs((ToolCallArgsEvent) event); - } else if (event instanceof ToolCallEndEvent) { - handler.onToolCallEnd((ToolCallEndEvent) event); - } else if (event instanceof StateSnapshotEvent) { - handler.onStateSnapshot((StateSnapshotEvent) event); - } else if (event instanceof StateDeltaEvent) { - handler.onStateDelta((StateDeltaEvent) event); - } else if (event instanceof MessagesSnapshotEvent) { - handler.onMessagesSnapshot((MessagesSnapshotEvent) event); - } else if (event instanceof RawEvent) { - handler.onRawEvent((RawEvent) event); - } else if (event instanceof CustomEvent) { - handler.onCustomEvent((CustomEvent) event); - } else { - handler.onUnknownEvent(event); - } - } - - /** - * Check if an event is a text message event - * @param event The event to check - * @return true if the event is related to text messages - */ - public static boolean isTextMessageEvent(BaseEvent event) { - return event instanceof TextMessageStartEvent || - event instanceof TextMessageContentEvent || - event instanceof TextMessageEndEvent; - } - - /** - * Check if an event is a tool call event - * @param event The event to check - * @return true if the event is related to tool calls - */ - public static boolean isToolCallEvent(BaseEvent event) { - return event instanceof ToolCallStartEvent || - event instanceof ToolCallArgsEvent || - event instanceof ToolCallEndEvent; - } - - /** - * Check if an event is a lifecycle event - * @param event The event to check - * @return true if the event is a lifecycle event - */ - public static boolean isLifecycleEvent(BaseEvent event) { - return event instanceof RunStartedEvent || - event instanceof RunFinishedEvent || - event instanceof RunErrorEvent || - event instanceof StepStartedEvent || - event instanceof StepFinishedEvent; - } - - /** - * Check if an event is a state management event - * @param event The event to check - * @return true if the event is related to state management - */ - public static boolean isStateEvent(BaseEvent event) { - return event instanceof StateSnapshotEvent || - event instanceof StateDeltaEvent || - event instanceof MessagesSnapshotEvent; - } - - /** - * Get a human-readable description of the event type - * @param event The event to describe - * @return A human-readable description - */ - public static String getEventDescription(BaseEvent event) { - if (event instanceof RunStartedEvent) return "Run Started"; - if (event instanceof RunFinishedEvent) return "Run Finished"; - if (event instanceof RunErrorEvent) return "Run Error"; - if (event instanceof StepStartedEvent) return "Step Started"; - if (event instanceof StepFinishedEvent) return "Step Finished"; - if (event instanceof TextMessageStartEvent) return "Text Message Start"; - if (event instanceof TextMessageContentEvent) return "Text Message Content"; - if (event instanceof TextMessageEndEvent) return "Text Message End"; - if (event instanceof ToolCallStartEvent) return "Tool Call Start"; - if (event instanceof ToolCallArgsEvent) return "Tool Call Args"; - if (event instanceof ToolCallEndEvent) return "Tool Call End"; - if (event instanceof StateSnapshotEvent) return "State Snapshot"; - if (event instanceof StateDeltaEvent) return "State Delta"; - if (event instanceof MessagesSnapshotEvent) return "Messages Snapshot"; - if (event instanceof RawEvent) return "Raw Event"; - if (event instanceof CustomEvent) return "Custom Event"; - return "Unknown Event"; - } -} \ No newline at end of file diff --git a/sdks/community/kotlin/examples/chatapp-java/app/src/main/java/com/agui/chatapp/java/model/AgentProfile.java b/sdks/community/kotlin/examples/chatapp-java/app/src/main/java/com/agui/chatapp/java/model/AgentProfile.java deleted file mode 100644 index b7335de75..000000000 --- a/sdks/community/kotlin/examples/chatapp-java/app/src/main/java/com/agui/chatapp/java/model/AgentProfile.java +++ /dev/null @@ -1,231 +0,0 @@ -package com.agui.chatapp.java.model; - -import androidx.annotation.NonNull; -import androidx.annotation.Nullable; - -import java.util.Objects; - -/** - * Represents a configured agent that the user can connect to. - * This is a Java implementation of the AgentConfig from the compose app. - */ -public class AgentProfile { - private final String id; - private final String name; - private final String url; - private final String description; - private final AuthMethod authMethod; - private final boolean isActive; - private final long createdAt; - private final Long lastUsedAt; - private final String systemPrompt; - - private AgentProfile(Builder builder) { - this.id = builder.id; - this.name = builder.name; - this.url = builder.url; - this.description = builder.description; - this.authMethod = builder.authMethod; - this.isActive = builder.isActive; - this.createdAt = builder.createdAt; - this.lastUsedAt = builder.lastUsedAt; - this.systemPrompt = builder.systemPrompt; - } - - @NonNull - public String getId() { - return id; - } - - @NonNull - public String getName() { - return name; - } - - @NonNull - public String getUrl() { - return url; - } - - @Nullable - public String getDescription() { - return description; - } - - @NonNull - public AuthMethod getAuthMethod() { - return authMethod; - } - - public boolean isActive() { - return isActive; - } - - public long getCreatedAt() { - return createdAt; - } - - @Nullable - public Long getLastUsedAt() { - return lastUsedAt; - } - - @Nullable - public String getSystemPrompt() { - return systemPrompt; - } - - /** - * Check if this agent profile is valid and can be used. - */ - public boolean isValid() { - return id != null && !id.trim().isEmpty() && - name != null && !name.trim().isEmpty() && - url != null && !url.trim().isEmpty() && - authMethod != null && authMethod.isValid(); - } - - /** - * Create a copy of this profile with the specified active state. - */ - public AgentProfile withActive(boolean active) { - return toBuilder().setActive(active).build(); - } - - /** - * Create a copy of this profile with the last used time updated. - */ - public AgentProfile withLastUsedAt(long lastUsedAt) { - return toBuilder().setLastUsedAt(lastUsedAt).build(); - } - - /** - * Create a builder from this profile for easy copying/modification. - */ - public Builder toBuilder() { - return new Builder() - .setId(id) - .setName(name) - .setUrl(url) - .setDescription(description) - .setAuthMethod(authMethod) - .setActive(isActive) - .setCreatedAt(createdAt) - .setLastUsedAt(lastUsedAt) - .setSystemPrompt(systemPrompt); - } - - @Override - public boolean equals(Object obj) { - if (this == obj) return true; - if (!(obj instanceof AgentProfile)) return false; - AgentProfile that = (AgentProfile) obj; - return isActive == that.isActive && - createdAt == that.createdAt && - Objects.equals(id, that.id) && - Objects.equals(name, that.name) && - Objects.equals(url, that.url) && - Objects.equals(description, that.description) && - Objects.equals(authMethod, that.authMethod) && - Objects.equals(lastUsedAt, that.lastUsedAt) && - Objects.equals(systemPrompt, that.systemPrompt); - } - - @Override - public int hashCode() { - return Objects.hash(id, name, url, description, authMethod, isActive, createdAt, lastUsedAt, systemPrompt); - } - - @Override - public String toString() { - return "AgentProfile{" + - "id='" + id + '\'' + - ", name='" + name + '\'' + - ", url='" + url + '\'' + - ", description='" + description + '\'' + - ", authMethod=" + authMethod + - ", isActive=" + isActive + - ", createdAt=" + createdAt + - ", lastUsedAt=" + lastUsedAt + - ", systemPrompt='" + systemPrompt + '\'' + - '}'; - } - - /** - * Generate a unique ID for a new agent profile. - */ - public static String generateId() { - long timestamp = System.currentTimeMillis(); - int random = (int) (Math.random() * 9000) + 1000; // 4-digit random number - return "agent_" + timestamp + "_" + random; - } - - /** - * Builder class for creating AgentProfile instances. - */ - public static class Builder { - private String id; - private String name; - private String url; - private String description; - private AuthMethod authMethod = new AuthMethod.None(); - private boolean isActive = false; - private long createdAt = System.currentTimeMillis(); - private Long lastUsedAt; - private String systemPrompt; - - public Builder() {} - - public Builder setId(@NonNull String id) { - this.id = id; - return this; - } - - public Builder setName(@NonNull String name) { - this.name = name; - return this; - } - - public Builder setUrl(@NonNull String url) { - this.url = url; - return this; - } - - public Builder setDescription(@Nullable String description) { - this.description = description; - return this; - } - - public Builder setAuthMethod(@NonNull AuthMethod authMethod) { - this.authMethod = authMethod; - return this; - } - - public Builder setActive(boolean active) { - this.isActive = active; - return this; - } - - public Builder setCreatedAt(long createdAt) { - this.createdAt = createdAt; - return this; - } - - public Builder setLastUsedAt(@Nullable Long lastUsedAt) { - this.lastUsedAt = lastUsedAt; - return this; - } - - public Builder setSystemPrompt(@Nullable String systemPrompt) { - this.systemPrompt = systemPrompt; - return this; - } - - public AgentProfile build() { - if (id == null) { - id = generateId(); - } - return new AgentProfile(this); - } - } -} \ No newline at end of file diff --git a/sdks/community/kotlin/examples/chatapp-java/app/src/main/java/com/agui/chatapp/java/model/AuthMethod.java b/sdks/community/kotlin/examples/chatapp-java/app/src/main/java/com/agui/chatapp/java/model/AuthMethod.java deleted file mode 100644 index cf8c13dc8..000000000 --- a/sdks/community/kotlin/examples/chatapp-java/app/src/main/java/com/agui/chatapp/java/model/AuthMethod.java +++ /dev/null @@ -1,203 +0,0 @@ -package com.agui.chatapp.java.model; - -import androidx.annotation.NonNull; -import androidx.annotation.Nullable; - -/** - * Base class for different authentication methods supported by agents. - * This is a Java implementation of the Kotlin sealed class from the compose app. - */ -public abstract class AuthMethod { - - /** - * Get the type identifier for this auth method. - */ - public abstract String getType(); - - /** - * Check if this auth method is valid and can be used. - */ - public abstract boolean isValid(); - - /** - * No authentication required. - */ - public static class None extends AuthMethod { - @Override - public String getType() { - return "none"; - } - - @Override - public boolean isValid() { - return true; - } - - @Override - public boolean equals(Object obj) { - return obj instanceof None; - } - - @Override - public int hashCode() { - return getType().hashCode(); - } - - @Override - public String toString() { - return "AuthMethod.None{}"; - } - } - - /** - * API Key authentication with configurable header name. - */ - public static class ApiKey extends AuthMethod { - private final String key; - private final String headerName; - - public ApiKey(@NonNull String key, @NonNull String headerName) { - this.key = key; - this.headerName = headerName; - } - - public ApiKey(@NonNull String key) { - this(key, "X-API-Key"); - } - - @NonNull - public String getKey() { - return key; - } - - @NonNull - public String getHeaderName() { - return headerName; - } - - @Override - public String getType() { - return "api_key"; - } - - @Override - public boolean isValid() { - return key != null && !key.trim().isEmpty() && - headerName != null && !headerName.trim().isEmpty(); - } - - @Override - public boolean equals(Object obj) { - if (this == obj) return true; - if (!(obj instanceof ApiKey)) return false; - ApiKey apiKey = (ApiKey) obj; - return key.equals(apiKey.key) && headerName.equals(apiKey.headerName); - } - - @Override - public int hashCode() { - return key.hashCode() * 31 + headerName.hashCode(); - } - - @Override - public String toString() { - return "AuthMethod.ApiKey{key='***', headerName='" + headerName + "'}"; - } - } - - /** - * Bearer Token authentication. - */ - public static class BearerToken extends AuthMethod { - private final String token; - - public BearerToken(@NonNull String token) { - this.token = token; - } - - @NonNull - public String getToken() { - return token; - } - - @Override - public String getType() { - return "bearer_token"; - } - - @Override - public boolean isValid() { - return token != null && !token.trim().isEmpty(); - } - - @Override - public boolean equals(Object obj) { - if (this == obj) return true; - if (!(obj instanceof BearerToken)) return false; - BearerToken that = (BearerToken) obj; - return token.equals(that.token); - } - - @Override - public int hashCode() { - return token.hashCode(); - } - - @Override - public String toString() { - return "AuthMethod.BearerToken{token='***'}"; - } - } - - /** - * Basic Auth with username and password. - */ - public static class BasicAuth extends AuthMethod { - private final String username; - private final String password; - - public BasicAuth(@NonNull String username, @NonNull String password) { - this.username = username; - this.password = password; - } - - @NonNull - public String getUsername() { - return username; - } - - @NonNull - public String getPassword() { - return password; - } - - @Override - public String getType() { - return "basic_auth"; - } - - @Override - public boolean isValid() { - return username != null && !username.trim().isEmpty() && - password != null && !password.trim().isEmpty(); - } - - @Override - public boolean equals(Object obj) { - if (this == obj) return true; - if (!(obj instanceof BasicAuth)) return false; - BasicAuth basicAuth = (BasicAuth) obj; - return username.equals(basicAuth.username) && password.equals(basicAuth.password); - } - - @Override - public int hashCode() { - return username.hashCode() * 31 + password.hashCode(); - } - - @Override - public String toString() { - return "AuthMethod.BasicAuth{username='" + username + "', password='***'}"; - } - } -} \ No newline at end of file diff --git a/sdks/community/kotlin/examples/chatapp-java/app/src/main/java/com/agui/chatapp/java/model/ChatMessage.java b/sdks/community/kotlin/examples/chatapp-java/app/src/main/java/com/agui/chatapp/java/model/ChatMessage.java index da1ef6608..5c960b503 100644 --- a/sdks/community/kotlin/examples/chatapp-java/app/src/main/java/com/agui/chatapp/java/model/ChatMessage.java +++ b/sdks/community/kotlin/examples/chatapp-java/app/src/main/java/com/agui/chatapp/java/model/ChatMessage.java @@ -1,90 +1,63 @@ package com.agui.chatapp.java.model; -import com.agui.core.types.Message; -import com.agui.core.types.Role; +import com.agui.example.chatapp.chat.DisplayMessage; +import com.agui.example.chatapp.chat.MessageRole; -import java.time.LocalDateTime; +import java.time.Instant; +import java.time.ZoneId; import java.time.format.DateTimeFormatter; /** - * UI model for chat messages that wraps the AG-UI Message type. - * Provides additional UI-specific properties and formatting. + * UI wrapper around the shared DisplayMessage. */ -public class ChatMessage { +public final class ChatMessage { + private static final DateTimeFormatter TIME_FORMATTER = DateTimeFormatter.ofPattern("h:mm a"); + private final String id; - private final Role role; + private final MessageRole role; private final String content; - private final String name; - private final LocalDateTime timestamp; - private boolean isStreaming; - - // Message state for streaming - private StringBuilder streamingContent; - - public ChatMessage(Message message) { + private final boolean streaming; + private final String senderDisplayName; + private final long timestampMillis; + + public ChatMessage(DisplayMessage message) { this.id = message.getId(); - this.role = message.getMessageRole(); + this.role = message.getRole(); this.content = message.getContent(); - this.name = message.getName(); - this.timestamp = LocalDateTime.now(); - this.isStreaming = false; - this.streamingContent = null; - } - - public ChatMessage(String id, Role role, String content, String name) { - this.id = id; - this.role = role; - this.content = content; - this.name = name; - this.timestamp = LocalDateTime.now(); - this.isStreaming = false; - this.streamingContent = null; - } - - // Create a streaming message - public static ChatMessage createStreaming(String id, Role role, String name) { - ChatMessage message = new ChatMessage(id, role, "", name); - message.isStreaming = true; - message.streamingContent = new StringBuilder(); - return message; + this.streaming = message.isStreaming(); + this.senderDisplayName = message.getEphemeralType() != null + ? message.getEphemeralType().name() + : defaultDisplayName(role); + this.timestampMillis = message.getTimestamp(); } - + public String getId() { return id; } - - public Role getRole() { + + public MessageRole getRole() { return role; } - + public String getContent() { - if (streamingContent != null) { - return streamingContent.toString(); - } return content != null ? content : ""; } - - public String getName() { - return name; - } - - public LocalDateTime getTimestamp() { - return timestamp; - } - + public boolean isStreaming() { - return isStreaming && streamingContent != null; + return streaming; } - + + public String getSenderDisplayName() { + return senderDisplayName; + } + public String getFormattedTimestamp() { - return timestamp.format(DateTimeFormatter.ofPattern("h:mm a")); + Instant instant = Instant.ofEpochMilli(timestampMillis); + return TIME_FORMATTER.format(instant.atZone(ZoneId.systemDefault())); } - - public String getSenderDisplayName() { - if (name != null && !name.isEmpty()) { - return name; - } - + + private static String defaultDisplayName(MessageRole role) { + if (role == null) return ""; switch (role) { case USER: return "You"; @@ -92,87 +65,14 @@ public String getSenderDisplayName() { return "Assistant"; case SYSTEM: return "System"; - case DEVELOPER: - return "Developer"; - case TOOL: + case ERROR: + return "Error"; + case TOOL_CALL: return "Tool"; + case STEP_INFO: + return "Step"; default: - return "Unknown"; - } - } - - /** - * Append content to a streaming message - * @param delta The content to append - */ - public void appendStreamingContent(String delta) { - if (streamingContent != null) { - streamingContent.append(delta); - } - } - - /** - * Finish streaming and finalize the message content - */ - public void finishStreaming() { - if (streamingContent != null) { - // Content is now final, streaming is complete - isStreaming = false; - // Keep the streamed content by updating the content field - // Note: We can't change the final content field, so we keep streamingContent + return ""; } } - - /** - * Check if this is a user message - */ - public boolean isUser() { - return role == Role.USER; - } - - /** - * Check if this is an assistant message - */ - public boolean isAssistant() { - return role == Role.ASSISTANT; - } - - /** - * Check if this is a system message - */ - public boolean isSystem() { - return role == Role.SYSTEM; - } - - /** - * Check if this message has content to display - */ - public boolean hasContent() { - String messageContent = getContent(); - return messageContent != null && !messageContent.trim().isEmpty(); - } - - @Override - public boolean equals(Object obj) { - if (this == obj) return true; - if (obj == null || getClass() != obj.getClass()) return false; - ChatMessage that = (ChatMessage) obj; - return id.equals(that.id); - } - - @Override - public int hashCode() { - return id.hashCode(); - } - - @Override - public String toString() { - return "ChatMessage{" + - "id='" + id + '\'' + - ", role=" + role + - ", content='" + getContent() + '\'' + - ", timestamp=" + timestamp + - ", isStreaming=" + isStreaming() + - '}'; - } -} \ No newline at end of file +} diff --git a/sdks/community/kotlin/examples/chatapp-java/app/src/main/java/com/agui/chatapp/java/model/ChatSession.java b/sdks/community/kotlin/examples/chatapp-java/app/src/main/java/com/agui/chatapp/java/model/ChatSession.java deleted file mode 100644 index 65f862c26..000000000 --- a/sdks/community/kotlin/examples/chatapp-java/app/src/main/java/com/agui/chatapp/java/model/ChatSession.java +++ /dev/null @@ -1,71 +0,0 @@ -package com.agui.chatapp.java.model; - -import androidx.annotation.NonNull; - -import java.util.Objects; - -/** - * Represents the current chat session state. - * Tracks which agent is being used and the thread ID for conversation continuity. - * This ensures each agent conversation is properly isolated. - */ -public class ChatSession { - private final String agentId; - private final String threadId; - private final long startedAt; - - public ChatSession(@NonNull String agentId, @NonNull String threadId, long startedAt) { - this.agentId = agentId; - this.threadId = threadId; - this.startedAt = startedAt; - } - - public ChatSession(@NonNull String agentId, @NonNull String threadId) { - this(agentId, threadId, System.currentTimeMillis()); - } - - @NonNull - public String getAgentId() { - return agentId; - } - - @NonNull - public String getThreadId() { - return threadId; - } - - public long getStartedAt() { - return startedAt; - } - - /** - * Generate a unique thread ID for a new chat session. - */ - public static String generateThreadId() { - return "thread_" + System.currentTimeMillis() + "_" + (int)(Math.random() * 1000); - } - - @Override - public boolean equals(Object obj) { - if (this == obj) return true; - if (!(obj instanceof ChatSession)) return false; - ChatSession that = (ChatSession) obj; - return startedAt == that.startedAt && - Objects.equals(agentId, that.agentId) && - Objects.equals(threadId, that.threadId); - } - - @Override - public int hashCode() { - return Objects.hash(agentId, threadId, startedAt); - } - - @Override - public String toString() { - return "ChatSession{" + - "agentId='" + agentId + '\'' + - ", threadId='" + threadId + '\'' + - ", startedAt=" + startedAt + - '}'; - } -} \ No newline at end of file diff --git a/sdks/community/kotlin/examples/chatapp-java/app/src/main/java/com/agui/chatapp/java/repository/AgentRepository.java b/sdks/community/kotlin/examples/chatapp-java/app/src/main/java/com/agui/chatapp/java/repository/AgentRepository.java deleted file mode 100644 index 94af2378d..000000000 --- a/sdks/community/kotlin/examples/chatapp-java/app/src/main/java/com/agui/chatapp/java/repository/AgentRepository.java +++ /dev/null @@ -1,156 +0,0 @@ -package com.agui.chatapp.java.repository; - -import android.content.Context; -import android.content.SharedPreferences; - -/** - * Repository for managing agent configuration using SharedPreferences. - * Provides persistent storage for agent settings across app sessions. - */ -public class AgentRepository { - private static final String PREF_NAME = "agent_settings"; - private static final String KEY_AGENT_URL = "agent_url"; - private static final String KEY_AUTH_TYPE = "auth_type"; - private static final String KEY_BEARER_TOKEN = "bearer_token"; - private static final String KEY_API_KEY = "api_key"; - private static final String KEY_API_KEY_HEADER = "api_key_header"; - private static final String KEY_SYSTEM_PROMPT = "system_prompt"; - private static final String KEY_DEBUG = "debug"; - - public enum AuthType { - NONE("none"), - BEARER("bearer"), - API_KEY("api_key"); - - private final String value; - - AuthType(String value) { - this.value = value; - } - - public String getValue() { - return value; - } - - public static AuthType fromValue(String value) { - for (AuthType type : values()) { - if (type.value.equals(value)) { - return type; - } - } - return NONE; - } - } - - public static class AgentConfig { - private String agentUrl; - private AuthType authType; - private String bearerToken; - private String apiKey; - private String apiKeyHeader; - private String systemPrompt; - private boolean debug; - - public AgentConfig() { - this.agentUrl = ""; - this.authType = AuthType.NONE; - this.bearerToken = ""; - this.apiKey = ""; - this.apiKeyHeader = "x-api-key"; - this.systemPrompt = ""; - this.debug = false; - } - - // Getters and setters - public String getAgentUrl() { return agentUrl; } - public void setAgentUrl(String agentUrl) { this.agentUrl = agentUrl; } - - public AuthType getAuthType() { return authType; } - public void setAuthType(AuthType authType) { this.authType = authType; } - - public String getBearerToken() { return bearerToken; } - public void setBearerToken(String bearerToken) { this.bearerToken = bearerToken; } - - public String getApiKey() { return apiKey; } - public void setApiKey(String apiKey) { this.apiKey = apiKey; } - - public String getApiKeyHeader() { return apiKeyHeader; } - public void setApiKeyHeader(String apiKeyHeader) { this.apiKeyHeader = apiKeyHeader; } - - public String getSystemPrompt() { return systemPrompt; } - public void setSystemPrompt(String systemPrompt) { this.systemPrompt = systemPrompt; } - - public boolean isDebug() { return debug; } - public void setDebug(boolean debug) { this.debug = debug; } - - public boolean isValid() { - return agentUrl != null && !agentUrl.trim().isEmpty(); - } - - public boolean hasAuthentication() { - switch (authType) { - case BEARER: - return bearerToken != null && !bearerToken.trim().isEmpty(); - case API_KEY: - return apiKey != null && !apiKey.trim().isEmpty(); - case NONE: - default: - return true; // No auth is valid - } - } - } - - private final SharedPreferences preferences; - - public AgentRepository(Context context) { - this.preferences = context.getSharedPreferences(PREF_NAME, Context.MODE_PRIVATE); - } - - /** - * Save agent configuration - */ - public void saveAgentConfig(AgentConfig config) { - SharedPreferences.Editor editor = preferences.edit(); - - editor.putString(KEY_AGENT_URL, config.getAgentUrl()); - editor.putString(KEY_AUTH_TYPE, config.getAuthType().getValue()); - editor.putString(KEY_BEARER_TOKEN, config.getBearerToken()); - editor.putString(KEY_API_KEY, config.getApiKey()); - editor.putString(KEY_API_KEY_HEADER, config.getApiKeyHeader()); - editor.putString(KEY_SYSTEM_PROMPT, config.getSystemPrompt()); - editor.putBoolean(KEY_DEBUG, config.isDebug()); - - editor.apply(); - } - - /** - * Load agent configuration - */ - public AgentConfig loadAgentConfig() { - AgentConfig config = new AgentConfig(); - - config.setAgentUrl(preferences.getString(KEY_AGENT_URL, "")); - config.setAuthType(AuthType.fromValue(preferences.getString(KEY_AUTH_TYPE, "none"))); - config.setBearerToken(preferences.getString(KEY_BEARER_TOKEN, "")); - config.setApiKey(preferences.getString(KEY_API_KEY, "")); - config.setApiKeyHeader(preferences.getString(KEY_API_KEY_HEADER, "x-api-key")); - config.setSystemPrompt(preferences.getString(KEY_SYSTEM_PROMPT, "")); - config.setDebug(preferences.getBoolean(KEY_DEBUG, false)); - - return config; - } - - /** - * Check if agent is configured - */ - public boolean hasAgentConfig() { - return loadAgentConfig().isValid(); - } - - /** - * Clear all agent configuration - */ - public void clearAgentConfig() { - preferences.edit().clear().apply(); - } -} \ No newline at end of file diff --git a/sdks/community/kotlin/examples/chatapp-java/app/src/main/java/com/agui/chatapp/java/repository/MultiAgentRepository.java b/sdks/community/kotlin/examples/chatapp-java/app/src/main/java/com/agui/chatapp/java/repository/MultiAgentRepository.java deleted file mode 100644 index f3e7f5a7a..000000000 --- a/sdks/community/kotlin/examples/chatapp-java/app/src/main/java/com/agui/chatapp/java/repository/MultiAgentRepository.java +++ /dev/null @@ -1,489 +0,0 @@ -package com.agui.chatapp.java.repository; - -import android.content.Context; -import android.content.SharedPreferences; -import android.os.Handler; -import android.os.Looper; -import androidx.annotation.NonNull; -import androidx.annotation.Nullable; -import androidx.lifecycle.LiveData; -import androidx.lifecycle.MutableLiveData; - -import com.agui.chatapp.java.model.AgentProfile; -import com.agui.chatapp.java.model.AuthMethod; -import com.agui.chatapp.java.model.ChatSession; - -import java.util.ArrayList; -import java.util.List; -import java.util.concurrent.CompletableFuture; -import java.util.concurrent.Executor; -import java.util.concurrent.Executors; - -/** - * Repository for managing multiple agent profiles with CRUD operations. - * Uses simple key-value storage in SharedPreferences (no JSON dependencies). - * Each agent is stored as separate preference entries. - */ -public class MultiAgentRepository { - private static volatile MultiAgentRepository INSTANCE; - private static final String PREFS_NAME = "multi_agent_repository"; - private static final String KEY_AGENT_LIST = "agent_list"; - private static final String KEY_ACTIVE_AGENT_ID = "active_agent_id"; - - // Prefixes for agent properties - private static final String PREFIX_AGENT = "agent_"; - private static final String SUFFIX_NAME = "_name"; - private static final String SUFFIX_URL = "_url"; - private static final String SUFFIX_DESCRIPTION = "_description"; - private static final String SUFFIX_AUTH_TYPE = "_auth_type"; - private static final String SUFFIX_AUTH_KEY = "_auth_key"; - private static final String SUFFIX_AUTH_HEADER = "_auth_header"; - private static final String SUFFIX_AUTH_TOKEN = "_auth_token"; - private static final String SUFFIX_AUTH_USERNAME = "_auth_username"; - private static final String SUFFIX_AUTH_PASSWORD = "_auth_password"; - private static final String SUFFIX_CREATED_AT = "_created_at"; - private static final String SUFFIX_LAST_USED_AT = "_last_used_at"; - private static final String SUFFIX_SYSTEM_PROMPT = "_system_prompt"; - - private final SharedPreferences preferences; - private final Executor executor; - private final Handler mainThreadHandler = new Handler(Looper.getMainLooper()); - - // LiveData for reactive updates - private final MutableLiveData> agentsLiveData; - private final MutableLiveData activeAgentLiveData; - private final MutableLiveData currentSessionLiveData; - - // In-memory cache - private List agents; - private AgentProfile activeAgent; - private ChatSession currentSession; - - // Initialization tracking - private final CompletableFuture initializationFuture = new CompletableFuture<>(); - - // 2. Make the constructor private - private MultiAgentRepository(@NonNull Context context) { - this.preferences = context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE); - this.executor = Executors.newSingleThreadExecutor(); - - this.agentsLiveData = new MutableLiveData<>(); - this.activeAgentLiveData = new MutableLiveData<>(); - this.currentSessionLiveData = new MutableLiveData<>(); - - this.agents = new ArrayList<>(); - - loadData(); - } - public static MultiAgentRepository getInstance(@NonNull Context context) { - if (INSTANCE == null) { - synchronized (MultiAgentRepository.class) { - if (INSTANCE == null) { - // Use application context to avoid memory leaks - INSTANCE = new MultiAgentRepository(context.getApplicationContext()); - } - } - } - return INSTANCE; - } - - /** - * Get LiveData for observing the list of agents. - */ - @NonNull - public LiveData> getAgents() { - return agentsLiveData; - } - - /** - * Get LiveData for observing the active agent. - */ - @NonNull - public LiveData getActiveAgent() { - return activeAgentLiveData; - } - - /** - * Get LiveData for observing the current chat session. - */ - @NonNull - public LiveData getCurrentSession() { - return currentSessionLiveData; - } - - /** - * Add a new agent profile. - */ - @NonNull - public CompletableFuture addAgent(@NonNull AgentProfile agent) { - return CompletableFuture.runAsync(() -> { - synchronized (this) { - List updatedAgents = new ArrayList<>(agents); - updatedAgents.add(agent); - agents = updatedAgents; - saveAgent(agent); - saveAgentList(); - agentsLiveData.postValue(new ArrayList<>(agents)); - } - }, executor); - } - - /** - * Update an existing agent profile. - */ - @NonNull - public CompletableFuture updateAgent(@NonNull AgentProfile agent) { - return CompletableFuture.runAsync(() -> { - synchronized (this) { - List updatedAgents = new ArrayList<>(); - boolean found = false; - - for (AgentProfile existing : agents) { - if (existing.getId().equals(agent.getId())) { - updatedAgents.add(agent); - found = true; - } else { - updatedAgents.add(existing); - } - } - - if (found) { - agents = updatedAgents; - saveAgent(agent); - agentsLiveData.postValue(new ArrayList<>(agents)); - - // Update active agent if it's the one being updated - if (activeAgent != null && activeAgent.getId().equals(agent.getId())) { - activeAgent = agent; - activeAgentLiveData.postValue(activeAgent); - } - } - } - }, executor); - } - - /** - * Delete an agent profile by ID. - */ - @NonNull - public CompletableFuture deleteAgent(@NonNull String agentId) { - return CompletableFuture.runAsync(() -> { - synchronized (this) { - List updatedAgents = new ArrayList<>(); - boolean removed = false; - - for (AgentProfile agent : agents) { - if (!agent.getId().equals(agentId)) { - updatedAgents.add(agent); - } else { - removed = true; - } - } - - if (removed) { - agents = updatedAgents; - deleteAgentFromStorage(agentId); - saveAgentList(); - agentsLiveData.postValue(new ArrayList<>(agents)); - - // Clear active agent if it's the one being deleted - if (activeAgent != null && activeAgent.getId().equals(agentId)) { - setActiveAgentInternal(null); - } - } - } - }, executor); - } - - /** - * Get an agent profile by ID. - */ - @NonNull - public CompletableFuture getAgent(@NonNull String agentId) { - return CompletableFuture.supplyAsync(() -> { - synchronized (this) { - for (AgentProfile agent : agents) { - if (agent.getId().equals(agentId)) { - return agent; - } - } - throw new IllegalArgumentException("Agent not found: " + agentId); - } - }, executor); - } - - /** - * Set the active agent and start a new chat session. - */ - @NonNull - public CompletableFuture setActiveAgent(@Nullable AgentProfile agent) { - return CompletableFuture.runAsync(() -> { - synchronized (this) { - setActiveAgentInternal(agent); - } - }, executor); - } - - /** - * Get current active agent synchronously (for immediate access). - */ - @Nullable - public AgentProfile getCurrentActiveAgent() { - synchronized (this) { - return activeAgent; - } - } - - /** - * Get current session synchronously (for immediate access). - */ - @Nullable - public ChatSession getCurrentChatSession() { - synchronized (this) { - return currentSession; - } - } - - /** - * Clear all data (useful for testing). - */ - @NonNull - public CompletableFuture clearAll() { - return CompletableFuture.runAsync(() -> { - synchronized (this) { - agents.clear(); - activeAgent = null; - currentSession = null; - - preferences.edit() - .remove(KEY_AGENT_LIST) - .remove(KEY_ACTIVE_AGENT_ID) - .apply(); - - agentsLiveData.postValue(new ArrayList<>()); - activeAgentLiveData.postValue(null); - currentSessionLiveData.postValue(null); - } - }, executor); - } - - private void setActiveAgentInternal(@Nullable AgentProfile agent) { - android.util.Log.d("MultiAgentRepo", "=== SET ACTIVE AGENT INTERNAL ==="); - android.util.Log.d("MultiAgentRepo", "Previous active agent: " + (activeAgent != null ? activeAgent.getName() + " (ID: " + activeAgent.getId() + ")" : "null")); - android.util.Log.d("MultiAgentRepo", "New agent: " + (agent != null ? agent.getName() + " (ID: " + agent.getId() + ")" : "null")); - - activeAgent = agent; - - if (agent != null) { - // Update last used time - AgentProfile updatedAgent = agent.withLastUsedAt(System.currentTimeMillis()); - updateAgentInList(updatedAgent); - activeAgent = updatedAgent; - - // Start a new session - String newThreadId = ChatSession.generateThreadId(); - currentSession = new ChatSession(agent.getId(), newThreadId); - android.util.Log.d("MultiAgentRepo", "Created new session with thread ID: " + newThreadId); - - // Persist the active agent's ID - preferences.edit() - .putString(KEY_ACTIVE_AGENT_ID, agent.getId()) - .apply(); - } else { - currentSession = null; - preferences.edit() - .remove(KEY_ACTIVE_AGENT_ID) - .apply(); - } - - // Directly post the final values to LiveData - activeAgentLiveData.postValue(activeAgent); - currentSessionLiveData.postValue(currentSession); - } - private void updateAgentInList(@NonNull AgentProfile updatedAgent) { - for (int i = 0; i < agents.size(); i++) { - if (agents.get(i).getId().equals(updatedAgent.getId())) { - agents.set(i, updatedAgent); - saveAgent(updatedAgent); - agentsLiveData.postValue(new ArrayList<>(agents)); - break; - } - } - } - - private void loadData() { - executor.execute(() -> { - synchronized (this) { - // Load agent list - agents = loadAllAgents(); - - // Load active agent - String activeAgentId = preferences.getString(KEY_ACTIVE_AGENT_ID, null); - if (activeAgentId != null) { - for (AgentProfile agent : agents) { - if (agent.getId().equals(activeAgentId)) { - activeAgent = agent; - currentSession = new ChatSession(agent.getId(), ChatSession.generateThreadId()); - break; - } - } - } - - // Post initial values - agentsLiveData.postValue(new ArrayList<>(agents)); - activeAgentLiveData.postValue(activeAgent); - currentSessionLiveData.postValue(currentSession); - - // Mark initialization as complete - initializationFuture.complete(null); - } - }); - } - - /** - * Wait for the repository to finish loading initial data. - * Useful for tests to ensure data is loaded. - */ - public CompletableFuture waitForInitialization() { - return initializationFuture; - } - - private List loadAllAgents() { - String agentListString = preferences.getString(KEY_AGENT_LIST, ""); - if (agentListString.isEmpty()) { - return new ArrayList<>(); - } - - String[] agentIds = agentListString.split(","); - List loadedAgents = new ArrayList<>(); - - for (String agentId : agentIds) { - AgentProfile agent = loadAgent(agentId.trim()); - if (agent != null) { - loadedAgents.add(agent); - } - } - - return loadedAgents; - } - - private AgentProfile loadAgent(String agentId) { - String nameKey = PREFIX_AGENT + agentId + SUFFIX_NAME; - String name = preferences.getString(nameKey, null); - if (name == null) { - return null; // Agent doesn't exist - } - - String url = preferences.getString(PREFIX_AGENT + agentId + SUFFIX_URL, ""); - String description = preferences.getString(PREFIX_AGENT + agentId + SUFFIX_DESCRIPTION, null); - String authType = preferences.getString(PREFIX_AGENT + agentId + SUFFIX_AUTH_TYPE, "none"); - long createdAt = preferences.getLong(PREFIX_AGENT + agentId + SUFFIX_CREATED_AT, System.currentTimeMillis()); - long lastUsedAtLong = preferences.getLong(PREFIX_AGENT + agentId + SUFFIX_LAST_USED_AT, -1); - Long lastUsedAt = lastUsedAtLong == -1 ? null : lastUsedAtLong; - String systemPrompt = preferences.getString(PREFIX_AGENT + agentId + SUFFIX_SYSTEM_PROMPT, null); - - // Load auth method based on type - AuthMethod authMethod = loadAuthMethod(agentId, authType); - - return new AgentProfile.Builder() - .setId(agentId) - .setName(name) - .setUrl(url) - .setDescription(description) - .setAuthMethod(authMethod) - .setCreatedAt(createdAt) - .setLastUsedAt(lastUsedAt) - .setSystemPrompt(systemPrompt) - .build(); - } - - private AuthMethod loadAuthMethod(String agentId, String authType) { - switch (authType) { - case "none": - return new AuthMethod.None(); - case "api_key": - String key = preferences.getString(PREFIX_AGENT + agentId + SUFFIX_AUTH_KEY, ""); - String header = preferences.getString(PREFIX_AGENT + agentId + SUFFIX_AUTH_HEADER, "X-API-Key"); - return new AuthMethod.ApiKey(key, header); - case "bearer_token": - String token = preferences.getString(PREFIX_AGENT + agentId + SUFFIX_AUTH_TOKEN, ""); - return new AuthMethod.BearerToken(token); - case "basic_auth": - String username = preferences.getString(PREFIX_AGENT + agentId + SUFFIX_AUTH_USERNAME, ""); - String password = preferences.getString(PREFIX_AGENT + agentId + SUFFIX_AUTH_PASSWORD, ""); - return new AuthMethod.BasicAuth(username, password); - default: - return new AuthMethod.None(); - } - } - - private void saveAgent(AgentProfile agent) { - SharedPreferences.Editor editor = preferences.edit(); - String agentId = agent.getId(); - - // Save basic properties - editor.putString(PREFIX_AGENT + agentId + SUFFIX_NAME, agent.getName()); - editor.putString(PREFIX_AGENT + agentId + SUFFIX_URL, agent.getUrl()); - if (agent.getDescription() != null) { - editor.putString(PREFIX_AGENT + agentId + SUFFIX_DESCRIPTION, agent.getDescription()); - } - editor.putLong(PREFIX_AGENT + agentId + SUFFIX_CREATED_AT, agent.getCreatedAt()); - if (agent.getLastUsedAt() != null) { - editor.putLong(PREFIX_AGENT + agentId + SUFFIX_LAST_USED_AT, agent.getLastUsedAt()); - } - if (agent.getSystemPrompt() != null) { - editor.putString(PREFIX_AGENT + agentId + SUFFIX_SYSTEM_PROMPT, agent.getSystemPrompt()); - } - - // Save auth method - AuthMethod authMethod = agent.getAuthMethod(); - editor.putString(PREFIX_AGENT + agentId + SUFFIX_AUTH_TYPE, authMethod.getType()); - - if (authMethod instanceof AuthMethod.ApiKey) { - AuthMethod.ApiKey apiKey = (AuthMethod.ApiKey) authMethod; - editor.putString(PREFIX_AGENT + agentId + SUFFIX_AUTH_KEY, apiKey.getKey()); - editor.putString(PREFIX_AGENT + agentId + SUFFIX_AUTH_HEADER, apiKey.getHeaderName()); - } else if (authMethod instanceof AuthMethod.BearerToken) { - AuthMethod.BearerToken bearerToken = (AuthMethod.BearerToken) authMethod; - editor.putString(PREFIX_AGENT + agentId + SUFFIX_AUTH_TOKEN, bearerToken.getToken()); - } else if (authMethod instanceof AuthMethod.BasicAuth) { - AuthMethod.BasicAuth basicAuth = (AuthMethod.BasicAuth) authMethod; - editor.putString(PREFIX_AGENT + agentId + SUFFIX_AUTH_USERNAME, basicAuth.getUsername()); - editor.putString(PREFIX_AGENT + agentId + SUFFIX_AUTH_PASSWORD, basicAuth.getPassword()); - } - - editor.apply(); - } - - private void saveAgentList() { - List agentIds = new ArrayList<>(); - for (AgentProfile agent : agents) { - agentIds.add(agent.getId()); - } - String agentListString = String.join(",", agentIds); - preferences.edit() - .putString(KEY_AGENT_LIST, agentListString) - .apply(); - } - - private void deleteAgentFromStorage(String agentId) { - SharedPreferences.Editor editor = preferences.edit(); - - // Remove all agent properties - editor.remove(PREFIX_AGENT + agentId + SUFFIX_NAME); - editor.remove(PREFIX_AGENT + agentId + SUFFIX_URL); - editor.remove(PREFIX_AGENT + agentId + SUFFIX_DESCRIPTION); - editor.remove(PREFIX_AGENT + agentId + SUFFIX_AUTH_TYPE); - editor.remove(PREFIX_AGENT + agentId + SUFFIX_AUTH_KEY); - editor.remove(PREFIX_AGENT + agentId + SUFFIX_AUTH_HEADER); - editor.remove(PREFIX_AGENT + agentId + SUFFIX_AUTH_TOKEN); - editor.remove(PREFIX_AGENT + agentId + SUFFIX_AUTH_USERNAME); - editor.remove(PREFIX_AGENT + agentId + SUFFIX_AUTH_PASSWORD); - editor.remove(PREFIX_AGENT + agentId + SUFFIX_CREATED_AT); - editor.remove(PREFIX_AGENT + agentId + SUFFIX_LAST_USED_AT); - editor.remove(PREFIX_AGENT + agentId + SUFFIX_SYSTEM_PROMPT); - - editor.apply(); - } - -} \ No newline at end of file diff --git a/sdks/community/kotlin/examples/chatapp-java/app/src/main/java/com/agui/chatapp/java/repository/MultiAgentRepository.kt b/sdks/community/kotlin/examples/chatapp-java/app/src/main/java/com/agui/chatapp/java/repository/MultiAgentRepository.kt new file mode 100644 index 000000000..dc343efbf --- /dev/null +++ b/sdks/community/kotlin/examples/chatapp-java/app/src/main/java/com/agui/chatapp/java/repository/MultiAgentRepository.kt @@ -0,0 +1,115 @@ +package com.agui.chatapp.java.repository + +import android.content.Context +import androidx.lifecycle.LiveData +import androidx.lifecycle.MutableLiveData +import com.agui.example.chatapp.data.model.AgentConfig +import com.agui.example.chatapp.data.model.ChatSession +import com.agui.example.chatapp.data.repository.AgentRepository +import com.agui.example.chatapp.util.getPlatformSettings +import com.agui.example.chatapp.util.initializeAndroid +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.flow.collectLatest +import kotlinx.coroutines.launch +import java.util.concurrent.CompletableFuture + +/** + * Android-friendly wrapper around the shared [AgentRepository]. + * Exposes LiveData and `CompletableFuture` APIs for the existing Java UI. + */ +class MultiAgentRepository private constructor(context: Context) { + + private val applicationContext = context.applicationContext + private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default) + private val repository: AgentRepository + + private val _agentsLiveData = MutableLiveData>() + private val _activeAgentLiveData = MutableLiveData() + private val _currentSessionLiveData = MutableLiveData() + + init { + initializeAndroid(applicationContext) + repository = AgentRepository.getInstance(getPlatformSettings()) + + scope.launch { + repository.agents.collectLatest { agents -> + _agentsLiveData.postValue(agents) + } + } + scope.launch { + repository.activeAgent.collectLatest { active -> + _activeAgentLiveData.postValue(active) + } + } + scope.launch { + repository.currentSession.collectLatest { session -> + _currentSessionLiveData.postValue(session) + } + } + } + + fun getAgents(): LiveData> = _agentsLiveData + + fun getActiveAgent(): LiveData = _activeAgentLiveData + + fun getCurrentSession(): LiveData = _currentSessionLiveData + + fun addAgent(agent: AgentConfig): CompletableFuture = launchVoid { + repository.addAgent(agent) + } + + fun updateAgent(agent: AgentConfig): CompletableFuture = launchVoid { + repository.updateAgent(agent) + } + + fun deleteAgent(agentId: String): CompletableFuture = launchVoid { + repository.deleteAgent(agentId) + } + + fun setActiveAgent(agent: AgentConfig?): CompletableFuture = launchVoid { + repository.setActiveAgent(agent) + } + + fun getAgent(agentId: String): CompletableFuture = launchFuture { + repository.getAgent(agentId) + } + + fun clear(): CompletableFuture = launchVoid { + repository.setActiveAgent(null) + AgentRepository.resetInstance() + } + + private fun launchVoid(block: suspend () -> Unit): CompletableFuture { + val future = CompletableFuture() + scope.launch { + runCatching { block() } + .onSuccess { future.complete(null) } + .onFailure { throwable -> future.completeExceptionally(throwable) } + } + return future + } + + private fun launchFuture(block: suspend () -> T): CompletableFuture { + val future = CompletableFuture() + scope.launch { + runCatching { block() } + .onSuccess { result -> future.complete(result) } + .onFailure { throwable -> future.completeExceptionally(throwable) } + } + return future + } + + companion object { + @Volatile + private var INSTANCE: MultiAgentRepository? = null + + @JvmStatic + fun getInstance(context: Context): MultiAgentRepository { + return INSTANCE ?: synchronized(this) { + INSTANCE ?: MultiAgentRepository(context).also { INSTANCE = it } + } + } + } +} diff --git a/sdks/community/kotlin/examples/chatapp-java/app/src/main/java/com/agui/chatapp/java/ui/ChatActivity.java b/sdks/community/kotlin/examples/chatapp-java/app/src/main/java/com/agui/chatapp/java/ui/ChatActivity.java index 3126f03d3..6e6dcc9d7 100644 --- a/sdks/community/kotlin/examples/chatapp-java/app/src/main/java/com/agui/chatapp/java/ui/ChatActivity.java +++ b/sdks/community/kotlin/examples/chatapp-java/app/src/main/java/com/agui/chatapp/java/ui/ChatActivity.java @@ -1,6 +1,7 @@ package com.agui.chatapp.java.ui; import android.content.Intent; +import android.graphics.Color; import android.os.Bundle; import android.view.Menu; import android.view.MenuItem; @@ -10,7 +11,10 @@ import androidx.activity.result.ActivityResultLauncher; import androidx.activity.result.contract.ActivityResultContracts; +import androidx.annotation.AttrRes; +import androidx.annotation.ColorInt; import androidx.annotation.NonNull; +import androidx.annotation.Nullable; import androidx.appcompat.app.AppCompatActivity; import androidx.core.graphics.Insets; import androidx.core.view.ViewCompat; @@ -21,13 +25,16 @@ import com.agui.chatapp.java.R; import com.agui.chatapp.java.databinding.ActivityChatBinding; -import com.agui.chatapp.java.model.AgentProfile; +import com.agui.example.chatapp.data.model.AgentConfig; import com.agui.chatapp.java.ui.adapter.MessageAdapter; import com.agui.chatapp.java.viewmodel.ChatViewModel; +import com.agui.example.tools.BackgroundStyle; import com.google.android.material.snackbar.Snackbar; import java.util.ArrayList; +import android.util.TypedValue; + /** * Main chat activity using Material 3 design with Android View system. * Demonstrates Java integration with the Kotlin multiplatform AG-UI library. @@ -38,6 +45,8 @@ public class ChatActivity extends AppCompatActivity { private ChatViewModel viewModel; private MessageAdapter messageAdapter; private ActivityResultLauncher settingsLauncher; + @ColorInt + private int defaultBackgroundColor; @Override protected void onCreate(Bundle savedInstanceState) { @@ -49,6 +58,9 @@ protected void onCreate(Bundle savedInstanceState) { binding = ActivityChatBinding.inflate(getLayoutInflater()); setContentView(binding.getRoot()); + defaultBackgroundColor = resolveThemeColor(com.google.android.material.R.attr.colorSurface); + binding.getRoot().setBackgroundColor(defaultBackgroundColor); + // Setup edge-to-edge window insets setupEdgeToEdgeInsets(); @@ -122,6 +134,7 @@ private void observeViewModel() { Snackbar.make(binding.getRoot(), errorMessage, Snackbar.LENGTH_LONG) .setAction("Settings", v -> openSettings()) .show(); + viewModel.clearError(); } }); @@ -152,6 +165,35 @@ private void observeViewModel() { binding.noAgentCard.setVisibility(View.VISIBLE); } }); + + viewModel.getBackgroundStyle().observe(this, this::applyBackgroundStyle); + } + + private void applyBackgroundStyle(@Nullable BackgroundStyle style) { + if (binding == null) { + return; + } + + int targetColor = defaultBackgroundColor; + if (style != null) { + String colorHex = style.getColorHex(); + if (colorHex != null && !colorHex.isEmpty()) { + try { + targetColor = Color.parseColor(colorHex); + } catch (IllegalArgumentException ignored) { + android.util.Log.w("ChatActivity", "Invalid background colour received: " + colorHex); + } + } + } + + binding.getRoot().setBackgroundColor(targetColor); + } + + @ColorInt + private int resolveThemeColor(@AttrRes int attr) { + TypedValue typedValue = new TypedValue(); + boolean resolved = getTheme().resolveAttribute(attr, typedValue, true); + return resolved ? typedValue.data : Color.WHITE; } private void sendMessage() { @@ -285,4 +327,4 @@ protected void onDestroy() { super.onDestroy(); binding = null; } -} \ No newline at end of file +} diff --git a/sdks/community/kotlin/examples/chatapp-java/app/src/main/java/com/agui/chatapp/java/ui/SettingsActivity.java b/sdks/community/kotlin/examples/chatapp-java/app/src/main/java/com/agui/chatapp/java/ui/SettingsActivity.java index 442691ca6..a6f0c9fb7 100644 --- a/sdks/community/kotlin/examples/chatapp-java/app/src/main/java/com/agui/chatapp/java/ui/SettingsActivity.java +++ b/sdks/community/kotlin/examples/chatapp-java/app/src/main/java/com/agui/chatapp/java/ui/SettingsActivity.java @@ -2,6 +2,8 @@ import android.app.AlertDialog; import android.os.Bundle; +import android.text.Editable; +import android.text.TextWatcher; import android.view.LayoutInflater; import android.view.MenuItem; import android.view.View; @@ -10,417 +12,284 @@ import androidx.annotation.NonNull; import androidx.appcompat.app.AppCompatActivity; -import androidx.core.graphics.Insets; -import androidx.core.view.ViewCompat; -import androidx.core.view.WindowInsetsCompat; -import androidx.core.view.WindowCompat; import androidx.recyclerview.widget.LinearLayoutManager; import com.agui.chatapp.java.databinding.ActivitySettingsBinding; import com.agui.chatapp.java.databinding.DialogAgentFormBinding; -import com.agui.chatapp.java.model.AgentProfile; -import com.agui.chatapp.java.model.AuthMethod; import com.agui.chatapp.java.repository.MultiAgentRepository; import com.agui.chatapp.java.ui.adapter.AgentListAdapter; +import com.agui.example.chatapp.data.model.AgentConfig; +import com.agui.example.chatapp.data.model.AuthMethod; -import java.util.UUID; +import java.util.List; +import java.util.concurrent.CompletableFuture; + +import kotlin.collections.MapsKt; +import kotlinx.datetime.Clock; -/** - * Settings activity for managing multiple agent profiles. - * Includes full CRUD functionality with agent creation/editing dialogs. - */ public class SettingsActivity extends AppCompatActivity implements AgentListAdapter.OnAgentActionListener { - + private ActivitySettingsBinding binding; private MultiAgentRepository repository; - private AgentListAdapter agentAdapter; - + private AgentListAdapter adapter; + @Override protected void onCreate(Bundle savedInstanceState) { super.onCreate(savedInstanceState); - - // Enable edge-to-edge display - WindowCompat.setDecorFitsSystemWindows(getWindow(), false); - binding = ActivitySettingsBinding.inflate(getLayoutInflater()); setContentView(binding.getRoot()); - - // Setup toolbar + setSupportActionBar(binding.toolbar); if (getSupportActionBar() != null) { getSupportActionBar().setDisplayHomeAsUpEnabled(true); } - - // Setup edge-to-edge window insets - setupEdgeToEdgeInsets(); - // Initialize repository using the getInstance() method repository = MultiAgentRepository.getInstance(this); - - // Setup RecyclerView - setupRecyclerView(); - - // Setup listeners - setupListeners(); - - // Observe data - observeData(); - } - - private void setupRecyclerView() { - agentAdapter = new AgentListAdapter(); - agentAdapter.setOnAgentActionListener(this); - - binding.recyclerAgents.setAdapter(agentAdapter); + + adapter = new AgentListAdapter(); + adapter.setOnAgentActionListener(this); binding.recyclerAgents.setLayoutManager(new LinearLayoutManager(this)); - } - - private void setupListeners() { - // Floating action button + binding.recyclerAgents.setAdapter(adapter); + binding.fabAddAgent.setOnClickListener(v -> showAgentDialog(null)); + + observeRepository(); } - - private void observeData() { - // Observe agents list + + private void observeRepository() { repository.getAgents().observe(this, agents -> { - agentAdapter.submitList(agents); - - // Show/hide empty state - if (agents.isEmpty()) { - binding.recyclerAgents.setVisibility(android.view.View.GONE); - binding.layoutEmptyState.setVisibility(android.view.View.VISIBLE); + adapter.submitList(agents); + if (agents == null || agents.isEmpty()) { + binding.recyclerAgents.setVisibility(View.GONE); + binding.layoutEmptyState.setVisibility(View.VISIBLE); } else { - binding.recyclerAgents.setVisibility(android.view.View.VISIBLE); - binding.layoutEmptyState.setVisibility(android.view.View.GONE); + binding.recyclerAgents.setVisibility(View.VISIBLE); + binding.layoutEmptyState.setVisibility(View.GONE); } }); - - // Observe active agent - repository.getActiveAgent().observe(this, activeAgent -> { - String activeAgentId = activeAgent != null ? activeAgent.getId() : null; - agentAdapter.setActiveAgentId(activeAgentId); - }); + + repository.getActiveAgent().observe(this, agent -> + adapter.setActiveAgentId(agent != null ? agent.getId() : null)); } - + @Override - public void onActivateAgent(AgentProfile agent) { - android.util.Log.d("SettingsActivity", "=== ACTIVATING AGENT ==="); - android.util.Log.d("SettingsActivity", "Agent Name: " + agent.getName()); - android.util.Log.d("SettingsActivity", "Agent ID: " + agent.getId()); - android.util.Log.d("SettingsActivity", "Agent URL: " + agent.getUrl()); - - repository.setActiveAgent(agent) - .whenComplete((result, throwable) -> { - runOnUiThread(() -> { - if (throwable != null) { - android.util.Log.e("SettingsActivity", "Failed to activate agent", throwable); - Toast.makeText(this, "Failed to activate agent: " + throwable.getMessage(), - Toast.LENGTH_SHORT).show(); - } else { - android.util.Log.d("SettingsActivity", "Agent activation complete: " + agent.getName()); - Toast.makeText(this, "Agent activated: " + agent.getName(), - Toast.LENGTH_SHORT).show(); - } - }); - }); + public void onActivateAgent(AgentConfig agent) { + repository.setActiveAgent(agent).whenComplete((unused, throwable) -> + runOnUiThread(() -> { + if (throwable != null) { + Toast.makeText(this, "Failed: " + throwable.getMessage(), Toast.LENGTH_SHORT).show(); + } else { + Toast.makeText(this, "Agent activated: " + agent.getName(), Toast.LENGTH_SHORT).show(); + } + }) + ); } - + @Override - public void onEditAgent(AgentProfile agent) { + public void onEditAgent(AgentConfig agent) { showAgentDialog(agent); } - + @Override - public void onDeleteAgent(AgentProfile agent) { - // Simple confirmation for now - new android.app.AlertDialog.Builder(this) + public void onDeleteAgent(AgentConfig agent) { + new AlertDialog.Builder(this) .setTitle("Delete Agent") - .setMessage("Are you sure you want to delete \"" + agent.getName() + "\"?") - .setPositiveButton("Delete", (dialog, which) -> { - repository.deleteAgent(agent.getId()) - .whenComplete((result, throwable) -> { - runOnUiThread(() -> { - if (throwable != null) { - Toast.makeText(this, "Failed to delete agent: " + throwable.getMessage(), - Toast.LENGTH_SHORT).show(); - } else { - Toast.makeText(this, "Agent deleted", Toast.LENGTH_SHORT).show(); - } - }); - }); - }) + .setMessage("Delete \"" + agent.getName() + "\"?") + .setPositiveButton("Delete", (d, which) -> + repository.deleteAgent(agent.getId()).whenComplete((unused, throwable) -> + runOnUiThread(() -> { + if (throwable != null) { + Toast.makeText(this, "Failed: " + throwable.getMessage(), Toast.LENGTH_SHORT).show(); + } else { + Toast.makeText(this, "Agent deleted", Toast.LENGTH_SHORT).show(); + } + }) + ) + ) .setNegativeButton("Cancel", null) .show(); } - - private void showAgentDialog(AgentProfile existingAgent) { + + private void showAgentDialog(AgentConfig existing) { DialogAgentFormBinding dialogBinding = DialogAgentFormBinding.inflate(LayoutInflater.from(this)); - - // Auth type options - String[] authTypes = {"None", "API Key", "Bearer Token", "Basic Auth"}; - ArrayAdapter authAdapter = new ArrayAdapter<>(this, - android.R.layout.simple_dropdown_item_1line, authTypes); - dialogBinding.autoCompleteAuthType.setAdapter(authAdapter); - - // Pre-fill if editing - if (existingAgent != null) { - dialogBinding.editAgentName.setText(existingAgent.getName()); - dialogBinding.editAgentUrl.setText(existingAgent.getUrl()); - if (existingAgent.getDescription() != null) { - dialogBinding.editAgentDescription.setText(existingAgent.getDescription()); - } - if (existingAgent.getSystemPrompt() != null) { - dialogBinding.editSystemPrompt.setText(existingAgent.getSystemPrompt()); - } - - // Set auth type and fields - setAuthTypeInDialog(dialogBinding, existingAgent.getAuthMethod()); + String[] authOptions = {"None", "API Key", "Bearer Token", "Basic Auth"}; + ArrayAdapter adapter = new ArrayAdapter<>(this, android.R.layout.simple_dropdown_item_1line, authOptions); + dialogBinding.autoCompleteAuthType.setAdapter(adapter); + + if (existing != null) { + dialogBinding.editAgentName.setText(existing.getName()); + dialogBinding.editAgentUrl.setText(existing.getUrl()); + dialogBinding.editAgentDescription.setText(existing.getDescription() != null ? existing.getDescription() : ""); + dialogBinding.editSystemPrompt.setText(existing.getSystemPrompt() != null ? existing.getSystemPrompt() : ""); + applyAuthToDialog(existing.getAuthMethod(), dialogBinding, authOptions); } else { - // Default to "None" for new agents - dialogBinding.autoCompleteAuthType.setText(authTypes[0], false); - updateAuthFieldsVisibility(dialogBinding, new AuthMethod.None()); + dialogBinding.autoCompleteAuthType.setText(authOptions[0], false); + updateAuthFieldVisibility(dialogBinding, authOptions[0]); } - - // Auth type selection handler - dialogBinding.autoCompleteAuthType.setOnItemClickListener((parent, view, position, id) -> { - AuthMethod authMethod = getAuthMethodFromIndex(position); - updateAuthFieldsVisibility(dialogBinding, authMethod); - }); - - // Handle text changes (for test scenarios or manual typing) - dialogBinding.autoCompleteAuthType.addTextChangedListener(new android.text.TextWatcher() { - @Override - public void beforeTextChanged(CharSequence s, int start, int count, int after) {} - - @Override - public void onTextChanged(CharSequence s, int start, int before, int count) {} - - @Override - public void afterTextChanged(android.text.Editable s) { - String text = s.toString(); - AuthMethod authMethod = getAuthMethodFromString(text, dialogBinding); - updateAuthFieldsVisibility(dialogBinding, authMethod); + + dialogBinding.autoCompleteAuthType.setOnItemClickListener((parent, view, position, id) -> + updateAuthFieldVisibility(dialogBinding, authOptions[position])); + dialogBinding.autoCompleteAuthType.addTextChangedListener(new SimpleTextWatcher() { + @Override public void afterTextChanged(Editable editable) { + updateAuthFieldVisibility(dialogBinding, editable.toString()); } }); - + AlertDialog dialog = new AlertDialog.Builder(this) - .setTitle(existingAgent != null ? "Edit Agent" : "Add Agent") + .setTitle(existing != null ? "Edit Agent" : "Add Agent") .setView(dialogBinding.getRoot()) - .setPositiveButton(existingAgent != null ? "Update" : "Add", null) + .setPositiveButton(existing != null ? "Save" : "Add", null) .setNegativeButton("Cancel", null) .create(); - - dialog.setOnShowListener(dialogInterface -> { - dialog.getButton(AlertDialog.BUTTON_POSITIVE).setOnClickListener(v -> { - if (validateAndSaveAgent(dialogBinding, existingAgent)) { - dialog.dismiss(); - } - }); - }); - + + dialog.setOnShowListener(d -> + dialog.getButton(AlertDialog.BUTTON_POSITIVE).setOnClickListener(v -> + handleSaveAgent(dialog, dialogBinding, authOptions, existing)) + ); + dialog.show(); } - - private void setAuthTypeInDialog(DialogAgentFormBinding dialogBinding, AuthMethod authMethod) { - if (authMethod instanceof AuthMethod.None) { - dialogBinding.autoCompleteAuthType.setText("None", false); - updateAuthFieldsVisibility(dialogBinding, authMethod); - } else if (authMethod instanceof AuthMethod.ApiKey) { - AuthMethod.ApiKey apiKey = (AuthMethod.ApiKey) authMethod; - dialogBinding.autoCompleteAuthType.setText("API Key", false); - dialogBinding.editApiKey.setText(apiKey.getKey()); - updateAuthFieldsVisibility(dialogBinding, authMethod); - } else if (authMethod instanceof AuthMethod.BearerToken) { - AuthMethod.BearerToken bearerToken = (AuthMethod.BearerToken) authMethod; - dialogBinding.autoCompleteAuthType.setText("Bearer Token", false); - dialogBinding.editBearerToken.setText(bearerToken.getToken()); - updateAuthFieldsVisibility(dialogBinding, authMethod); - } else if (authMethod instanceof AuthMethod.BasicAuth) { - AuthMethod.BasicAuth basicAuth = (AuthMethod.BasicAuth) authMethod; - dialogBinding.autoCompleteAuthType.setText("Basic Auth", false); - dialogBinding.editBasicUsername.setText(basicAuth.getUsername()); - dialogBinding.editBasicPassword.setText(basicAuth.getPassword()); - updateAuthFieldsVisibility(dialogBinding, authMethod); - } - } - - private AuthMethod getAuthMethodFromIndex(int index) { - switch (index) { - case 0: return new AuthMethod.None(); - case 1: return new AuthMethod.ApiKey(""); // Will be filled later - case 2: return new AuthMethod.BearerToken(""); // Will be filled later - case 3: return new AuthMethod.BasicAuth("", ""); // Will be filled later - default: return new AuthMethod.None(); - } - } - - private void updateAuthFieldsVisibility(DialogAgentFormBinding dialogBinding, AuthMethod authMethod) { - // Hide all auth fields first - dialogBinding.textInputApiKey.setVisibility(View.GONE); - dialogBinding.textInputBearerToken.setVisibility(View.GONE); - dialogBinding.textInputBasicUsername.setVisibility(View.GONE); - dialogBinding.textInputBasicPassword.setVisibility(View.GONE); - - // Show relevant fields based on auth type - if (authMethod instanceof AuthMethod.ApiKey) { - dialogBinding.textInputApiKey.setVisibility(View.VISIBLE); - } else if (authMethod instanceof AuthMethod.BearerToken) { - dialogBinding.textInputBearerToken.setVisibility(View.VISIBLE); - } else if (authMethod instanceof AuthMethod.BasicAuth) { - dialogBinding.textInputBasicUsername.setVisibility(View.VISIBLE); - dialogBinding.textInputBasicPassword.setVisibility(View.VISIBLE); - } - // No additional fields for None - } - - private boolean validateAndSaveAgent(DialogAgentFormBinding dialogBinding, AgentProfile existingAgent) { - // Clear previous errors - dialogBinding.textInputAgentName.setError(null); - dialogBinding.textInputAgentUrl.setError(null); - dialogBinding.textInputApiKey.setError(null); - dialogBinding.textInputBearerToken.setError(null); - dialogBinding.textInputBasicUsername.setError(null); - dialogBinding.textInputBasicPassword.setError(null); - - // Validate required fields - String name = dialogBinding.editAgentName.getText().toString().trim(); + + private void handleSaveAgent(AlertDialog dialog, + DialogAgentFormBinding binding, + String[] authOptions, + AgentConfig existing) { + String name = getTrimmed(binding.editAgentName.getText()); + String url = getTrimmed(binding.editAgentUrl.getText()); + String description = getTrimmed(binding.editAgentDescription.getText()); + String systemPrompt = getTrimmed(binding.editSystemPrompt.getText()); + String authSelection = getTrimmed(binding.autoCompleteAuthType.getText()); + + boolean hasError = false; if (name.isEmpty()) { - dialogBinding.textInputAgentName.setError("Agent name is required"); - return false; - } - - String url = dialogBinding.editAgentUrl.getText().toString().trim(); - if (url.isEmpty()) { - dialogBinding.textInputAgentUrl.setError("Agent URL is required"); - return false; - } - - // Get auth method - String selectedAuthType = dialogBinding.autoCompleteAuthType.getText().toString(); - AuthMethod authMethod = getAuthMethodFromString(selectedAuthType, dialogBinding); - - // Validate auth method - if (!authMethod.isValid()) { - if (authMethod instanceof AuthMethod.ApiKey) { - dialogBinding.textInputApiKey.setError("API key is required"); - } else if (authMethod instanceof AuthMethod.BearerToken) { - dialogBinding.textInputBearerToken.setError("Bearer token is required"); - } else if (authMethod instanceof AuthMethod.BasicAuth) { - if (dialogBinding.editBasicUsername.getText().toString().trim().isEmpty()) { - dialogBinding.textInputBasicUsername.setError("Username is required"); - } - if (dialogBinding.editBasicPassword.getText().toString().trim().isEmpty()) { - dialogBinding.textInputBasicPassword.setError("Password is required"); - } - } - return false; - } - - // Create agent profile - String description = dialogBinding.editAgentDescription.getText().toString().trim(); - String systemPrompt = dialogBinding.editSystemPrompt.getText().toString().trim(); - - AgentProfile.Builder builder; - if (existingAgent != null) { - builder = existingAgent.toBuilder(); + binding.textInputAgentName.setError("Name is required"); + hasError = true; } else { - builder = new AgentProfile.Builder() - .setId(UUID.randomUUID().toString()) - .setCreatedAt(System.currentTimeMillis()); + binding.textInputAgentName.setError(null); } - - AgentProfile agent = builder - .setName(name) - .setUrl(url) - .setDescription(description.isEmpty() ? null : description) - .setSystemPrompt(systemPrompt.isEmpty() ? null : systemPrompt) - .setAuthMethod(authMethod) - .build(); - - // Save agent - if (existingAgent != null) { - repository.updateAgent(agent) - .whenComplete((result, throwable) -> { - runOnUiThread(() -> { - if (throwable != null) { - Toast.makeText(this, "Failed to update agent: " + throwable.getMessage(), - Toast.LENGTH_SHORT).show(); - } else { - Toast.makeText(this, "Agent updated", Toast.LENGTH_SHORT).show(); - } - }); - }); + + if (url.isEmpty()) { + binding.textInputAgentUrl.setError("URL is required"); + hasError = true; } else { - repository.addAgent(agent) - .whenComplete((result, throwable) -> { - runOnUiThread(() -> { - if (throwable != null) { - Toast.makeText(this, "Failed to add agent: " + throwable.getMessage(), - Toast.LENGTH_SHORT).show(); - } else { - Toast.makeText(this, "Agent added", Toast.LENGTH_SHORT).show(); - } - }); - }); + binding.textInputAgentUrl.setError(null); } - - return true; - } - - private AuthMethod getAuthMethodFromString(String authTypeString, DialogAgentFormBinding dialogBinding) { - switch (authTypeString) { - case "None": - return new AuthMethod.None(); + + if (hasError) return; + + AuthMethod authMethod; + switch (authSelection) { case "API Key": - String apiKey = dialogBinding.editApiKey.getText().toString().trim(); - return new AuthMethod.ApiKey(apiKey); + String apiKey = getTrimmed(binding.editApiKey.getText()); + if (apiKey.isEmpty()) { + binding.textInputApiKey.setError("API key required"); + return; + } + binding.textInputApiKey.setError(null); + authMethod = new AuthMethod.ApiKey(apiKey, "X-API-Key"); + break; case "Bearer Token": - String bearerToken = dialogBinding.editBearerToken.getText().toString().trim(); - return new AuthMethod.BearerToken(bearerToken); + String token = getTrimmed(binding.editBearerToken.getText()); + if (token.isEmpty()) { + binding.textInputBearerToken.setError("Token required"); + return; + } + binding.textInputBearerToken.setError(null); + authMethod = new AuthMethod.BearerToken(token); + break; case "Basic Auth": - String username = dialogBinding.editBasicUsername.getText().toString().trim(); - String password = dialogBinding.editBasicPassword.getText().toString().trim(); - return new AuthMethod.BasicAuth(username, password); + String username = getTrimmed(binding.editBasicUsername.getText()); + String password = getTrimmed(binding.editBasicPassword.getText()); + if (username.isEmpty()) { + binding.textInputBasicUsername.setError("Username required"); + return; + } + if (password.isEmpty()) { + binding.textInputBasicPassword.setError("Password required"); + return; + } + binding.textInputBasicUsername.setError(null); + binding.textInputBasicPassword.setError(null); + authMethod = new AuthMethod.BasicAuth(username, password); + break; default: - return new AuthMethod.None(); + authMethod = new AuthMethod.None(); } + + AgentConfig config = buildAgentConfig(existing, name, url, + description.isEmpty() ? null : description, + authMethod, + systemPrompt.isEmpty() ? null : systemPrompt); + + CompletableFuture future = (existing == null) + ? repository.addAgent(config) + : repository.updateAgent(config); + + future.whenComplete((unused, throwable) -> runOnUiThread(() -> { + if (throwable != null) { + Toast.makeText(this, "Failed: " + throwable.getMessage(), Toast.LENGTH_SHORT).show(); + } else { + Toast.makeText(this, "Agent saved", Toast.LENGTH_SHORT).show(); + dialog.dismiss(); + } + })); } - - private void setupEdgeToEdgeInsets() { - ViewCompat.setOnApplyWindowInsetsListener(binding.getRoot(), (v, insets) -> { - Insets systemBars = insets.getInsets(WindowInsetsCompat.Type.systemBars()); - - // Since we're using a simple layout without AppBarLayout, we need to handle the toolbar differently - // Apply padding to the whole root view for status bar - binding.getRoot().setPadding( - 0, - systemBars.top, - 0, - 0 - ); - - // Apply bottom padding to RecyclerView for navigation bar - binding.recyclerAgents.setPadding( - binding.recyclerAgents.getPaddingLeft(), - binding.recyclerAgents.getPaddingTop(), - binding.recyclerAgents.getPaddingRight(), - systemBars.bottom - ); - binding.recyclerAgents.setClipToPadding(false); - - // Apply bottom padding to empty state for navigation bar - binding.layoutEmptyState.setPadding( - binding.layoutEmptyState.getPaddingLeft(), - binding.layoutEmptyState.getPaddingTop(), - binding.layoutEmptyState.getPaddingRight(), - systemBars.bottom - ); - - // Apply bottom margin to FAB to avoid nav bar overlap - binding.fabAddAgent.setTranslationY(-systemBars.bottom); - - return insets; - }); + + private AgentConfig buildAgentConfig(AgentConfig base, + String name, + String url, + String description, + AuthMethod authMethod, + String systemPrompt) { + String id = base != null ? base.getId() : AgentConfig.Companion.generateId(); + boolean active = base != null && base.isActive(); + return new AgentConfig( + id, + name, + url, + description, + authMethod, + active, + base != null ? base.getCreatedAt() : Clock.System.INSTANCE.now(), + base != null ? base.getLastUsedAt() : null, + base != null ? base.getCustomHeaders() : kotlin.collections.MapsKt.emptyMap(), + systemPrompt + ); + } + + private void applyAuthToDialog(AuthMethod method, DialogAgentFormBinding binding, String[] authOptions) { + if (method instanceof AuthMethod.ApiKey) { + binding.autoCompleteAuthType.setText(authOptions[1], false); + binding.editApiKey.setText(((AuthMethod.ApiKey) method).getKey()); + updateAuthFieldVisibility(binding, authOptions[1]); + } else if (method instanceof AuthMethod.BearerToken) { + binding.autoCompleteAuthType.setText(authOptions[2], false); + binding.editBearerToken.setText(((AuthMethod.BearerToken) method).getToken()); + updateAuthFieldVisibility(binding, authOptions[2]); + } else if (method instanceof AuthMethod.BasicAuth) { + binding.autoCompleteAuthType.setText(authOptions[3], false); + binding.editBasicUsername.setText(((AuthMethod.BasicAuth) method).getUsername()); + binding.editBasicPassword.setText(((AuthMethod.BasicAuth) method).getPassword()); + updateAuthFieldVisibility(binding, authOptions[3]); + } else { + binding.autoCompleteAuthType.setText(authOptions[0], false); + updateAuthFieldVisibility(binding, authOptions[0]); + } + } + + private void updateAuthFieldVisibility(DialogAgentFormBinding binding, String selection) { + boolean apiKey = "API Key".equalsIgnoreCase(selection); + boolean bearer = "Bearer Token".equalsIgnoreCase(selection); + boolean basic = "Basic Auth".equalsIgnoreCase(selection); + + binding.textInputApiKey.setVisibility(apiKey ? View.VISIBLE : View.GONE); + binding.textInputBearerToken.setVisibility(bearer ? View.VISIBLE : View.GONE); + binding.textInputBasicUsername.setVisibility(basic ? View.VISIBLE : View.GONE); + binding.textInputBasicPassword.setVisibility(basic ? View.VISIBLE : View.GONE); + } + + private static String getTrimmed(CharSequence text) { + return text == null ? "" : text.toString().trim(); } @Override @@ -431,10 +300,9 @@ public boolean onOptionsItemSelected(@NonNull MenuItem item) { } return super.onOptionsItemSelected(item); } - - @Override - protected void onDestroy() { - super.onDestroy(); - binding = null; + + private abstract static class SimpleTextWatcher implements TextWatcher { + @Override public void beforeTextChanged(CharSequence s, int start, int count, int after) {} + @Override public void onTextChanged(CharSequence s, int start, int before, int count) {} } -} \ No newline at end of file +} diff --git a/sdks/community/kotlin/examples/chatapp-java/app/src/main/java/com/agui/chatapp/java/ui/adapter/AgentListAdapter.java b/sdks/community/kotlin/examples/chatapp-java/app/src/main/java/com/agui/chatapp/java/ui/adapter/AgentListAdapter.java index 88a92eb82..67740677b 100644 --- a/sdks/community/kotlin/examples/chatapp-java/app/src/main/java/com/agui/chatapp/java/ui/adapter/AgentListAdapter.java +++ b/sdks/community/kotlin/examples/chatapp-java/app/src/main/java/com/agui/chatapp/java/ui/adapter/AgentListAdapter.java @@ -7,43 +7,38 @@ import androidx.recyclerview.widget.DiffUtil; import androidx.recyclerview.widget.ListAdapter; import androidx.recyclerview.widget.RecyclerView; - import com.agui.chatapp.java.databinding.ItemAgentCardBinding; -import com.agui.chatapp.java.model.AgentProfile; -import com.agui.chatapp.java.model.AuthMethod; - +import com.agui.example.chatapp.data.model.AgentConfig; +import com.agui.example.chatapp.data.model.AuthMethod; import java.text.SimpleDateFormat; import java.util.Date; import java.util.Locale; -/** - * RecyclerView adapter for displaying agent profiles in a list. - * Uses Material 3 design with agent cards showing basic info and action buttons. - */ -public class AgentListAdapter extends ListAdapter { - - private OnAgentActionListener actionListener; - private String activeAgentId; - +public class AgentListAdapter extends ListAdapter { + public interface OnAgentActionListener { - void onActivateAgent(AgentProfile agent); - void onEditAgent(AgentProfile agent); - void onDeleteAgent(AgentProfile agent); + void onActivateAgent(AgentConfig agent); + void onEditAgent(AgentConfig agent); + void onDeleteAgent(AgentConfig agent); } - + + private final SimpleDateFormat formatter = new SimpleDateFormat("MMM dd, HH:mm", Locale.getDefault()); + private OnAgentActionListener actionListener; + private String activeAgentId; + public AgentListAdapter() { - super(new AgentDiffCallback()); + super(DIFF_CALLBACK); } - + public void setOnAgentActionListener(OnAgentActionListener listener) { this.actionListener = listener; } - - public void setActiveAgentId(String activeAgentId) { - this.activeAgentId = activeAgentId; - notifyDataSetChanged(); // Refresh to update active state indicators + + public void setActiveAgentId(String id) { + this.activeAgentId = id; + notifyDataSetChanged(); } - + @NonNull @Override public AgentViewHolder onCreateViewHolder(@NonNull ViewGroup parent, int viewType) { @@ -51,51 +46,45 @@ public AgentViewHolder onCreateViewHolder(@NonNull ViewGroup parent, int viewTyp LayoutInflater.from(parent.getContext()), parent, false); return new AgentViewHolder(binding); } - + @Override public void onBindViewHolder(@NonNull AgentViewHolder holder, int position) { holder.bind(getItem(position)); } - - class AgentViewHolder extends RecyclerView.ViewHolder { + + public class AgentViewHolder extends RecyclerView.ViewHolder { private final ItemAgentCardBinding binding; - - public AgentViewHolder(@NonNull ItemAgentCardBinding binding) { + + AgentViewHolder(ItemAgentCardBinding binding) { super(binding.getRoot()); this.binding = binding; } - - public void bind(AgentProfile agent) { + + void bind(AgentConfig agent) { boolean isActive = agent.getId().equals(activeAgentId); - - // Set basic info + binding.textAgentName.setText(agent.getName()); binding.textAgentUrl.setText(agent.getUrl()); - - // Description (optional) + if (agent.getDescription() != null && !agent.getDescription().isEmpty()) { - binding.textAgentDescription.setText(agent.getDescription()); binding.textAgentDescription.setVisibility(View.VISIBLE); + binding.textAgentDescription.setText(agent.getDescription()); } else { binding.textAgentDescription.setVisibility(View.GONE); } - - // Auth method chip - binding.chipAuthMethod.setText(getAuthMethodLabel(agent.getAuthMethod())); - - // Last used info + + binding.chipAuthMethod.setText(labelForAuth(agent.getAuthMethod())); + if (agent.getLastUsedAt() != null) { - String lastUsed = formatDateTime(agent.getLastUsedAt()); - binding.textLastUsed.setText("Last used: " + lastUsed); + long millis = agent.getLastUsedAt().toEpochMilliseconds(); binding.textLastUsed.setVisibility(View.VISIBLE); + binding.textLastUsed.setText("Last used: " + formatter.format(new Date(millis))); } else { binding.textLastUsed.setVisibility(View.GONE); } - - // Active indicator + binding.iconActive.setVisibility(isActive ? View.VISIBLE : View.GONE); - - // Activate button (only show if not active) + if (isActive) { binding.btnActivate.setVisibility(View.GONE); } else { @@ -106,69 +95,43 @@ public void bind(AgentProfile agent) { } }); } - - // Edit button + binding.btnEdit.setOnClickListener(v -> { if (actionListener != null) { actionListener.onEditAgent(agent); } }); - - // Delete button + binding.btnDelete.setOnClickListener(v -> { if (actionListener != null) { actionListener.onDeleteAgent(agent); } }); - - // Card highlighting for active agent - // Use theme-appropriate colors with proper contrast - if (isActive) { - // Highlight active agent with elevated appearance - binding.cardAgent.setCardBackgroundColor( - binding.getRoot().getContext().getColor(com.google.android.material.R.color.cardview_light_background)); - binding.cardAgent.setCardElevation(8f); - binding.cardAgent.setStrokeWidth(2); - binding.cardAgent.setStrokeColor( - binding.getRoot().getContext().getColor(com.google.android.material.R.color.design_default_color_primary)); - } else { - // Default appearance for inactive agents - binding.cardAgent.setCardBackgroundColor( - binding.getRoot().getContext().getColor(com.google.android.material.R.color.cardview_light_background)); - binding.cardAgent.setCardElevation(2f); - binding.cardAgent.setStrokeWidth(0); - } } - - private String getAuthMethodLabel(AuthMethod authMethod) { - if (authMethod instanceof AuthMethod.None) { - return "No Auth"; - } else if (authMethod instanceof AuthMethod.ApiKey) { + + private String labelForAuth(AuthMethod method) { + if (method instanceof AuthMethod.ApiKey) { return "API Key"; - } else if (authMethod instanceof AuthMethod.BearerToken) { + } else if (method instanceof AuthMethod.BearerToken) { return "Bearer Token"; - } else if (authMethod instanceof AuthMethod.BasicAuth) { + } else if (method instanceof AuthMethod.BasicAuth) { return "Basic Auth"; } else { - return "Unknown"; + return "No Auth"; } } - - private String formatDateTime(Long timestamp) { - SimpleDateFormat formatter = new SimpleDateFormat("MMM dd, HH:mm", Locale.getDefault()); - return formatter.format(new Date(timestamp)); - } - } - - static class AgentDiffCallback extends DiffUtil.ItemCallback { - @Override - public boolean areItemsTheSame(@NonNull AgentProfile oldItem, @NonNull AgentProfile newItem) { - return oldItem.getId().equals(newItem.getId()); - } - - @Override - public boolean areContentsTheSame(@NonNull AgentProfile oldItem, @NonNull AgentProfile newItem) { - return oldItem.equals(newItem); - } } -} \ No newline at end of file + + private static final DiffUtil.ItemCallback DIFF_CALLBACK = + new DiffUtil.ItemCallback() { + @Override + public boolean areItemsTheSame(@NonNull AgentConfig oldItem, @NonNull AgentConfig newItem) { + return oldItem.getId().equals(newItem.getId()); + } + + @Override + public boolean areContentsTheSame(@NonNull AgentConfig oldItem, @NonNull AgentConfig newItem) { + return oldItem.equals(newItem); + } + }; +} diff --git a/sdks/community/kotlin/examples/chatapp-java/app/src/main/java/com/agui/chatapp/java/ui/adapter/MessageAdapter.java b/sdks/community/kotlin/examples/chatapp-java/app/src/main/java/com/agui/chatapp/java/ui/adapter/MessageAdapter.java index caa7f5803..c13f927b3 100644 --- a/sdks/community/kotlin/examples/chatapp-java/app/src/main/java/com/agui/chatapp/java/ui/adapter/MessageAdapter.java +++ b/sdks/community/kotlin/examples/chatapp-java/app/src/main/java/com/agui/chatapp/java/ui/adapter/MessageAdapter.java @@ -1,5 +1,6 @@ package com.agui.chatapp.java.ui.adapter; +import android.text.method.LinkMovementMethod; import android.view.LayoutInflater; import android.view.View; import android.view.ViewGroup; @@ -13,7 +14,8 @@ import com.agui.chatapp.java.R; import com.agui.chatapp.java.model.ChatMessage; -import com.agui.core.types.Role; +import com.agui.example.chatapp.chat.MessageRole; +import io.noties.markwon.Markwon; /** * RecyclerView adapter for displaying chat messages with different layouts @@ -24,6 +26,8 @@ public class MessageAdapter extends ListAdapter> messages = new MutableLiveData<>(new ArrayList<>()); - private final MutableLiveData isConnecting = new MutableLiveData<>(false); - private final MutableLiveData errorMessage = new MutableLiveData<>(); - private final MutableLiveData hasAgentConfig = new MutableLiveData<>(false); - - // Current active agent - private AgentProfile currentAgent; - - // Agent and adapter - private AgUiJavaAdapter agentAdapter; - - // Thread ID management - private String currentThreadId; - - // Message tracking for streaming - private final Map streamingMessages = new HashMap<>(); - - public ChatViewModel(@NonNull Application application) { - super(application); - // Use the getInstance() method - this.repository = MultiAgentRepository.getInstance(application); - this.currentThreadId = generateNewThreadId(); - } - - // LiveData getters - public LiveData> getMessages() { - return messages; - } - - public LiveData getIsConnecting() { - return isConnecting; - } - - public LiveData getErrorMessage() { - return errorMessage; - } - - public LiveData getHasAgentConfig() { - return hasAgentConfig; - } - - public LiveData getActiveAgent() { - return repository.getActiveAgent(); - } - - /** - * Handle active agent changes - * This is called whenever an agent is activated from the Settings screen - */ - public void setActiveAgent(AgentProfile agent) { - // Log for debugging - String currentAgentId = currentAgent != null ? currentAgent.getId() : "null"; - String newAgentId = agent != null ? agent.getId() : "null"; - - // Only proceed if the agent has actually changed - if ((currentAgentId == null && newAgentId == null) || - (currentAgentId != null && currentAgentId.equals(newAgentId))) { - return; - } - - - // Clean up existing agent if any - if (agentAdapter != null) { - agentAdapter.close(); - agentAdapter = null; - } - - // Always clear messages for a fresh start - messages.setValue(new ArrayList<>()); - streamingMessages.clear(); - - // Always generate new thread ID for fresh conversation - currentThreadId = generateNewThreadId(); - - // Update current agent reference - currentAgent = agent; - - if (currentAgent != null) { - initializeAgent(); - } else { - // Handle the case where there is no active agent - hasAgentConfig.setValue(false); - } - } - - /** - * Initialize the agent with current active agent profile - */ - private void initializeAgent() { - if (currentAgent == null) { - hasAgentConfig.setValue(false); - return; - } - - Log.d(TAG, "Initializing agent: " + currentAgent.getName() + " with URL: " + currentAgent.getUrl()); - - try { - AgUiAgentBuilder builder = AgUiAgentBuilder.create(currentAgent.getUrl()) - .debug(false); // Debug can be made configurable later - - // Add authentication based on agent profile - AuthMethod authMethod = currentAgent.getAuthMethod(); - Log.d(TAG, "Auth method: " + authMethod.getClass().getSimpleName()); - - if (authMethod instanceof AuthMethod.BearerToken) { - AuthMethod.BearerToken bearerToken = (AuthMethod.BearerToken) authMethod; - builder.bearerToken(bearerToken.getToken()); - Log.d(TAG, "Using Bearer Token auth"); - } else if (authMethod instanceof AuthMethod.ApiKey) { - AuthMethod.ApiKey apiKey = (AuthMethod.ApiKey) authMethod; - builder.apiKey(apiKey.getKey()) - .apiKeyHeader(apiKey.getHeaderName()); - Log.d(TAG, "Using API Key auth with header: " + apiKey.getHeaderName()); - } else if (authMethod instanceof AuthMethod.BasicAuth) { - AuthMethod.BasicAuth basicAuth = (AuthMethod.BasicAuth) authMethod; - // Basic auth needs to be Base64 encoded and added as Authorization header - String credentials = basicAuth.getUsername() + ":" + basicAuth.getPassword(); - String encodedCredentials = android.util.Base64.encodeToString( - credentials.getBytes(), android.util.Base64.NO_WRAP); - builder.addHeader("Authorization", "Basic " + encodedCredentials); - Log.d(TAG, "Using Basic Auth with username: " + basicAuth.getUsername()); - } else { - Log.d(TAG, "No authentication configured"); - } - - // Add system prompt - if (currentAgent.getSystemPrompt() != null && !currentAgent.getSystemPrompt().trim().isEmpty()) { - builder.systemPrompt(currentAgent.getSystemPrompt()); - Log.d(TAG, "System prompt configured: " + currentAgent.getSystemPrompt().substring(0, Math.min(50, currentAgent.getSystemPrompt().length())) + "..."); - } else { - Log.d(TAG, "No system prompt configured"); - } - - agentAdapter = new AgUiJavaAdapter(builder.buildStateful()); - hasAgentConfig.setValue(true); - Log.d(TAG, "Agent initialized successfully: " + currentAgent.getName()); - - } catch (Exception e) { - Log.e(TAG, "Failed to initialize agent", e); - errorMessage.setValue("Failed to initialize agent: " + e.getMessage()); - hasAgentConfig.setValue(false); - } - } - - /** - * Send a message to the agent - */ - public void sendMessage(String messageText) { - if (agentAdapter == null) { - Log.e(TAG, "Agent adapter is null - cannot send message"); - errorMessage.setValue("Agent not configured"); - return; - } - - if (messageText == null || messageText.trim().isEmpty()) { - return; - } - - String threadIdBeingSent = getCurrentThreadId(); - - // Add user message to chat - ChatMessage userMessage = new ChatMessage( - "user_" + System.currentTimeMillis(), - Role.USER, - messageText.trim(), - null - ); - addMessage(userMessage); - - // Start connecting - isConnecting.setValue(true); - - // Send message to agent with current thread ID - Disposable disposable = agentAdapter.sendMessage(messageText.trim(), threadIdBeingSent, new EventCallback() { - @Override - public void onEvent(BaseEvent event) { - handleAgentEvent(event); - } - - @Override - public void onError(Throwable error) { - Log.e(TAG, "Agent error", error); - isConnecting.setValue(false); - errorMessage.setValue("Connection error: " + error.getMessage()); - } - - @Override - public void onComplete() { - Log.d(TAG, "Agent response complete"); - isConnecting.setValue(false); - - // Finish any remaining streaming messages by replacing them - if (!streamingMessages.isEmpty()) { - List currentMessages = messages.getValue(); - if (currentMessages != null) { - List newMessages = new ArrayList<>(currentMessages); - - for (ChatMessage streamingMessage : streamingMessages.values()) { - // Create finished message - ChatMessage finishedMessage = new ChatMessage( - streamingMessage.getId(), - streamingMessage.getRole(), - streamingMessage.getContent(), - streamingMessage.getName() - ); - - // Replace in list - int index = newMessages.indexOf(streamingMessage); - if (index >= 0) { - newMessages.set(index, finishedMessage); - } - } - - messages.setValue(newMessages); - } - - streamingMessages.clear(); - } - } - }); - - disposables.add(disposable); - } - - /** - * Handle events from the agent - */ - private void handleAgentEvent(BaseEvent event) { - EventProcessor.processEvent(event, new EventProcessor.EventHandler() { - @Override - public void onRunStarted(RunStartedEvent event) { - Log.d(TAG, "Run started: " + event.getRunId()); - } - - @Override - public void onRunFinished(RunFinishedEvent event) { - Log.d(TAG, "Run finished: " + event.getRunId()); - isConnecting.setValue(false); - } - - @Override - public void onRunError(RunErrorEvent event) { - Log.e(TAG, "Run error: " + event.getMessage()); - isConnecting.setValue(false); - errorMessage.setValue("Agent error: " + event.getMessage()); - } - - @Override - public void onStepStarted(StepStartedEvent event) { - Log.d(TAG, "Step started: " + event.getStepName()); - } - - @Override - public void onStepFinished(StepFinishedEvent event) { - Log.d(TAG, "Step finished: " + event.getStepName()); - } - - @Override - public void onTextMessageStart(TextMessageStartEvent event) { - Log.d(TAG, "Text message start: " + event.getMessageId()); - - // Create streaming message - ChatMessage streamingMessage = ChatMessage.createStreaming( - event.getMessageId(), - Role.ASSISTANT, - null - ); - - streamingMessages.put(event.getMessageId(), streamingMessage); - addMessage(streamingMessage); - } - - @Override - public void onTextMessageContent(TextMessageContentEvent event) { - Log.d(TAG, "Text message content: " + event.getDelta()); - - // Update streaming message - ChatMessage message = streamingMessages.get(event.getMessageId()); - if (message != null) { - message.appendStreamingContent(event.getDelta()); - notifyMessagesChanged(); - } - } - - @Override - public void onTextMessageEnd(TextMessageEndEvent event) { - Log.d(TAG, "Text message end: " + event.getMessageId()); - - // Finish streaming message by creating a new instance - ChatMessage streamingMessage = streamingMessages.get(event.getMessageId()); - if (streamingMessage != null) { - // Create a new non-streaming message with the final content - ChatMessage finishedMessage = new ChatMessage( - streamingMessage.getId(), - streamingMessage.getRole(), - streamingMessage.getContent(), // This gets the streamed content - streamingMessage.getName() - ); - - // Replace the streaming message with the finished one - List currentMessages = messages.getValue(); - if (currentMessages != null) { - List newMessages = new ArrayList<>(currentMessages); - int index = newMessages.indexOf(streamingMessage); - if (index >= 0) { - newMessages.set(index, finishedMessage); - messages.setValue(newMessages); - } - } - - streamingMessages.remove(event.getMessageId()); - } - } - - @Override - public void onToolCallStart(ToolCallStartEvent event) { - Log.d(TAG, "Tool call start: " + event.getToolCallId()); - } - - @Override - public void onToolCallArgs(ToolCallArgsEvent event) { - Log.d(TAG, "Tool call args: " + event.getDelta()); - } - - @Override - public void onToolCallEnd(ToolCallEndEvent event) { - Log.d(TAG, "Tool call end: " + event.getToolCallId()); - } - - @Override - public void onStateSnapshot(StateSnapshotEvent event) { - Log.d(TAG, "State snapshot received"); - } - - @Override - public void onStateDelta(StateDeltaEvent event) { - Log.d(TAG, "State delta received"); - } - - @Override - public void onMessagesSnapshot(MessagesSnapshotEvent event) { - Log.d(TAG, "Messages snapshot received"); - } - - @Override - public void onRawEvent(RawEvent event) { - Log.d(TAG, "Raw event: " + event.getEvent()); - } - - @Override - public void onCustomEvent(CustomEvent event) { - Log.d(TAG, "Custom event: " + event.getValue()); - } - - @Override - public void onUnknownEvent(BaseEvent event) { - Log.w(TAG, "Unknown event type: " + event.getClass().getSimpleName()); - } - }); - } - - /** - * Test connection to the agent - */ - public CompletableFuture testConnection() { - if (agentAdapter == null) { - return CompletableFuture.completedFuture(false); - } - - return agentAdapter.testConnection(); - } - - /** - * Clear chat history - */ - public void clearHistory() { - if (agentAdapter != null) { - agentAdapter.clearHistory(); - } - - // Generate new thread ID for fresh conversation - currentThreadId = generateNewThreadId(); - - messages.setValue(new ArrayList<>()); - streamingMessages.clear(); - } - - - /** - * Refresh agent configuration (call after settings change) - */ - public void refreshAgentConfiguration() { - // Clean up existing agent - if (agentAdapter != null) { - agentAdapter.close(); - agentAdapter = null; - } - - // Reinitialize with current agent (preserve thread ID for conversation continuity) - if (currentAgent != null) { - initializeAgent(); - } - } - - - /** - * Add a message to the chat - */ - private void addMessage(ChatMessage message) { - List currentMessages = messages.getValue(); - if (currentMessages != null) { - List newMessages = new ArrayList<>(currentMessages); - newMessages.add(message); - messages.setValue(newMessages); - } - } - - /** - * Notify that messages have changed (for streaming updates) - */ - private void notifyMessagesChanged() { - List currentMessages = messages.getValue(); - if (currentMessages != null) { - messages.setValue(new ArrayList<>(currentMessages)); - } - } - - /** - * Generate a new unique thread ID - */ - private String generateNewThreadId() { - return "thread_" + System.currentTimeMillis() + "_" + (int)(Math.random() * 1000); - } - - /** - * Get the current thread ID, creating one if needed - */ - private String getCurrentThreadId() { - if (currentThreadId == null) { - currentThreadId = generateNewThreadId(); - } - return currentThreadId; - } - - @Override - protected void onCleared() { - super.onCleared(); - - // Clean up disposables - disposables.clear(); - - // Clean up agent - if (agentAdapter != null) { - agentAdapter.close(); - } - } -} \ No newline at end of file diff --git a/sdks/community/kotlin/examples/chatapp-java/app/src/main/java/com/agui/chatapp/java/viewmodel/ChatViewModel.kt b/sdks/community/kotlin/examples/chatapp-java/app/src/main/java/com/agui/chatapp/java/viewmodel/ChatViewModel.kt new file mode 100644 index 000000000..5d679e9a9 --- /dev/null +++ b/sdks/community/kotlin/examples/chatapp-java/app/src/main/java/com/agui/chatapp/java/viewmodel/ChatViewModel.kt @@ -0,0 +1,88 @@ +package com.agui.chatapp.java.viewmodel + +import android.app.Application +import androidx.lifecycle.AndroidViewModel +import androidx.lifecycle.LiveData +import androidx.lifecycle.MutableLiveData +import androidx.lifecycle.viewModelScope +import com.agui.chatapp.java.model.ChatMessage +import com.agui.chatapp.java.repository.MultiAgentRepository +import com.agui.example.chatapp.chat.ChatController +import com.agui.example.chatapp.data.model.AgentConfig +import com.agui.example.tools.BackgroundStyle +import kotlinx.coroutines.flow.collectLatest +import kotlinx.coroutines.launch + +class ChatViewModel(application: Application) : AndroidViewModel(application) { + + private val repository = MultiAgentRepository.getInstance(application) + private val controller = ChatController(viewModelScope) + + private val _messages = MutableLiveData>(emptyList()) + private val _isConnecting = MutableLiveData(false) + private val _errorMessage = MutableLiveData() + private val _hasAgentConfig = MutableLiveData(false) + private val _backgroundStyle = MutableLiveData(BackgroundStyle.Default) + + private val activeAgentLiveData: LiveData = repository.getActiveAgent() + + fun getMessages(): LiveData> = _messages + + fun getIsConnecting(): LiveData = _isConnecting + + fun getErrorMessage(): LiveData = _errorMessage + + fun getHasAgentConfig(): LiveData = _hasAgentConfig + + fun getBackgroundStyle(): LiveData = _backgroundStyle + + fun getActiveAgent(): LiveData = activeAgentLiveData + + init { + viewModelScope.launch { + controller.state.collectLatest { state -> + _messages.postValue(state.messages.map(::ChatMessage)) + _isConnecting.postValue(state.isLoading) + _errorMessage.postValue(state.error) + _hasAgentConfig.postValue(state.activeAgent != null) + _backgroundStyle.postValue(state.background) + } + } + } + + fun setActiveAgent(agent: AgentConfig?) { + val current = activeAgentLiveData.value + val currentId = current?.id + val targetId = agent?.id + if (currentId == targetId) return + + repository.setActiveAgent(agent) + .whenComplete { _, throwable -> + if (throwable != null) { + _errorMessage.postValue("Failed to activate agent: ${throwable.message}") + } + } + } + + fun sendMessage(message: String) { + controller.sendMessage(message) + } + + fun cancelOperations() { + controller.cancelCurrentOperation() + } + + fun clearError() { + controller.clearError() + } + + fun clearHistory() { + _messages.value = emptyList() + controller.cancelCurrentOperation() + } + + override fun onCleared() { + super.onCleared() + controller.close() + } +} diff --git a/sdks/community/kotlin/examples/chatapp-java/app/src/test/java/com/agui/chatapp/java/adapter/EventProcessorTest.java b/sdks/community/kotlin/examples/chatapp-java/app/src/test/java/com/agui/chatapp/java/adapter/EventProcessorTest.java deleted file mode 100644 index 5257f1989..000000000 --- a/sdks/community/kotlin/examples/chatapp-java/app/src/test/java/com/agui/chatapp/java/adapter/EventProcessorTest.java +++ /dev/null @@ -1,162 +0,0 @@ -package com.agui.chatapp.java.adapter; - -import com.agui.core.types.*; - -import org.junit.Test; -import org.junit.Before; -import org.mockito.Mock; -import org.mockito.MockitoAnnotations; - -import static org.junit.Assert.*; -import static org.mockito.Mockito.*; - -/** - * Unit tests for EventProcessor class. - * Tests event type classification and handler delegation. - */ -public class EventProcessorTest { - - @Mock - private EventProcessor.EventHandler mockHandler; - - @Before - public void setUp() { - MockitoAnnotations.openMocks(this); - } - - @Test - public void testProcessTextMessageStartEvent() { - TextMessageStartEvent event = new TextMessageStartEvent("msg-123", null, null); - - EventProcessor.processEvent(event, mockHandler); - - verify(mockHandler).onTextMessageStart(event); - verifyNoMoreInteractions(mockHandler); - } - - @Test - public void testProcessTextMessageContentEvent() { - TextMessageContentEvent event = new TextMessageContentEvent("msg-123", "Hello", null, null); - - EventProcessor.processEvent(event, mockHandler); - - verify(mockHandler).onTextMessageContent(event); - verifyNoMoreInteractions(mockHandler); - } - - @Test - public void testProcessTextMessageEndEvent() { - TextMessageEndEvent event = new TextMessageEndEvent("msg-123", null, null); - - EventProcessor.processEvent(event, mockHandler); - - verify(mockHandler).onTextMessageEnd(event); - verifyNoMoreInteractions(mockHandler); - } - - @Test - public void testProcessRunStartedEvent() { - RunStartedEvent event = new RunStartedEvent("thread-1", "run-123", null, null); - - EventProcessor.processEvent(event, mockHandler); - - verify(mockHandler).onRunStarted(event); - verifyNoMoreInteractions(mockHandler); - } - - @Test - public void testProcessRunFinishedEvent() { - RunFinishedEvent event = new RunFinishedEvent("thread-1", "run-123", null, null); - - EventProcessor.processEvent(event, mockHandler); - - verify(mockHandler).onRunFinished(event); - verifyNoMoreInteractions(mockHandler); - } - - @Test - public void testProcessRunErrorEvent() { - RunErrorEvent event = new RunErrorEvent("Connection failed", "CONN_ERROR", null, null); - - EventProcessor.processEvent(event, mockHandler); - - verify(mockHandler).onRunError(event); - verifyNoMoreInteractions(mockHandler); - } - - @Test - public void testProcessStepEvents() { - StepStartedEvent startEvent = new StepStartedEvent("reasoning", null, null); - StepFinishedEvent finishEvent = new StepFinishedEvent("reasoning", null, null); - - EventProcessor.processEvent(startEvent, mockHandler); - EventProcessor.processEvent(finishEvent, mockHandler); - - verify(mockHandler).onStepStarted(startEvent); - verify(mockHandler).onStepFinished(finishEvent); - verifyNoMoreInteractions(mockHandler); - } - - @Test - public void testProcessToolCallEvents() { - ToolCallStartEvent startEvent = new ToolCallStartEvent("call-123", "search", "msg-456", null, null); - ToolCallArgsEvent argsEvent = new ToolCallArgsEvent("call-123", "{\"query\":", null, null); - ToolCallEndEvent endEvent = new ToolCallEndEvent("call-123", null, null); - - EventProcessor.processEvent(startEvent, mockHandler); - EventProcessor.processEvent(argsEvent, mockHandler); - EventProcessor.processEvent(endEvent, mockHandler); - - verify(mockHandler).onToolCallStart(startEvent); - verify(mockHandler).onToolCallArgs(argsEvent); - verify(mockHandler).onToolCallEnd(endEvent); - verifyNoMoreInteractions(mockHandler); - } - - @Test - public void testEventTypeClassification() { - // Text message events - assertTrue(EventProcessor.isTextMessageEvent(new TextMessageStartEvent("1", null, null))); - assertTrue(EventProcessor.isTextMessageEvent(new TextMessageContentEvent("1", "text", null, null))); - assertTrue(EventProcessor.isTextMessageEvent(new TextMessageEndEvent("1", null, null))); - - // Tool call events - assertTrue(EventProcessor.isToolCallEvent(new ToolCallStartEvent("1", "tool", null, null, null))); - assertTrue(EventProcessor.isToolCallEvent(new ToolCallArgsEvent("1", "args", null, null))); - assertTrue(EventProcessor.isToolCallEvent(new ToolCallEndEvent("1", null, null))); - - // Lifecycle events - assertTrue(EventProcessor.isLifecycleEvent(new RunStartedEvent("t", "r", null, null))); - assertTrue(EventProcessor.isLifecycleEvent(new RunFinishedEvent("t", "r", null, null))); - assertTrue(EventProcessor.isLifecycleEvent(new RunErrorEvent("error", null, null, null))); - assertTrue(EventProcessor.isLifecycleEvent(new StepStartedEvent("step", null, null))); - assertTrue(EventProcessor.isLifecycleEvent(new StepFinishedEvent("step", null, null))); - - // Cross-category tests - assertFalse(EventProcessor.isTextMessageEvent(new RunStartedEvent("t", "r", null, null))); - assertFalse(EventProcessor.isToolCallEvent(new TextMessageStartEvent("1", null, null))); - assertFalse(EventProcessor.isLifecycleEvent(new ToolCallStartEvent("1", "tool", null, null, null))); - } - - @Test - public void testEventDescriptions() { - assertEquals("Run Started", EventProcessor.getEventDescription(new RunStartedEvent("t", "r", null, null))); - assertEquals("Text Message Start", EventProcessor.getEventDescription(new TextMessageStartEvent("1", null, null))); - assertEquals("Tool Call Args", EventProcessor.getEventDescription(new ToolCallArgsEvent("1", "args", null, null))); - // Create a simple JSON element for the CustomEvent - kotlinx.serialization.json.JsonElement jsonValue = kotlinx.serialization.json.JsonElementKt.JsonPrimitive("test"); - assertEquals("Custom Event", EventProcessor.getEventDescription(new CustomEvent("test", jsonValue, null, null))); - } - - @Test - public void testUnknownEventHandling() { - // Create a simple JSON element for the RawEvent - kotlinx.serialization.json.JsonElement jsonValue = kotlinx.serialization.json.JsonElementKt.JsonPrimitive("test"); - RawEvent unknownEvent = new RawEvent(jsonValue, null, null, null); - - EventProcessor.processEvent(unknownEvent, mockHandler); - - verify(mockHandler).onRawEvent(unknownEvent); - verifyNoMoreInteractions(mockHandler); - } -} \ No newline at end of file diff --git a/sdks/community/kotlin/examples/chatapp-java/app/src/test/java/com/agui/chatapp/java/integration/RepositoryConsistencyTest.java b/sdks/community/kotlin/examples/chatapp-java/app/src/test/java/com/agui/chatapp/java/integration/RepositoryConsistencyTest.java deleted file mode 100644 index 1b446c934..000000000 --- a/sdks/community/kotlin/examples/chatapp-java/app/src/test/java/com/agui/chatapp/java/integration/RepositoryConsistencyTest.java +++ /dev/null @@ -1,246 +0,0 @@ -package com.agui.chatapp.java.integration; - -import android.app.Application; -import android.content.Context; -import androidx.arch.core.executor.testing.InstantTaskExecutorRule; -import androidx.lifecycle.Observer; - -import com.agui.chatapp.java.model.AgentProfile; -import com.agui.chatapp.java.model.AuthMethod; -import com.agui.chatapp.java.repository.MultiAgentRepository; -import com.agui.chatapp.java.viewmodel.ChatViewModel; - -import org.junit.After; -import org.junit.Before; -import org.junit.Rule; -import org.junit.Test; -import org.junit.runner.RunWith; -import org.mockito.Mock; -import org.mockito.MockitoAnnotations; -import org.robolectric.RobolectricTestRunner; -import org.robolectric.RuntimeEnvironment; - -import java.lang.reflect.Field; -import java.util.List; - -import static org.junit.Assert.*; -import static org.mockito.ArgumentMatchers.any; -import static org.mockito.Mockito.*; - -/** - * Integration tests to verify repository singleton consistency across different components. - * These tests ensure that ChatViewModel, SettingsActivity, and direct repository access - * all work with the same repository instance and see consistent data. - */ -@RunWith(RobolectricTestRunner.class) -public class RepositoryConsistencyTest { - - @Rule - public InstantTaskExecutorRule instantExecutorRule = new InstantTaskExecutorRule(); - - private Context context; - private Application application; - - @Mock - private Observer> agentsObserver; - - @Mock - private Observer activeAgentObserver; - - @Before - public void setUp() { - MockitoAnnotations.openMocks(this); - context = RuntimeEnvironment.getApplication(); - application = RuntimeEnvironment.getApplication(); - - // Clear singleton instance for clean test - clearSingletonInstance(); - - // Clear any existing data - MultiAgentRepository.getInstance(context).clearAll().join(); - } - - @After - public void tearDown() { - // Clear singleton for next test - clearSingletonInstance(); - } - - private void clearSingletonInstance() { - try { - Field instanceField = MultiAgentRepository.class.getDeclaredField("INSTANCE"); - instanceField.setAccessible(true); - instanceField.set(null, null); - } catch (Exception e) { - // Ignore reflection errors - } - } - - @Test - public void testRepositoryConsistency_ViewModelAndDirectAccess() { - // Create ChatViewModel (which gets repository via singleton) - ChatViewModel viewModel = new ChatViewModel(application); - - // Get repository directly - MultiAgentRepository directRepository = MultiAgentRepository.getInstance(context); - - // They should be the same repository instance underlying the ViewModel - // We can verify this by checking that changes made via direct access - // are visible through the ViewModel's LiveData - - // Set up observer on the agents LiveData - directRepository.getAgents().observeForever(agentsObserver); - - // Create test agent and add via direct repository - AgentProfile testAgent = createTestAgent("consistency-test-1", "Consistency Test Agent 1"); - directRepository.addAgent(testAgent).join(); - - // Verify observer was notified - verify(agentsObserver, atLeast(2)).onChanged(any(List.class)); // Initial + addition - - // Verify the data is accessible through both direct access and ViewModel - List directAgents = directRepository.getAgents().getValue(); - List viewModelAgents = viewModel.getActiveAgent().getValue() != null ? - directRepository.getAgents().getValue() : directRepository.getAgents().getValue(); - - assertNotNull("Direct agents should not be null", directAgents); - assertEquals("Should have one agent", 1, directAgents.size()); - assertEquals("Agent should match", testAgent.getId(), directAgents.get(0).getId()); - } - - @Test - public void testActiveAgentConsistency_AcrossComponents() { - // Create ChatViewModel - ChatViewModel viewModel = new ChatViewModel(application); - - // Get repository directly - MultiAgentRepository directRepository = MultiAgentRepository.getInstance(context); - - // Create and add test agent - AgentProfile testAgent = createTestAgent("active-test", "Active Test Agent"); - directRepository.addAgent(testAgent).join(); - - // Set up observer - directRepository.getActiveAgent().observeForever(activeAgentObserver); - - // Set active agent via direct repository - directRepository.setActiveAgent(testAgent).join(); - - // Verify observer was notified - verify(activeAgentObserver).onChanged(any(AgentProfile.class)); - - // Verify data consistency - AgentProfile directActiveAgent = directRepository.getCurrentActiveAgent(); - AgentProfile liveDataActiveAgent = directRepository.getActiveAgent().getValue(); - - assertNotNull("Direct repository should return active agent", directActiveAgent); - assertNotNull("LiveData should return active agent", liveDataActiveAgent); - assertEquals("Direct and LiveData active agents should match", - directActiveAgent.getId(), liveDataActiveAgent.getId()); - assertEquals("Active agent should match test agent", - testAgent.getId(), directActiveAgent.getId()); - } - - @Test - public void testMultipleViewModels_ShareSameRepository() { - // Create two ChatViewModel instances - ChatViewModel viewModel1 = new ChatViewModel(application); - ChatViewModel viewModel2 = new ChatViewModel(application); - - // Get repository instances they use - MultiAgentRepository repo1 = MultiAgentRepository.getInstance(context); - MultiAgentRepository repo2 = MultiAgentRepository.getInstance(context); - - // Should be the same instance - assertSame("ViewModels should use same repository instance", repo1, repo2); - - // Create test agent and add via first repository reference - AgentProfile testAgent = createTestAgent("multi-vm-test", "Multi ViewModel Test"); - repo1.addAgent(testAgent).join(); - repo1.setActiveAgent(testAgent).join(); - - // Verify both repository references see the same data - AgentProfile activeAgent1 = repo1.getCurrentActiveAgent(); - AgentProfile activeAgent2 = repo2.getCurrentActiveAgent(); - - assertNotNull("First repository should have active agent", activeAgent1); - assertNotNull("Second repository should have active agent", activeAgent2); - assertEquals("Both repositories should see same agent", - activeAgent1.getId(), activeAgent2.getId()); - } - - @Test - public void testDataModification_PropagatesAcrossComponents() { - // Create components - ChatViewModel viewModel = new ChatViewModel(application); - MultiAgentRepository directRepository = MultiAgentRepository.getInstance(context); - - // Create and add agent - AgentProfile originalAgent = createTestAgent("propagation-test", "Original Agent"); - directRepository.addAgent(originalAgent).join(); - directRepository.setActiveAgent(originalAgent).join(); - - // Set up observer - directRepository.getActiveAgent().observeForever(activeAgentObserver); - reset(activeAgentObserver); // Reset initial calls - - // Modify agent via direct repository - AgentProfile modifiedAgent = originalAgent.toBuilder() - .setName("Modified Agent Name") - .setDescription("Modified description") - .build(); - directRepository.updateAgent(modifiedAgent).join(); - - // Verify observer was notified of update - verify(activeAgentObserver).onChanged(any(AgentProfile.class)); - - // Verify the updated data is visible - AgentProfile updatedAgent = directRepository.getActiveAgent().getValue(); - assertNotNull("Updated agent should not be null", updatedAgent); - assertEquals("Agent name should be updated", "Modified Agent Name", updatedAgent.getName()); - assertEquals("Agent description should be updated", "Modified description", updatedAgent.getDescription()); - } - - @Test - public void testAgentDeletion_ConsistentAcrossComponents() { - // Create components - ChatViewModel viewModel = new ChatViewModel(application); - MultiAgentRepository directRepository = MultiAgentRepository.getInstance(context); - - // Create and add agent - AgentProfile testAgent = createTestAgent("deletion-test", "Deletion Test Agent"); - directRepository.addAgent(testAgent).join(); - directRepository.setActiveAgent(testAgent).join(); - - // Verify both components see the agent initially - assertNotNull("Direct repository should have active agent", directRepository.getCurrentActiveAgent()); - - // Set up observer - directRepository.getActiveAgent().observeForever(activeAgentObserver); - reset(activeAgentObserver); // Reset initial calls - - // Delete agent via direct repository - directRepository.deleteAgent(testAgent.getId()).join(); - - // Verify active agent is cleared - assertNull("Direct repository should have no active agent after deletion", - directRepository.getCurrentActiveAgent()); - - // Verify LiveData was updated - assertNull("LiveData should show no active agent after deletion", - directRepository.getActiveAgent().getValue()); - } - - // ===== HELPER METHODS ===== - - private AgentProfile createTestAgent(String id, String name) { - return new AgentProfile.Builder() - .setId(id) - .setName(name) - .setUrl("https://test.example.com/" + id) - .setDescription("Test agent: " + name) - .setAuthMethod(new AuthMethod.None()) - .setSystemPrompt("You are " + name) - .build(); - } -} \ No newline at end of file diff --git a/sdks/community/kotlin/examples/chatapp-java/app/src/test/java/com/agui/chatapp/java/model/AgentProfileTest.java b/sdks/community/kotlin/examples/chatapp-java/app/src/test/java/com/agui/chatapp/java/model/AgentProfileTest.java deleted file mode 100644 index 111a3a4d5..000000000 --- a/sdks/community/kotlin/examples/chatapp-java/app/src/test/java/com/agui/chatapp/java/model/AgentProfileTest.java +++ /dev/null @@ -1,212 +0,0 @@ -package com.agui.chatapp.java.model; - -import org.junit.Test; -import static org.junit.Assert.*; - -/** - * Unit tests for AgentProfile and its Builder. - */ -public class AgentProfileTest { - - @Test - public void testAgentProfileBuilder() { - long createdTime = System.currentTimeMillis(); - - AgentProfile profile = new AgentProfile.Builder() - .setName("Test Agent") - .setUrl("https://api.example.com/agent") - .setDescription("A test agent") - .setAuthMethod(new AuthMethod.ApiKey("test-key")) - .setActive(true) - .setCreatedAt(createdTime) - .setSystemPrompt("You are a helpful assistant") - .build(); - - assertNotNull(profile.getId()); // ID should be auto-generated - assertEquals("Test Agent", profile.getName()); - assertEquals("https://api.example.com/agent", profile.getUrl()); - assertEquals("A test agent", profile.getDescription()); - assertTrue(profile.getAuthMethod() instanceof AuthMethod.ApiKey); - assertTrue(profile.isActive()); - assertEquals(createdTime, profile.getCreatedAt()); - assertNull(profile.getLastUsedAt()); - assertEquals("You are a helpful assistant", profile.getSystemPrompt()); - assertTrue(profile.isValid()); - } - - @Test - public void testAgentProfileWithCustomId() { - AgentProfile profile = new AgentProfile.Builder() - .setId("custom-id") - .setName("Test Agent") - .setUrl("https://api.example.com/agent") - .build(); - - assertEquals("custom-id", profile.getId()); - assertEquals("Test Agent", profile.getName()); - assertEquals("https://api.example.com/agent", profile.getUrl()); - } - - @Test - public void testAgentProfileDefaults() { - AgentProfile profile = new AgentProfile.Builder() - .setName("Test Agent") - .setUrl("https://api.example.com/agent") - .build(); - - assertNotNull(profile.getId()); - assertNull(profile.getDescription()); - assertTrue(profile.getAuthMethod() instanceof AuthMethod.None); - assertFalse(profile.isActive()); - assertTrue(profile.getCreatedAt() > 0); - assertNull(profile.getLastUsedAt()); - assertNull(profile.getSystemPrompt()); - } - - @Test - public void testAgentProfileValidation() { - // Valid profile - AgentProfile validProfile = new AgentProfile.Builder() - .setName("Valid Agent") - .setUrl("https://api.example.com/agent") - .setAuthMethod(new AuthMethod.ApiKey("valid-key")) - .build(); - assertTrue(validProfile.isValid()); - - // Invalid - missing name - AgentProfile invalidName = new AgentProfile.Builder() - .setName("") - .setUrl("https://api.example.com/agent") - .build(); - assertFalse(invalidName.isValid()); - - // Invalid - missing URL - AgentProfile invalidUrl = new AgentProfile.Builder() - .setName("Test Agent") - .setUrl("") - .build(); - assertFalse(invalidUrl.isValid()); - - // Invalid - invalid auth method - AgentProfile invalidAuth = new AgentProfile.Builder() - .setName("Test Agent") - .setUrl("https://api.example.com/agent") - .setAuthMethod(new AuthMethod.ApiKey("")) // empty key - .build(); - assertFalse(invalidAuth.isValid()); - } - - @Test - public void testAgentProfileCopyOperations() { - AgentProfile original = new AgentProfile.Builder() - .setName("Original Agent") - .setUrl("https://api.example.com/agent") - .setActive(false) - .build(); - - // Test withActive - AgentProfile activated = original.withActive(true); - assertFalse(original.isActive()); // Original unchanged - assertTrue(activated.isActive()); - assertEquals(original.getName(), activated.getName()); - assertEquals(original.getUrl(), activated.getUrl()); - - // Test withLastUsedAt - long lastUsedTime = System.currentTimeMillis(); - AgentProfile withLastUsed = original.withLastUsedAt(lastUsedTime); - assertNull(original.getLastUsedAt()); // Original unchanged - assertEquals(Long.valueOf(lastUsedTime), withLastUsed.getLastUsedAt()); - } - - @Test - public void testAgentProfileToBuilder() { - long createdTime = System.currentTimeMillis(); - long lastUsedTime = createdTime + 1000; - - AgentProfile original = new AgentProfile.Builder() - .setId("test-id") - .setName("Test Agent") - .setUrl("https://api.example.com/agent") - .setDescription("Description") - .setAuthMethod(new AuthMethod.BearerToken("token")) - .setActive(true) - .setCreatedAt(createdTime) - .setLastUsedAt(lastUsedTime) - .setSystemPrompt("System prompt") - .build(); - - // Create a copy using toBuilder - AgentProfile copy = original.toBuilder() - .setName("Modified Agent") - .build(); - - assertEquals("test-id", copy.getId()); - assertEquals("Modified Agent", copy.getName()); // Changed - assertEquals("https://api.example.com/agent", copy.getUrl()); // Same - assertEquals("Description", copy.getDescription()); // Same - assertTrue(copy.getAuthMethod() instanceof AuthMethod.BearerToken); // Same - assertTrue(copy.isActive()); // Same - assertEquals(createdTime, copy.getCreatedAt()); // Same - assertEquals(Long.valueOf(lastUsedTime), copy.getLastUsedAt()); // Same - assertEquals("System prompt", copy.getSystemPrompt()); // Same - } - - @Test - public void testAgentProfileEquality() { - long createdTime = System.currentTimeMillis(); - - AgentProfile profile1 = new AgentProfile.Builder() - .setId("same-id") - .setName("Test Agent") - .setUrl("https://api.example.com/agent") - .setAuthMethod(new AuthMethod.ApiKey("key")) - .setCreatedAt(createdTime) - .build(); - - AgentProfile profile2 = new AgentProfile.Builder() - .setId("same-id") - .setName("Test Agent") - .setUrl("https://api.example.com/agent") - .setAuthMethod(new AuthMethod.ApiKey("key")) - .setCreatedAt(createdTime) - .build(); - - AgentProfile profile3 = new AgentProfile.Builder() - .setId("different-id") - .setName("Test Agent") - .setUrl("https://api.example.com/agent") - .setAuthMethod(new AuthMethod.ApiKey("key")) - .setCreatedAt(createdTime) - .build(); - - assertEquals(profile1, profile2); - assertEquals(profile1.hashCode(), profile2.hashCode()); - assertNotEquals(profile1, profile3); - } - - @Test - public void testGenerateId() { - String id1 = AgentProfile.generateId(); - String id2 = AgentProfile.generateId(); - - assertNotNull(id1); - assertNotNull(id2); - assertNotEquals(id1, id2); // Should be unique - assertTrue(id1.startsWith("agent_")); - assertTrue(id2.startsWith("agent_")); - } - - @Test - public void testAgentProfileToString() { - AgentProfile profile = new AgentProfile.Builder() - .setName("Test Agent") - .setUrl("https://api.example.com/agent") - .build(); - - String toString = profile.toString(); - assertNotNull(toString); - assertTrue(toString.contains("AgentProfile")); - assertTrue(toString.contains("Test Agent")); - assertTrue(toString.contains("https://api.example.com/agent")); - } -} \ No newline at end of file diff --git a/sdks/community/kotlin/examples/chatapp-java/app/src/test/java/com/agui/chatapp/java/model/AuthMethodTest.java b/sdks/community/kotlin/examples/chatapp-java/app/src/test/java/com/agui/chatapp/java/model/AuthMethodTest.java deleted file mode 100644 index d7cadcda5..000000000 --- a/sdks/community/kotlin/examples/chatapp-java/app/src/test/java/com/agui/chatapp/java/model/AuthMethodTest.java +++ /dev/null @@ -1,174 +0,0 @@ -package com.agui.chatapp.java.model; - -import org.junit.Test; -import static org.junit.Assert.*; - -/** - * Unit tests for AuthMethod and its subclasses. - */ -public class AuthMethodTest { - - @Test - public void testNoneAuthMethod() { - AuthMethod.None none = new AuthMethod.None(); - - assertEquals("none", none.getType()); - assertTrue(none.isValid()); - - // Test equality - AuthMethod.None another = new AuthMethod.None(); - assertEquals(none, another); - assertEquals(none.hashCode(), another.hashCode()); - - // Test toString - assertNotNull(none.toString()); - assertTrue(none.toString().contains("None")); - } - - @Test - public void testApiKeyAuthMethod() { - // Test with default header - AuthMethod.ApiKey apiKey1 = new AuthMethod.ApiKey("test-key"); - - assertEquals("api_key", apiKey1.getType()); - assertEquals("test-key", apiKey1.getKey()); - assertEquals("X-API-Key", apiKey1.getHeaderName()); - assertTrue(apiKey1.isValid()); - - // Test with custom header - AuthMethod.ApiKey apiKey2 = new AuthMethod.ApiKey("another-key", "Custom-Header"); - - assertEquals("api_key", apiKey2.getType()); - assertEquals("another-key", apiKey2.getKey()); - assertEquals("Custom-Header", apiKey2.getHeaderName()); - assertTrue(apiKey2.isValid()); - - // Test equality - AuthMethod.ApiKey apiKey3 = new AuthMethod.ApiKey("test-key", "X-API-Key"); - assertEquals(apiKey1, apiKey3); - assertEquals(apiKey1.hashCode(), apiKey3.hashCode()); - assertNotEquals(apiKey1, apiKey2); - - // Test toString (should hide key) - String toString = apiKey1.toString(); - assertNotNull(toString); - assertTrue(toString.contains("ApiKey")); - assertTrue(toString.contains("***")); - assertFalse(toString.contains("test-key")); - } - - @Test - public void testApiKeyValidation() { - // Valid cases - assertTrue(new AuthMethod.ApiKey("valid-key").isValid()); - assertTrue(new AuthMethod.ApiKey("valid-key", "Valid-Header").isValid()); - - // Invalid cases - empty/null key - assertFalse(new AuthMethod.ApiKey("").isValid()); - assertFalse(new AuthMethod.ApiKey(" ").isValid()); - - // Invalid cases - empty/null header - assertFalse(new AuthMethod.ApiKey("valid-key", "").isValid()); - assertFalse(new AuthMethod.ApiKey("valid-key", " ").isValid()); - } - - @Test - public void testBearerTokenAuthMethod() { - AuthMethod.BearerToken token = new AuthMethod.BearerToken("test-token"); - - assertEquals("bearer_token", token.getType()); - assertEquals("test-token", token.getToken()); - assertTrue(token.isValid()); - - // Test equality - AuthMethod.BearerToken token2 = new AuthMethod.BearerToken("test-token"); - assertEquals(token, token2); - assertEquals(token.hashCode(), token2.hashCode()); - - AuthMethod.BearerToken token3 = new AuthMethod.BearerToken("different-token"); - assertNotEquals(token, token3); - - // Test toString (should hide token) - String toString = token.toString(); - assertNotNull(toString); - assertTrue(toString.contains("BearerToken")); - assertTrue(toString.contains("***")); - assertFalse(toString.contains("test-token")); - } - - @Test - public void testBearerTokenValidation() { - // Valid cases - assertTrue(new AuthMethod.BearerToken("valid-token").isValid()); - - // Invalid cases - assertFalse(new AuthMethod.BearerToken("").isValid()); - assertFalse(new AuthMethod.BearerToken(" ").isValid()); - } - - @Test - public void testBasicAuthMethod() { - AuthMethod.BasicAuth basicAuth = new AuthMethod.BasicAuth("username", "secret123"); - - assertEquals("basic_auth", basicAuth.getType()); - assertEquals("username", basicAuth.getUsername()); - assertEquals("secret123", basicAuth.getPassword()); - assertTrue(basicAuth.isValid()); - - // Test equality - AuthMethod.BasicAuth basicAuth2 = new AuthMethod.BasicAuth("username", "secret123"); - assertEquals(basicAuth, basicAuth2); - assertEquals(basicAuth.hashCode(), basicAuth2.hashCode()); - - AuthMethod.BasicAuth basicAuth3 = new AuthMethod.BasicAuth("different", "secret123"); - assertNotEquals(basicAuth, basicAuth3); - - // Test toString (should hide password value) - String toString = basicAuth.toString(); - assertNotNull(toString); - assertTrue(toString.contains("BasicAuth")); - assertTrue(toString.contains("username")); - assertTrue(toString.contains("***")); - assertFalse(toString.contains("secret123")); // Check actual password value isn't shown - } - - @Test - public void testBasicAuthValidation() { - // Valid cases - assertTrue(new AuthMethod.BasicAuth("user", "pass").isValid()); - - // Invalid cases - empty/null username - assertFalse(new AuthMethod.BasicAuth("", "pass").isValid()); - assertFalse(new AuthMethod.BasicAuth(" ", "pass").isValid()); - - // Invalid cases - empty/null password - assertFalse(new AuthMethod.BasicAuth("user", "").isValid()); - assertFalse(new AuthMethod.BasicAuth("user", " ").isValid()); - } - - @Test - public void testDifferentAuthMethodTypes() { - AuthMethod none = new AuthMethod.None(); - AuthMethod apiKey = new AuthMethod.ApiKey("key"); - AuthMethod bearerToken = new AuthMethod.BearerToken("token"); - AuthMethod basicAuth = new AuthMethod.BasicAuth("user", "pass"); - - // Different types should not be equal - assertNotEquals(none, apiKey); - assertNotEquals(apiKey, bearerToken); - assertNotEquals(bearerToken, basicAuth); - assertNotEquals(basicAuth, none); - - // All should be valid - assertTrue(none.isValid()); - assertTrue(apiKey.isValid()); - assertTrue(bearerToken.isValid()); - assertTrue(basicAuth.isValid()); - - // Check types - assertEquals("none", none.getType()); - assertEquals("api_key", apiKey.getType()); - assertEquals("bearer_token", bearerToken.getType()); - assertEquals("basic_auth", basicAuth.getType()); - } -} \ No newline at end of file diff --git a/sdks/community/kotlin/examples/chatapp-java/app/src/test/java/com/agui/chatapp/java/model/ChatMessageTest.java b/sdks/community/kotlin/examples/chatapp-java/app/src/test/java/com/agui/chatapp/java/model/ChatMessageTest.java deleted file mode 100644 index de7370d25..000000000 --- a/sdks/community/kotlin/examples/chatapp-java/app/src/test/java/com/agui/chatapp/java/model/ChatMessageTest.java +++ /dev/null @@ -1,131 +0,0 @@ -package com.agui.chatapp.java.model; - -import com.agui.core.types.Role; - -import org.junit.Test; -import org.junit.Before; - -import static org.junit.Assert.*; - -/** - * Unit tests for ChatMessage model class. - * Tests streaming functionality, content management, and state transitions. - */ -public class ChatMessageTest { - - private ChatMessage staticMessage; - private ChatMessage streamingMessage; - - @Before - public void setUp() { - staticMessage = new ChatMessage("test-id", Role.ASSISTANT, "Hello", null); - streamingMessage = ChatMessage.createStreaming("streaming-id", Role.ASSISTANT, null); - } - - @Test - public void testStaticMessageProperties() { - assertEquals("test-id", staticMessage.getId()); - assertEquals(Role.ASSISTANT, staticMessage.getRole()); - assertEquals("Hello", staticMessage.getContent()); - assertEquals("Assistant", staticMessage.getSenderDisplayName()); - assertFalse(staticMessage.isStreaming()); - assertTrue(staticMessage.hasContent()); - } - - @Test - public void testStreamingMessageCreation() { - assertTrue(streamingMessage.isStreaming()); - assertEquals("streaming-id", streamingMessage.getId()); - assertEquals(Role.ASSISTANT, streamingMessage.getRole()); - assertEquals("", streamingMessage.getContent()); // Empty initially - assertFalse(streamingMessage.hasContent()); // No content initially - } - - @Test - public void testStreamingContentAppending() { - streamingMessage.appendStreamingContent("Hello"); - assertEquals("Hello", streamingMessage.getContent()); - assertTrue(streamingMessage.hasContent()); - assertTrue(streamingMessage.isStreaming()); - - streamingMessage.appendStreamingContent(" World"); - assertEquals("Hello World", streamingMessage.getContent()); - assertTrue(streamingMessage.isStreaming()); - } - - @Test - public void testFinishStreaming() { - streamingMessage.appendStreamingContent("Test content"); - assertTrue(streamingMessage.isStreaming()); - - streamingMessage.finishStreaming(); - assertFalse(streamingMessage.isStreaming()); - assertEquals("Test content", streamingMessage.getContent()); - assertTrue(streamingMessage.hasContent()); - } - - @Test - public void testRoleBasedDisplayNames() { - ChatMessage userMessage = new ChatMessage("1", Role.USER, "Hi", null); - ChatMessage systemMessage = new ChatMessage("2", Role.SYSTEM, "System", null); - ChatMessage toolMessage = new ChatMessage("3", Role.TOOL, "Tool", null); - - assertEquals("You", userMessage.getSenderDisplayName()); - assertEquals("System", systemMessage.getSenderDisplayName()); - assertEquals("Tool", toolMessage.getSenderDisplayName()); - } - - @Test - public void testCustomNameOverride() { - ChatMessage namedMessage = new ChatMessage("1", Role.ASSISTANT, "Hi", "Claude"); - assertEquals("Claude", namedMessage.getSenderDisplayName()); - } - - @Test - public void testMessageEquality() { - ChatMessage message1 = new ChatMessage("same-id", Role.USER, "Hello", null); - ChatMessage message2 = new ChatMessage("same-id", Role.ASSISTANT, "Hi", null); - ChatMessage message3 = new ChatMessage("different-id", Role.USER, "Hello", null); - - assertEquals(message1, message2); // Same ID - assertNotEquals(message1, message3); // Different ID - assertEquals(message1.hashCode(), message2.hashCode()); - } - - @Test - public void testEmptyContentHandling() { - ChatMessage emptyMessage = new ChatMessage("1", Role.USER, "", null); - ChatMessage nullMessage = new ChatMessage("2", Role.USER, null, null); - - assertFalse(emptyMessage.hasContent()); - assertFalse(nullMessage.hasContent()); - assertEquals("", emptyMessage.getContent()); - assertEquals("", nullMessage.getContent()); - } - - @Test - public void testTimestampFormatting() { - String formatted = staticMessage.getFormattedTimestamp(); - assertNotNull(formatted); - assertTrue(formatted.matches("\\d{1,2}:\\d{2} (AM|PM)")); - } - - @Test - public void testRoleTypeCheckers() { - ChatMessage userMsg = new ChatMessage("1", Role.USER, "Hi", null); - ChatMessage assistantMsg = new ChatMessage("2", Role.ASSISTANT, "Hello", null); - ChatMessage systemMsg = new ChatMessage("3", Role.SYSTEM, "System", null); - - assertTrue(userMsg.isUser()); - assertFalse(userMsg.isAssistant()); - assertFalse(userMsg.isSystem()); - - assertTrue(assistantMsg.isAssistant()); - assertFalse(assistantMsg.isUser()); - assertFalse(assistantMsg.isSystem()); - - assertTrue(systemMsg.isSystem()); - assertFalse(systemMsg.isUser()); - assertFalse(systemMsg.isAssistant()); - } -} \ No newline at end of file diff --git a/sdks/community/kotlin/examples/chatapp-java/app/src/test/java/com/agui/chatapp/java/model/ChatSessionTest.java b/sdks/community/kotlin/examples/chatapp-java/app/src/test/java/com/agui/chatapp/java/model/ChatSessionTest.java deleted file mode 100644 index 152401513..000000000 --- a/sdks/community/kotlin/examples/chatapp-java/app/src/test/java/com/agui/chatapp/java/model/ChatSessionTest.java +++ /dev/null @@ -1,149 +0,0 @@ -package com.agui.chatapp.java.model; - -import org.junit.Test; -import static org.junit.Assert.*; - -/** - * Unit tests for ChatSession. - */ -public class ChatSessionTest { - - @Test - public void testChatSessionConstruction() { - long startTime = System.currentTimeMillis(); - - ChatSession session = new ChatSession("agent-123", "thread-456", startTime); - - assertEquals("agent-123", session.getAgentId()); - assertEquals("thread-456", session.getThreadId()); - assertEquals(startTime, session.getStartedAt()); - } - - @Test - public void testChatSessionConstructionWithDefaultTime() { - long beforeCreation = System.currentTimeMillis(); - - ChatSession session = new ChatSession("agent-123", "thread-456"); - - long afterCreation = System.currentTimeMillis(); - - assertEquals("agent-123", session.getAgentId()); - assertEquals("thread-456", session.getThreadId()); - assertTrue(session.getStartedAt() >= beforeCreation); - assertTrue(session.getStartedAt() <= afterCreation); - } - - @Test - public void testChatSessionEquality() { - long startTime = System.currentTimeMillis(); - - ChatSession session1 = new ChatSession("agent-123", "thread-456", startTime); - ChatSession session2 = new ChatSession("agent-123", "thread-456", startTime); - ChatSession session3 = new ChatSession("agent-456", "thread-456", startTime); - ChatSession session4 = new ChatSession("agent-123", "thread-789", startTime); - ChatSession session5 = new ChatSession("agent-123", "thread-456", startTime + 1000); - - // Same values should be equal - assertEquals(session1, session2); - assertEquals(session1.hashCode(), session2.hashCode()); - - // Different agent ID - assertNotEquals(session1, session3); - - // Different thread ID - assertNotEquals(session1, session4); - - // Different start time - assertNotEquals(session1, session5); - - // Null check - assertNotEquals(session1, null); - - // Different class - assertNotEquals(session1, "not a session"); - - // Self equality - assertEquals(session1, session1); - } - - @Test - public void testGenerateThreadId() throws InterruptedException { - String threadId1 = ChatSession.generateThreadId(); - Thread.sleep(1); // Small delay to ensure different timestamps - String threadId2 = ChatSession.generateThreadId(); - - assertNotNull(threadId1); - assertNotNull(threadId2); - assertNotEquals(threadId1, threadId2); // Should be unique - assertTrue(threadId1.startsWith("thread_")); - assertTrue(threadId2.startsWith("thread_")); - - // Should match format: thread_{timestamp}_{random} - assertTrue(threadId1.matches("thread_\\d+_\\d+")); - assertTrue(threadId2.matches("thread_\\d+_\\d+")); - - // Extract timestamp parts (between "thread_" and second "_") - String[] parts1 = threadId1.split("_"); - String[] parts2 = threadId2.split("_"); - - assertEquals(3, parts1.length); // Should have exactly 3 parts: "thread", timestamp, random - assertEquals(3, parts2.length); - - long timestamp1 = Long.parseLong(parts1[1]); - long timestamp2 = Long.parseLong(parts2[1]); - - assertTrue(timestamp1 > 0); - assertTrue(timestamp2 > 0); - assertTrue(timestamp2 >= timestamp1); // Should be same or later - - // Verify random parts are different (highly likely but not guaranteed) - // This helps ensure uniqueness even with same timestamp - int random1 = Integer.parseInt(parts1[2]); - int random2 = Integer.parseInt(parts2[2]); - assertTrue(random1 >= 0 && random1 < 1000); - assertTrue(random2 >= 0 && random2 < 1000); - } - - @Test - public void testChatSessionToString() { - ChatSession session = new ChatSession("agent-123", "thread-456", 1234567890L); - - String toString = session.toString(); - assertNotNull(toString); - assertTrue(toString.contains("ChatSession")); - assertTrue(toString.contains("agent-123")); - assertTrue(toString.contains("thread-456")); - assertTrue(toString.contains("1234567890")); - } - - @Test - public void testChatSessionWithLongIds() { - // Test with longer, more realistic IDs - String agentId = "agent_1703123456789_5432"; - String threadId = "thread_1703123456890"; - - ChatSession session = new ChatSession(agentId, threadId); - - assertEquals(agentId, session.getAgentId()); - assertEquals(threadId, session.getThreadId()); - assertTrue(session.getStartedAt() > 0); - } - - @Test - public void testChatSessionImmutability() { - ChatSession session = new ChatSession("agent-123", "thread-456"); - - // Verify that getters return the same values consistently - String agentId1 = session.getAgentId(); - String agentId2 = session.getAgentId(); - assertEquals(agentId1, agentId2); - - String threadId1 = session.getThreadId(); - String threadId2 = session.getThreadId(); - assertEquals(threadId1, threadId2); - - long startTime1 = session.getStartedAt(); - long startTime2 = session.getStartedAt(); - assertEquals(startTime1, startTime2); - } -} \ No newline at end of file diff --git a/sdks/community/kotlin/examples/chatapp-java/app/src/test/java/com/agui/chatapp/java/repository/AgentRepositoryTest.java b/sdks/community/kotlin/examples/chatapp-java/app/src/test/java/com/agui/chatapp/java/repository/AgentRepositoryTest.java deleted file mode 100644 index 1812824a1..000000000 --- a/sdks/community/kotlin/examples/chatapp-java/app/src/test/java/com/agui/chatapp/java/repository/AgentRepositoryTest.java +++ /dev/null @@ -1,160 +0,0 @@ -package com.agui.chatapp.java.repository; - -import android.content.Context; -import android.content.SharedPreferences; - -import org.junit.Test; -import org.junit.Before; -import org.junit.runner.RunWith; -import org.mockito.Mock; -import org.mockito.MockitoAnnotations; -import org.robolectric.RobolectricTestRunner; -import org.robolectric.RuntimeEnvironment; - -import static org.junit.Assert.*; -import static org.mockito.ArgumentMatchers.*; -import static org.mockito.Mockito.*; - -/** - * Unit tests for AgentRepository class. - * Tests configuration persistence and validation using Robolectric. - */ -@RunWith(RobolectricTestRunner.class) -public class AgentRepositoryTest { - - private AgentRepository repository; - private Context context; - - @Before - public void setUp() { - MockitoAnnotations.openMocks(this); - context = RuntimeEnvironment.getApplication(); - repository = new AgentRepository(context); - } - - @Test - public void testDefaultConfiguration() { - AgentRepository.AgentConfig config = repository.loadAgentConfig(); - - assertNotNull(config); - assertFalse(config.isValid()); - assertEquals(AgentRepository.AuthType.NONE, config.getAuthType()); - assertEquals("", config.getAgentUrl()); - assertEquals("", config.getBearerToken()); - assertEquals("", config.getApiKey()); - assertEquals("x-api-key", config.getApiKeyHeader()); - assertEquals("", config.getSystemPrompt()); - assertFalse(config.isDebug()); - } - - @Test - public void testSaveAndLoadConfiguration() { - AgentRepository.AgentConfig config = new AgentRepository.AgentConfig(); - config.setAgentUrl("https://test.example.com/agent"); - config.setAuthType(AgentRepository.AuthType.BEARER); - config.setBearerToken("test-token"); - config.setSystemPrompt("You are a test assistant"); - config.setDebug(true); - - repository.saveAgentConfig(config); - - AgentRepository.AgentConfig loaded = repository.loadAgentConfig(); - assertEquals("https://test.example.com/agent", loaded.getAgentUrl()); - assertEquals(AgentRepository.AuthType.BEARER, loaded.getAuthType()); - assertEquals("test-token", loaded.getBearerToken()); - assertEquals("You are a test assistant", loaded.getSystemPrompt()); - assertTrue(loaded.isDebug()); - assertTrue(loaded.isValid()); - } - - @Test - public void testConfigurationValidation() { - AgentRepository.AgentConfig config = new AgentRepository.AgentConfig(); - - // Invalid - no URL - assertFalse(config.isValid()); - - // Valid - has URL - config.setAgentUrl("https://example.com"); - assertTrue(config.isValid()); - - // Still valid with authentication - config.setAuthType(AgentRepository.AuthType.API_KEY); - config.setApiKey("test-key"); - assertTrue(config.isValid()); - } - - @Test - public void testHasAgentConfig() { - assertFalse(repository.hasAgentConfig()); - - AgentRepository.AgentConfig config = new AgentRepository.AgentConfig(); - config.setAgentUrl("https://test.com"); - repository.saveAgentConfig(config); - - assertTrue(repository.hasAgentConfig()); - } - - @Test - public void testAuthTypeConversion() { - AgentRepository.AgentConfig config = new AgentRepository.AgentConfig(); - - config.setAuthType(AgentRepository.AuthType.NONE); - repository.saveAgentConfig(config); - assertEquals(AgentRepository.AuthType.NONE, repository.loadAgentConfig().getAuthType()); - - config.setAuthType(AgentRepository.AuthType.BEARER); - repository.saveAgentConfig(config); - assertEquals(AgentRepository.AuthType.BEARER, repository.loadAgentConfig().getAuthType()); - - config.setAuthType(AgentRepository.AuthType.API_KEY); - repository.saveAgentConfig(config); - assertEquals(AgentRepository.AuthType.API_KEY, repository.loadAgentConfig().getAuthType()); - } - - @Test - public void testClearConfiguration() { - // Save some config - AgentRepository.AgentConfig config = new AgentRepository.AgentConfig(); - config.setAgentUrl("https://test.com"); - repository.saveAgentConfig(config); - assertTrue(repository.hasAgentConfig()); - - // Clear preferences to simulate reset - context.getSharedPreferences("agent_settings", Context.MODE_PRIVATE) - .edit() - .clear() - .apply(); - - assertFalse(repository.hasAgentConfig()); - AgentRepository.AgentConfig cleared = repository.loadAgentConfig(); - assertFalse(cleared.isValid()); - } - - @Test - public void testApiKeyHeaderCustomization() { - AgentRepository.AgentConfig config = new AgentRepository.AgentConfig(); - config.setApiKeyHeader("Authorization"); - repository.saveAgentConfig(config); - - AgentRepository.AgentConfig loaded = repository.loadAgentConfig(); - assertEquals("Authorization", loaded.getApiKeyHeader()); - } - - @Test - public void testEmptyStringHandling() { - AgentRepository.AgentConfig config = new AgentRepository.AgentConfig(); - config.setAgentUrl(""); - config.setBearerToken(""); - config.setApiKey(""); - config.setSystemPrompt(""); - - repository.saveAgentConfig(config); - AgentRepository.AgentConfig loaded = repository.loadAgentConfig(); - - assertFalse(loaded.isValid()); // Empty URL is invalid - assertEquals("", loaded.getBearerToken()); - assertEquals("", loaded.getApiKey()); - assertEquals("", loaded.getSystemPrompt()); - } -} \ No newline at end of file diff --git a/sdks/community/kotlin/examples/chatapp-java/app/src/test/java/com/agui/chatapp/java/repository/MultiAgentRepositoryTest.java b/sdks/community/kotlin/examples/chatapp-java/app/src/test/java/com/agui/chatapp/java/repository/MultiAgentRepositoryTest.java deleted file mode 100644 index c031b6dc5..000000000 --- a/sdks/community/kotlin/examples/chatapp-java/app/src/test/java/com/agui/chatapp/java/repository/MultiAgentRepositoryTest.java +++ /dev/null @@ -1,397 +0,0 @@ -package com.agui.chatapp.java.repository; - -import android.content.Context; -import androidx.arch.core.executor.testing.InstantTaskExecutorRule; -import androidx.lifecycle.Observer; - -import com.agui.chatapp.java.model.AgentProfile; -import com.agui.chatapp.java.model.AuthMethod; -import com.agui.chatapp.java.model.ChatSession; - -import org.junit.After; -import org.junit.Before; -import org.junit.Rule; -import org.junit.Test; -import org.junit.runner.RunWith; -import org.mockito.Mock; -import org.mockito.MockitoAnnotations; -import org.robolectric.RobolectricTestRunner; -import org.robolectric.RuntimeEnvironment; - -import java.lang.reflect.Field; -import java.util.List; -import java.util.concurrent.CountDownLatch; -import java.util.concurrent.TimeUnit; -import java.util.concurrent.atomic.AtomicReference; - -import static org.junit.Assert.*; -import static org.mockito.ArgumentMatchers.any; -import static org.mockito.Mockito.*; - -/** - * Comprehensive tests for MultiAgentRepository singleton pattern. - * Tests singleton behavior, data consistency, LiveData observation, and CRUD operations. - */ -@RunWith(RobolectricTestRunner.class) -public class MultiAgentRepositoryTest { - - @Rule - public InstantTaskExecutorRule instantExecutorRule = new InstantTaskExecutorRule(); - - private Context context; - private MultiAgentRepository repository; - - @Mock - private Observer> agentsObserver; - - @Mock - private Observer activeAgentObserver; - - @Mock - private Observer sessionObserver; - - @Before - public void setUp() { - MockitoAnnotations.openMocks(this); - context = RuntimeEnvironment.getApplication(); - - // Clear any existing singleton instance using reflection - clearSingletonInstance(); - - // Get fresh instance - repository = MultiAgentRepository.getInstance(context); - - // Clear all data for clean test environment - repository.clearAll().join(); - } - - @After - public void tearDown() { - // Clear data and singleton for next test - if (repository != null) { - repository.clearAll().join(); - } - clearSingletonInstance(); - } - - /** - * Clear singleton instance using reflection for testing - */ - private void clearSingletonInstance() { - try { - Field instanceField = MultiAgentRepository.class.getDeclaredField("INSTANCE"); - instanceField.setAccessible(true); - instanceField.set(null, null); - } catch (Exception e) { - // Ignore reflection errors in tests - } - } - - // ===== SINGLETON PATTERN TESTS ===== - - @Test - public void testSingletonInstance_SameInstance() { - MultiAgentRepository instance1 = MultiAgentRepository.getInstance(context); - MultiAgentRepository instance2 = MultiAgentRepository.getInstance(context); - - assertSame("Should return same instance", instance1, instance2); - } - - @Test - public void testSingletonInstance_DifferentContexts() { - Context appContext = context.getApplicationContext(); - Context activityContext = context; // In tests, both are same, but verifies behavior - - MultiAgentRepository instance1 = MultiAgentRepository.getInstance(appContext); - MultiAgentRepository instance2 = MultiAgentRepository.getInstance(activityContext); - - assertSame("Should return same instance regardless of context type", instance1, instance2); - } - - @Test - public void testSingletonInstance_ThreadSafety() throws InterruptedException { - final int numThreads = 10; - final CountDownLatch latch = new CountDownLatch(numThreads); - final AtomicReference[] instances = new AtomicReference[numThreads]; - - // Initialize array - for (int i = 0; i < numThreads; i++) { - instances[i] = new AtomicReference<>(); - } - - // Create multiple threads to test concurrent access - Thread[] threads = new Thread[numThreads]; - for (int i = 0; i < numThreads; i++) { - final int index = i; - threads[i] = new Thread(() -> { - instances[index].set(MultiAgentRepository.getInstance(context)); - latch.countDown(); - }); - } - - // Start all threads - for (Thread thread : threads) { - thread.start(); - } - - // Wait for completion - assertTrue("Threads should complete within timeout", latch.await(5, TimeUnit.SECONDS)); - - // Verify all instances are the same - MultiAgentRepository firstInstance = instances[0].get(); - for (int i = 1; i < numThreads; i++) { - assertSame("All instances should be the same", firstInstance, instances[i].get()); - } - } - - // ===== DATA CONSISTENCY TESTS ===== - - @Test - public void testDataConsistency_AcrossMultipleReferences() { - // Get two references to the repository - MultiAgentRepository repo1 = MultiAgentRepository.getInstance(context); - MultiAgentRepository repo2 = MultiAgentRepository.getInstance(context); - - // They should be the same instance - assertSame("Repository instances should be the same", repo1, repo2); - - // Create test agent - AgentProfile testAgent = createTestAgent("test-agent-1", "Test Agent 1"); - - // Set up observer on second reference before adding agent - repo2.getAgents().observeForever(agentsObserver); - - // Add agent through first reference - repo1.addAgent(testAgent).join(); - - // Verify observer was notified (at least twice: initial empty + addition) - verify(agentsObserver, atLeast(2)).onChanged(any(List.class)); - - // Verify current value is correct - List agents = repo2.getAgents().getValue(); - assertNotNull("Agents list should not be null", agents); - assertEquals("Should have one agent", 1, agents.size()); - assertEquals("Agent should match", testAgent.getId(), agents.get(0).getId()); - } - - @Test - public void testActiveAgentConsistency_AcrossReferences() { - MultiAgentRepository repo1 = MultiAgentRepository.getInstance(context); - MultiAgentRepository repo2 = MultiAgentRepository.getInstance(context); - - // Create and add test agent - AgentProfile testAgent = createTestAgent("test-agent-1", "Test Agent 1"); - repo1.addAgent(testAgent).join(); - - // Set up observer on second reference - repo2.getActiveAgent().observeForever(activeAgentObserver); - - // Set active agent through first reference - repo1.setActiveAgent(testAgent).join(); - - // Verify observer was notified - verify(activeAgentObserver).onChanged(any(AgentProfile.class)); - - // Verify current value is correct - AgentProfile activeAgent = repo2.getActiveAgent().getValue(); - assertNotNull("Active agent should not be null", activeAgent); - assertEquals("Active agent should match", testAgent.getId(), activeAgent.getId()); - - // Also verify synchronous access is consistent - AgentProfile directActiveAgent = repo1.getCurrentActiveAgent(); - assertEquals("Direct and LiveData active agents should match", - activeAgent.getId(), directActiveAgent.getId()); - } - - // ===== LIVEDATA OBSERVATION TESTS ===== - - @Test - public void testLiveDataObservation_AgentAddition() { - // Set up observer before any operations - repository.getAgents().observeForever(agentsObserver); - - // Initial call should happen immediately with empty list - verify(agentsObserver).onChanged(any(List.class)); - reset(agentsObserver); // Reset to count new calls - - // Create and add agent - AgentProfile testAgent = createTestAgent("test-agent", "Test Agent"); - repository.addAgent(testAgent).join(); - - // Observer should be notified of the addition - verify(agentsObserver).onChanged(any(List.class)); - - // Verify current value is correct - List agents = repository.getAgents().getValue(); - assertNotNull("Agents should not be null", agents); - assertEquals("Should have one agent", 1, agents.size()); - assertEquals("Agent should match", testAgent.getId(), agents.get(0).getId()); - } - - @Test - public void testLiveDataObservation_AgentActivation() { - // Create and add test agent - AgentProfile testAgent = createTestAgent("test-agent", "Test Agent"); - repository.addAgent(testAgent).join(); - - // Set up observer - repository.getActiveAgent().observeForever(activeAgentObserver); - - // Activate agent - repository.setActiveAgent(testAgent).join(); - - // Observer should be notified - verify(activeAgentObserver).onChanged(any(AgentProfile.class)); - - // Verify current value is correct - AgentProfile activeAgent = repository.getActiveAgent().getValue(); - assertNotNull("Active agent should not be null", activeAgent); - assertEquals("Active agent should match", testAgent.getId(), activeAgent.getId()); - } - - @Test - public void testLiveDataInstances_SameAcrossReferences() { - MultiAgentRepository repo1 = MultiAgentRepository.getInstance(context); - MultiAgentRepository repo2 = MultiAgentRepository.getInstance(context); - - // LiveData instances should be the same since repositories are the same - assertSame("Agents LiveData should be same instance", - repo1.getAgents(), repo2.getAgents()); - assertSame("Active agent LiveData should be same instance", - repo1.getActiveAgent(), repo2.getActiveAgent()); - assertSame("Current session LiveData should be same instance", - repo1.getCurrentSession(), repo2.getCurrentSession()); - } - - // ===== CRUD OPERATION TESTS ===== - - @Test - public void testCRUDOperations_BasicFlow() throws InterruptedException { - // Create - AgentProfile agent = createTestAgent("crud-test", "CRUD Test Agent"); - repository.addAgent(agent).join(); - - // Read - AgentProfile retrieved = repository.getAgent(agent.getId()).join(); - assertEquals("Retrieved agent should match", agent.getId(), retrieved.getId()); - assertEquals("Retrieved agent name should match", agent.getName(), retrieved.getName()); - - // Update - AgentProfile updated = agent.toBuilder() - .setName("Updated CRUD Test Agent") - .setDescription("Updated description") - .build(); - repository.updateAgent(updated).join(); - - AgentProfile updatedRetrieved = repository.getAgent(agent.getId()).join(); - assertEquals("Updated name should match", "Updated CRUD Test Agent", updatedRetrieved.getName()); - assertEquals("Updated description should match", "Updated description", updatedRetrieved.getDescription()); - - // Delete - repository.deleteAgent(agent.getId()).join(); - - try { - repository.getAgent(agent.getId()).join(); - fail("Should throw exception for deleted agent"); - } catch (Exception e) { - assertTrue("Should get IllegalArgumentException", e.getCause() instanceof IllegalArgumentException); - } - } - - @Test - public void testActiveAgentDeletion_ClearsActiveAgent() throws InterruptedException { - // Create and add agent - AgentProfile agent = createTestAgent("delete-test", "Delete Test Agent"); - repository.addAgent(agent).join(); - repository.setActiveAgent(agent).join(); - - // Verify agent is active - assertNotNull("Agent should be active", repository.getCurrentActiveAgent()); - - // Delete active agent - repository.deleteAgent(agent.getId()).join(); - - // Verify active agent is cleared - assertNull("Active agent should be cleared after deletion", repository.getCurrentActiveAgent()); - } - - // ===== SESSION MANAGEMENT TESTS ===== - - @Test - public void testSessionCreation_OnAgentActivation() throws InterruptedException { - AgentProfile agent = createTestAgent("session-test", "Session Test Agent"); - repository.addAgent(agent).join(); - - // Initially no session - assertNull("Should have no session initially", repository.getCurrentChatSession()); - - // Activate agent - repository.setActiveAgent(agent).join(); - - // Should have session - ChatSession session = repository.getCurrentChatSession(); - assertNotNull("Should have session after activation", session); - assertEquals("Session should be for correct agent", agent.getId(), session.getAgentId()); - assertNotNull("Session should have thread ID", session.getThreadId()); - } - - @Test - public void testNewSessionOnEachActivation() throws InterruptedException { - AgentProfile agent = createTestAgent("session-test", "Session Test Agent"); - repository.addAgent(agent).join(); - - // First activation - repository.setActiveAgent(agent).join(); - ChatSession session1 = repository.getCurrentChatSession(); - assertNotNull("First session should exist", session1); - - // Deactivate - repository.setActiveAgent(null).join(); - assertNull("Session should be cleared", repository.getCurrentChatSession()); - - // Reactivate same agent - repository.setActiveAgent(agent).join(); - ChatSession session2 = repository.getCurrentChatSession(); - assertNotNull("Second session should exist", session2); - - // Should be different sessions (different thread IDs) - assertNotEquals("Sessions should have different thread IDs", - session1.getThreadId(), session2.getThreadId()); - } - - // ===== PERSISTENCE TESTS ===== - - @Test - public void testPersistence_AgentSurvivesInstanceRecreation() { - // Add agent to current instance - AgentProfile agent = createTestAgent("persist-test", "Persistence Test Agent"); - repository.addAgent(agent).join(); - repository.setActiveAgent(agent).join(); - - // Clear singleton and create new instance - clearSingletonInstance(); - MultiAgentRepository newRepository = MultiAgentRepository.getInstance(context); - - // Wait for the new repository to finish loading - newRepository.waitForInitialization().join(); - - // Verify agent persisted - AgentProfile retrieved = newRepository.getCurrentActiveAgent(); - assertNotNull("Active agent should persist", retrieved); - assertEquals("Persisted agent should match", agent.getId(), retrieved.getId()); - assertEquals("Persisted agent name should match", agent.getName(), retrieved.getName()); - } - - // ===== HELPER METHODS ===== - - private AgentProfile createTestAgent(String id, String name) { - return new AgentProfile.Builder() - .setId(id) - .setName(name) - .setUrl("https://test.example.com/agent") - .setDescription("Test agent description") - .setAuthMethod(new AuthMethod.None()) - .setSystemPrompt("You are a test assistant") - .build(); - } -} \ No newline at end of file diff --git a/sdks/community/kotlin/examples/chatapp-java/app/src/test/java/com/agui/chatapp/java/viewmodel/ThreadIdManagementTest.java b/sdks/community/kotlin/examples/chatapp-java/app/src/test/java/com/agui/chatapp/java/viewmodel/ThreadIdManagementTest.java deleted file mode 100644 index 54cc235a1..000000000 --- a/sdks/community/kotlin/examples/chatapp-java/app/src/test/java/com/agui/chatapp/java/viewmodel/ThreadIdManagementTest.java +++ /dev/null @@ -1,208 +0,0 @@ -package com.agui.chatapp.java.viewmodel; - -import android.app.Application; - -import com.agui.chatapp.java.model.AgentProfile; -import com.agui.chatapp.java.model.AuthMethod; - -import org.junit.Before; -import org.junit.Test; -import org.junit.runner.RunWith; -import org.robolectric.RobolectricTestRunner; -import org.robolectric.RuntimeEnvironment; - -import java.lang.reflect.Method; -import java.util.ArrayList; -import java.util.List; - -import static org.junit.Assert.*; - -/** - * Unit tests for thread ID management in ChatViewModel. - * Verifies that new thread IDs are created when switching agents or clearing history. - */ -@RunWith(RobolectricTestRunner.class) -public class ThreadIdManagementTest { - - private ChatViewModel viewModel; - private Application application; - - @Before - public void setUp() { - application = RuntimeEnvironment.getApplication(); - viewModel = new ChatViewModel(application); - } - - @Test - public void testInitialThreadIdGeneration() throws Exception { - // Verify that a thread ID is generated during initialization - Method getCurrentThreadIdMethod = ChatViewModel.class.getDeclaredMethod("getCurrentThreadId"); - getCurrentThreadIdMethod.setAccessible(true); - - String threadId = (String) getCurrentThreadIdMethod.invoke(viewModel); - - assertNotNull("Thread ID should not be null", threadId); - assertTrue("Thread ID should start with 'thread_'", threadId.startsWith("thread_")); - } - - @Test - public void testNewThreadIdOnAgentSwitch() throws Exception { - // Get initial thread ID - Method getCurrentThreadIdMethod = ChatViewModel.class.getDeclaredMethod("getCurrentThreadId"); - getCurrentThreadIdMethod.setAccessible(true); - - String initialThreadId = (String) getCurrentThreadIdMethod.invoke(viewModel); - - // Create test agent profile - AgentProfile testAgent = new AgentProfile.Builder() - .setId("test-agent") - .setName("Test Agent") - .setUrl("https://test.example.com") - .setAuthMethod(new AuthMethod.None()) - .setCreatedAt(System.currentTimeMillis()) - .build(); - - // Switch to agent - viewModel.setActiveAgent(testAgent); - - // Get new thread ID - String newThreadId = (String) getCurrentThreadIdMethod.invoke(viewModel); - - assertNotNull("New thread ID should not be null", newThreadId); - assertTrue("New thread ID should start with 'thread_'", newThreadId.startsWith("thread_")); - assertNotEquals("Thread ID should be different after agent switch", initialThreadId, newThreadId); - } - - @Test - public void testNewThreadIdOnClearHistory() throws Exception { - // Get initial thread ID - Method getCurrentThreadIdMethod = ChatViewModel.class.getDeclaredMethod("getCurrentThreadId"); - getCurrentThreadIdMethod.setAccessible(true); - - String initialThreadId = (String) getCurrentThreadIdMethod.invoke(viewModel); - - // Clear history - viewModel.clearHistory(); - - // Get new thread ID - String newThreadId = (String) getCurrentThreadIdMethod.invoke(viewModel); - - assertNotNull("New thread ID should not be null", newThreadId); - assertTrue("New thread ID should start with 'thread_'", newThreadId.startsWith("thread_")); - assertNotEquals("Thread ID should be different after clear history", initialThreadId, newThreadId); - } - - @Test - public void testThreadIdFormat() throws Exception { - Method getCurrentThreadIdMethod = ChatViewModel.class.getDeclaredMethod("getCurrentThreadId"); - getCurrentThreadIdMethod.setAccessible(true); - - String threadId = (String) getCurrentThreadIdMethod.invoke(viewModel); - - // Should match pattern: thread__ - String[] parts = threadId.split("_"); - assertEquals("Thread ID should have 3 parts separated by underscores", 3, parts.length); - assertEquals("First part should be 'thread'", "thread", parts[0]); - - // Verify timestamp part is numeric - try { - Long.parseLong(parts[1]); - } catch (NumberFormatException e) { - fail("Second part should be a numeric timestamp"); - } - - // Verify random part is numeric - try { - Integer.parseInt(parts[2]); - } catch (NumberFormatException e) { - fail("Third part should be a numeric random value"); - } - } - - @Test - public void testMessagesClearedOnAgentSwitch() { - // Add some initial messages to the view model - // Note: This would normally happen through sendMessage, but for testing we can check the LiveData - - // Create test agent profile - AgentProfile testAgent = new AgentProfile.Builder() - .setId("test-agent") - .setName("Test Agent") - .setUrl("https://test.example.com") - .setAuthMethod(new AuthMethod.None()) - .setCreatedAt(System.currentTimeMillis()) - .build(); - - // Switch to agent - this should clear messages - viewModel.setActiveAgent(testAgent); - - // Verify messages are cleared - List messages = viewModel.getMessages().getValue(); - assertNotNull("Messages list should not be null after agent switch", messages); - assertTrue("Messages list should be empty after agent switch", messages.isEmpty()); - - // Switch to another agent - AgentProfile anotherAgent = new AgentProfile.Builder() - .setId("another-agent") - .setName("Another Agent") - .setUrl("https://another.example.com") - .setAuthMethod(new AuthMethod.None()) - .setCreatedAt(System.currentTimeMillis()) - .build(); - - viewModel.setActiveAgent(anotherAgent); - - // Verify messages are still cleared - messages = viewModel.getMessages().getValue(); - assertNotNull("Messages list should not be null after second agent switch", messages); - assertTrue("Messages list should be empty after second agent switch", messages.isEmpty()); - } - - @Test - public void testMessagesClearedOnClearHistory() { - // Clear history should clear messages - viewModel.clearHistory(); - - // Verify messages are cleared - List messages = viewModel.getMessages().getValue(); - assertNotNull("Messages list should not be null after clear history", messages); - assertTrue("Messages list should be empty after clear history", messages.isEmpty()); - } - - @Test - public void testMultipleAgentSwitches() throws Exception { - Method getCurrentThreadIdMethod = ChatViewModel.class.getDeclaredMethod("getCurrentThreadId"); - getCurrentThreadIdMethod.setAccessible(true); - - String threadId1 = (String) getCurrentThreadIdMethod.invoke(viewModel); - - // Create first agent - AgentProfile agent1 = new AgentProfile.Builder() - .setId("agent-1") - .setName("Agent 1") - .setUrl("https://agent1.example.com") - .setAuthMethod(new AuthMethod.None()) - .setCreatedAt(System.currentTimeMillis()) - .build(); - - viewModel.setActiveAgent(agent1); - String threadId2 = (String) getCurrentThreadIdMethod.invoke(viewModel); - - // Create second agent - AgentProfile agent2 = new AgentProfile.Builder() - .setId("agent-2") - .setName("Agent 2") - .setUrl("https://agent2.example.com") - .setAuthMethod(new AuthMethod.None()) - .setCreatedAt(System.currentTimeMillis()) - .build(); - - viewModel.setActiveAgent(agent2); - String threadId3 = (String) getCurrentThreadIdMethod.invoke(viewModel); - - // All thread IDs should be different - assertNotEquals("Thread ID 1 and 2 should be different", threadId1, threadId2); - assertNotEquals("Thread ID 2 and 3 should be different", threadId2, threadId3); - assertNotEquals("Thread ID 1 and 3 should be different", threadId1, threadId3); - } -} \ No newline at end of file diff --git a/sdks/community/kotlin/examples/chatapp-java/build.gradle b/sdks/community/kotlin/examples/chatapp-java/build.gradle index 62bc8ebda..2c113ca2e 100644 --- a/sdks/community/kotlin/examples/chatapp-java/build.gradle +++ b/sdks/community/kotlin/examples/chatapp-java/build.gradle @@ -6,6 +6,7 @@ buildscript { } dependencies { classpath 'com.android.tools.build:gradle:8.12.0' + classpath 'org.jetbrains.kotlin:kotlin-gradle-plugin:2.2.20' } } @@ -19,4 +20,4 @@ allprojects { task clean(type: Delete) { delete rootProject.buildDir -} \ No newline at end of file +} diff --git a/sdks/community/kotlin/examples/chatapp-java/gradle.properties b/sdks/community/kotlin/examples/chatapp-java/gradle.properties index 1aac9b1ed..ace597414 100644 --- a/sdks/community/kotlin/examples/chatapp-java/gradle.properties +++ b/sdks/community/kotlin/examples/chatapp-java/gradle.properties @@ -20,4 +20,7 @@ android.useAndroidX=true android.nonTransitiveRClass=true # Enable non-final R classes -android.nonFinalResIds=true \ No newline at end of file +android.nonFinalResIds=true + +# Enable Android target in shared core when building this sample +agui.enableAndroid=true diff --git a/sdks/community/kotlin/examples/chatapp-java/gradle/libs.versions.toml b/sdks/community/kotlin/examples/chatapp-java/gradle/libs.versions.toml new file mode 100644 index 000000000..87e16a53b --- /dev/null +++ b/sdks/community/kotlin/examples/chatapp-java/gradle/libs.versions.toml @@ -0,0 +1,94 @@ +[versions] +activity-compose = "1.10.1" +agui-core = "0.2.3" +appcompat = "1.7.1" +core = "1.6.1" +core-ktx = "1.16.0" +junit = "4.13.2" +junit-version = "1.2.1" +kotlin = "2.2.20" +#Downgrading to avoid an R8 error +ktor = "3.1.3" +kotlinx-serialization = "1.8.1" +kotlinx-coroutines = "1.10.2" +kotlinx-datetime = "0.6.2" +android-gradle = "8.10.1" +kotlin-logging = "3.0.5" +logback-android = "3.0.0" +multiplatform-settings-coroutines = "1.2.0" +okio = "3.13.0" +runner = "1.6.2" +slf4j = "2.0.9" +voyager-navigator = "1.0.0" +markdown-renderer = "0.37.0" +compose = "1.9.1" +compose-material3 = "1.4.0" + +[libraries] +# Ktor +activity-compose = { module = "androidx.activity:activity-compose", version.ref = "activity-compose" } +agui-client = { module = "com.agui:kotlin-client", version.ref = "agui-core" } +agui-core = { module = "com.agui:kotlin-core", version.ref = "agui-core" } +agui-tools = { module = "com.agui:kotlin-tools", version.ref = "agui-core" } +androidx-ui-tooling = { module = "androidx.compose.ui:ui-tooling", version.ref = "compose" } +appcompat = { module = "androidx.appcompat:appcompat", version.ref = "appcompat" } +core = { module = "androidx.test:core", version.ref = "core" } +core-ktx = { module = "androidx.core:core-ktx", version.ref = "core-ktx" } +ext-junit = { module = "androidx.test.ext:junit", version.ref = "junit-version" } +junit = { module = "junit:junit", version.ref = "junit" } +ktor-client-core = { module = "io.ktor:ktor-client-core", version.ref = "ktor" } +ktor-client-content-negotiation = { module = "io.ktor:ktor-client-content-negotiation", version.ref = "ktor" } +ktor-serialization-kotlinx-json = { module = "io.ktor:ktor-serialization-kotlinx-json", version.ref = "ktor" } +ktor-client-logging = { module = "io.ktor:ktor-client-logging", version.ref = "ktor" } +ktor-client-android = { module = "io.ktor:ktor-client-android", version.ref = "ktor" } +ktor-client-darwin = { module = "io.ktor:ktor-client-darwin", version.ref = "ktor" } +ktor-client-java = { module = "io.ktor:ktor-client-java", version.ref = "ktor" } +ktor-client-cio = { module = "io.ktor:ktor-client-cio", version.ref = "ktor" } +ktor-client-mock = { module = "io.ktor:ktor-client-mock", version.ref = "ktor" } + +# Kotlinx +kotlinx-coroutines-core = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-core", version.ref = "kotlinx-coroutines" } +kotlinx-coroutines-test = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-test", version.ref = "kotlinx-coroutines" } +kotlinx-serialization-json = { module = "org.jetbrains.kotlinx:kotlinx-serialization-json", version.ref = "kotlinx-serialization" } +kotlinx-datetime = { module = "org.jetbrains.kotlinx:kotlinx-datetime", version.ref = "kotlinx-datetime" } + +# Logging +kotlin-logging = { module = "io.github.microutils:kotlin-logging", version.ref = "kotlin-logging" } +logback-android = { module = "com.github.tony19:logback-android", version.ref = "logback-android" } +multiplatform-settings = { module = "com.russhwolf:multiplatform-settings", version.ref = "multiplatform-settings-coroutines" } +multiplatform-settings-coroutines = { module = "com.russhwolf:multiplatform-settings-coroutines", version.ref = "multiplatform-settings-coroutines" } +okio = { module = "com.squareup.okio:okio", version.ref = "okio" } +runner = { module = "androidx.test:runner", version.ref = "runner" } +slf4j-simple = { module = "org.slf4j:slf4j-simple", version.ref = "slf4j" } +androidx-compose-runtime = { module = "androidx.compose.runtime:runtime", version.ref = "compose" } +androidx-compose-runtime-saveable = { module = "androidx.compose.runtime:runtime-saveable", version.ref = "compose" } +androidx-compose-foundation = { module = "androidx.compose.foundation:foundation", version.ref = "compose" } +androidx-compose-material = { module = "androidx.compose.material:material", version.ref = "compose" } +androidx-compose-material3 = { module = "androidx.compose.material3:material3", version.ref = "compose-material3" } +androidx-compose-ui = { module = "androidx.compose.ui:ui", version.ref = "compose" } +androidx-compose-ui-tooling-preview = { module = "androidx.compose.ui:ui-tooling-preview", version.ref = "compose" } +androidx-compose-ui-test-manifest = { module = "androidx.compose.ui:ui-test-manifest", version.ref = "compose" } +ui-test-junit4 = { module = "androidx.compose.ui:ui-test-junit4", version.ref = "compose" } +voyager-navigator = { module = "cafe.adriel.voyager:voyager-navigator", version.ref = "voyager-navigator" } +voyager-screenmodel = { module = "cafe.adriel.voyager:voyager-screenmodel", version.ref = "voyager-navigator" } +voyager-transitions = { module = "cafe.adriel.voyager:voyager-transitions", version.ref = "voyager-navigator" } +markdown-renderer-m3 = { module = "com.mikepenz:multiplatform-markdown-renderer-m3", version.ref = "markdown-renderer" } + +[plugins] +kotlin-multiplatform = { id = "org.jetbrains.kotlin.multiplatform", version.ref = "kotlin" } +kotlin-serialization = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "kotlin" } +android-library = { id = "com.android.library", version.ref = "android-gradle" } + +[bundles] +ktor-common = [ + "ktor-client-core", + "ktor-client-content-negotiation", + "ktor-serialization-kotlinx-json", + "ktor-client-logging" +] + +kotlinx-common = [ + "kotlinx-coroutines-core", + "kotlinx-serialization-json", + "kotlinx-datetime" +] diff --git a/sdks/community/kotlin/examples/chatapp-java/settings.gradle b/sdks/community/kotlin/examples/chatapp-java/settings.gradle index 560e26633..bc3548b97 100644 --- a/sdks/community/kotlin/examples/chatapp-java/settings.gradle +++ b/sdks/community/kotlin/examples/chatapp-java/settings.gradle @@ -1,2 +1,24 @@ +pluginManagement { + repositories { + google() + gradlePluginPortal() + mavenCentral() + } + + plugins { + def kotlinVersion = "2.2.20" + def agpVersion = "8.12.0" + + id("org.jetbrains.kotlin.android") version kotlinVersion + id("org.jetbrains.kotlin.multiplatform") version kotlinVersion + id("org.jetbrains.kotlin.plugin.serialization") version kotlinVersion + id("com.android.application") version agpVersion + id("com.android.library") version agpVersion + } +} + rootProject.name = "agui-chatapp-java" -include ':app' \ No newline at end of file + +include(":app") +include(":chatapp-shared") +project(":chatapp-shared").projectDir = file("../chatapp-shared") diff --git a/sdks/community/kotlin/examples/chatapp-shared/README.md b/sdks/community/kotlin/examples/chatapp-shared/README.md new file mode 100644 index 000000000..32d043cc4 --- /dev/null +++ b/sdks/community/kotlin/examples/chatapp-shared/README.md @@ -0,0 +1,28 @@ +# ChatApp Shared Core + +A Kotlin Multiplatform module that exposes the non-UI logic reused by the chat application samples. + +## Responsibilities + +- Agent persistence via the multiplatform `AgentRepository` +- Authentication providers (API Key, Bearer, Basic, OAuth2/custom hook point) +- Chat orchestration through the UI-agnostic `ChatController`, which now wires the Kotlin client `AgentSubscriber` hooks to + populate conversation history and ephemerals across platforms +- Platform utilities (settings storage, user ID management, string helpers) +- Tool confirmation integration shared across platforms + +The Compose Multiplatform, SwiftUI, and Android Views (chatapp-java) samples all depend on this module for networking, persistence, and business rules. + +## Targets + +The module ships with Android, JVM desktop, and iOS targets. Consumers compile it directly (Compose) or bundle it inside an XCFramework (SwiftUI). + +## Building + +From the `chatapp-swiftui` or `chatapp` project roots you can reference the Gradle project as `:chatapp-shared`. + +```bash +./gradlew :chatapp-shared:assemble +``` + +The SwiftUI sample's `:shared` module packages this core component into `shared.xcframework` for Xcode. diff --git a/sdks/community/kotlin/examples/chatapp-shared/build.gradle.kts b/sdks/community/kotlin/examples/chatapp-shared/build.gradle.kts new file mode 100644 index 000000000..51087c4ad --- /dev/null +++ b/sdks/community/kotlin/examples/chatapp-shared/build.gradle.kts @@ -0,0 +1,131 @@ +import com.android.build.gradle.LibraryExtension + +plugins { + kotlin("multiplatform") + kotlin("plugin.serialization") +} + +val androidEnabled = providers.gradleProperty("agui.enableAndroid") + .map(String::toBoolean) + .orElse(true) + .get() + +if (androidEnabled) { + pluginManager.apply("com.android.library") +} + +kotlin { + jvmToolchain(21) + + if (androidEnabled) { + androidTarget { + compilations.all { + compileTaskProvider.configure { + compilerOptions { + jvmTarget.set(org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_21) + } + } + } + } + } + + jvm("desktop") { + compilations.all { + compileTaskProvider.configure { + compilerOptions { + jvmTarget.set(org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_21) + } + } + } + } + + iosX64() + iosArm64() + iosSimulatorArm64() + + sourceSets { + val commonMain by getting { + dependencies { + implementation(libs.agui.client) + implementation(libs.kotlinx.coroutines.core) + implementation("org.jetbrains.kotlinx:atomicfu:0.23.2") + implementation(libs.kotlinx.serialization.json) + api(libs.multiplatform.settings) + api(libs.multiplatform.settings.coroutines) + implementation("co.touchlab:kermit:2.0.6") + implementation(libs.kotlinx.datetime) + implementation(libs.okio) + } + } + + val commonTest by getting { + dependencies { + implementation(kotlin("test")) + implementation(libs.kotlinx.coroutines.test) + implementation(libs.ktor.client.mock) + } + } + + if (androidEnabled) { + val androidMain by getting { + dependencies { + implementation(libs.ktor.client.android) + implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.10.2") + } + } + + val androidUnitTest by getting { + dependencies { + implementation(kotlin("test")) + implementation(libs.junit) + } + } + } + + val desktopMain by getting { + dependencies { + implementation(libs.ktor.client.java) + implementation("org.jetbrains.kotlinx:kotlinx-coroutines-swing:1.10.2") + } + } + + val desktopTest by getting + + val iosMain by creating { + dependsOn(commonMain) + val iosX64Main by getting + val iosArm64Main by getting + val iosSimulatorArm64Main by getting + iosX64Main.dependsOn(this) + iosArm64Main.dependsOn(this) + iosSimulatorArm64Main.dependsOn(this) + } + + val iosTest by creating { + dependsOn(commonTest) + val iosX64Test by getting + val iosArm64Test by getting + val iosSimulatorArm64Test by getting + iosX64Test.dependsOn(this) + iosArm64Test.dependsOn(this) + iosSimulatorArm64Test.dependsOn(this) + } + } +} + +pluginManager.withPlugin("com.android.library") { + extensions.configure("android") { + namespace = "com.agui.example.chatapp.shared" + compileSdk = 36 + + defaultConfig { + minSdk = 26 + testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" + } + + compileOptions { + sourceCompatibility = JavaVersion.VERSION_21 + targetCompatibility = JavaVersion.VERSION_21 + } + } +} diff --git a/sdks/community/kotlin/examples/chatapp-shared/gradle.properties b/sdks/community/kotlin/examples/chatapp-shared/gradle.properties new file mode 100644 index 000000000..fd5048b39 --- /dev/null +++ b/sdks/community/kotlin/examples/chatapp-shared/gradle.properties @@ -0,0 +1 @@ +kotlin.mpp.applyDefaultHierarchyTemplate=false diff --git a/sdks/community/kotlin/examples/chatapp/shared/src/androidMain/kotlin/com/agui/example/chatapp/util/AndroidPlatform.kt b/sdks/community/kotlin/examples/chatapp-shared/src/androidMain/kotlin/com/agui/example/chatapp/util/AndroidPlatform.kt similarity index 62% rename from sdks/community/kotlin/examples/chatapp/shared/src/androidMain/kotlin/com/agui/example/chatapp/util/AndroidPlatform.kt rename to sdks/community/kotlin/examples/chatapp-shared/src/androidMain/kotlin/com/agui/example/chatapp/util/AndroidPlatform.kt index c27847af9..ed64e9275 100644 --- a/sdks/community/kotlin/examples/chatapp/shared/src/androidMain/kotlin/com/agui/example/chatapp/util/AndroidPlatform.kt +++ b/sdks/community/kotlin/examples/chatapp-shared/src/androidMain/kotlin/com/agui/example/chatapp/util/AndroidPlatform.kt @@ -4,7 +4,6 @@ import android.content.Context import com.russhwolf.settings.Settings import com.russhwolf.settings.SharedPreferencesSettings -// Use nullable var instead of lateinit var to avoid initialization errors in tests private var appContext: Context? = null fun initializeAndroid(context: Context) { @@ -13,34 +12,20 @@ fun initializeAndroid(context: Context) { actual fun getPlatformSettings(): Settings { val context = appContext - if (context == null) { - throw IllegalStateException( + ?: throw IllegalStateException( "Android context not initialized. Call initializeAndroid(context) first. " + - "In tests, make sure to call initializeAndroid() in your @Before method." + "In tests, make sure to call initializeAndroid() before accessing platform settings." ) - } val sharedPreferences = context.getSharedPreferences("agui4k_prefs", Context.MODE_PRIVATE) return SharedPreferencesSettings(sharedPreferences) } actual fun getPlatformName(): String = "Android" -/** - * Check if Android context has been initialized. - * Useful for testing. - */ fun isAndroidInitialized(): Boolean = appContext != null -/** - * Get the current Android context if initialized. - * Useful for testing. - */ fun getAndroidContext(): Context? = appContext -/** - * Reset the Android context (useful for testing). - * Should only be used in tests. - */ fun resetAndroidContext() { appContext = null -} \ No newline at end of file +} diff --git a/sdks/community/kotlin/examples/chatapp-shared/src/commonMain/kotlin/com/agui/example/chatapp/chat/ChatAgent.kt b/sdks/community/kotlin/examples/chatapp-shared/src/commonMain/kotlin/com/agui/example/chatapp/chat/ChatAgent.kt new file mode 100644 index 000000000..0542e8ae7 --- /dev/null +++ b/sdks/community/kotlin/examples/chatapp-shared/src/commonMain/kotlin/com/agui/example/chatapp/chat/ChatAgent.kt @@ -0,0 +1,46 @@ +package com.agui.example.chatapp.chat + +import com.agui.client.StatefulAgUiAgent +import com.agui.client.agent.AgentSubscriber +import com.agui.client.agent.AgentSubscription +import com.agui.core.types.BaseEvent +import com.agui.example.chatapp.data.model.AgentConfig +import com.agui.tools.DefaultToolRegistry +import kotlinx.coroutines.flow.Flow + +/** Abstraction over the AG-UI client so we can substitute fakes in tests. */ +interface ChatAgent { + fun sendMessage(message: String, threadId: String): Flow? + + fun subscribe(subscriber: AgentSubscriber): AgentSubscription +} + +fun interface ChatAgentFactory { + fun createAgent( + config: AgentConfig, + headers: Map, + toolRegistry: DefaultToolRegistry, + userId: String, + systemPrompt: String? + ): ChatAgent + + companion object { + fun default(): ChatAgentFactory = ChatAgentFactory { config, headers, toolRegistry, userId, systemPrompt -> + val agent = StatefulAgUiAgent(url = config.url) { + this.headers.putAll(headers) + this.toolRegistry = toolRegistry + this.userId = userId + this.systemPrompt = systemPrompt + } + object : ChatAgent { + override fun sendMessage(message: String, threadId: String): Flow? { + return agent.sendMessage(message = message, threadId = threadId) + } + + override fun subscribe(subscriber: AgentSubscriber): AgentSubscription { + return agent.subscribe(subscriber) + } + } + } + } +} diff --git a/sdks/community/kotlin/examples/chatapp-shared/src/commonMain/kotlin/com/agui/example/chatapp/chat/ChatController.kt b/sdks/community/kotlin/examples/chatapp-shared/src/commonMain/kotlin/com/agui/example/chatapp/chat/ChatController.kt new file mode 100644 index 000000000..06ea5c5fb --- /dev/null +++ b/sdks/community/kotlin/examples/chatapp-shared/src/commonMain/kotlin/com/agui/example/chatapp/chat/ChatController.kt @@ -0,0 +1,561 @@ +package com.agui.example.chatapp.chat + +import co.touchlab.kermit.Logger +import com.agui.client.agent.AgentEventParams +import com.agui.client.agent.AgentStateChangedParams +import com.agui.client.agent.AgentStateMutation +import com.agui.client.agent.AgentSubscriber +import com.agui.client.agent.AgentSubscriberParams +import com.agui.client.agent.AgentSubscription +import com.agui.core.types.AssistantMessage +import com.agui.core.types.BaseEvent +import com.agui.core.types.DeveloperMessage +import com.agui.core.types.Message +import com.agui.core.types.Role +import com.agui.core.types.RunErrorEvent +import com.agui.core.types.RunFinishedEvent +import com.agui.core.types.StateDeltaEvent +import com.agui.core.types.StateSnapshotEvent +import com.agui.core.types.StepFinishedEvent +import com.agui.core.types.StepStartedEvent +import com.agui.core.types.SystemMessage +import com.agui.core.types.TextMessageContentEvent +import com.agui.core.types.TextMessageEndEvent +import com.agui.core.types.TextMessageStartEvent +import com.agui.core.types.ToolCallArgsEvent +import com.agui.core.types.ToolCallEndEvent +import com.agui.core.types.ToolCallStartEvent +import com.agui.core.types.ToolMessage +import com.agui.core.types.UserMessage +import com.agui.example.chatapp.data.auth.AuthManager +import com.agui.example.chatapp.data.model.AgentConfig +import com.agui.example.chatapp.data.repository.AgentRepository +import com.agui.example.chatapp.util.Strings +import com.agui.example.chatapp.util.UserIdManager +import com.agui.example.chatapp.util.getPlatformSettings +import com.agui.example.tools.BackgroundChangeHandler +import com.agui.example.tools.BackgroundStyle +import com.agui.example.tools.ChangeBackgroundToolExecutor +import com.agui.tools.DefaultToolRegistry +import kotlinx.atomicfu.atomic +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Job +import kotlinx.coroutines.MainScope +import kotlinx.coroutines.cancel +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.collectLatest +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import kotlinx.datetime.Clock +import com.russhwolf.settings.Settings + +private val logger = Logger.withTag("ChatController") + +/** + * Shared chat coordinator that exposes AG-UI conversation flows to multiplatform UIs. + * The controller is platform agnostic and owns the underlying Kotlin SDK client state. + */ +class ChatController( + externalScope: CoroutineScope? = null, + private val agentFactory: ChatAgentFactory = ChatAgentFactory.default(), + private val settings: Settings = getPlatformSettings(), + private val agentRepository: AgentRepository = AgentRepository.getInstance(settings), + private val authManager: AuthManager = AuthManager(), + private val userIdManager: UserIdManager = UserIdManager.getInstance(settings) +) { + + private val scope = externalScope ?: MainScope() + private val ownsScope = externalScope == null + + private val _state = MutableStateFlow(ChatState()) + val state: StateFlow = _state.asStateFlow() + + private var currentAgent: ChatAgent? = null + private var agentSubscription: AgentSubscription? = null + private var currentJob: Job? = null + private var currentThreadId: String? = null + + private val toolCallBuffer = mutableMapOf() + private val pendingToolCalls = mutableMapOf() + private val streamingMessageIds = mutableSetOf() + private val supplementalMessages = linkedMapOf() + private val ephemeralMessages = mutableMapOf() + private data class StoredMessage(var message: Message, val displayId: String) + + private val messageStore = mutableListOf() + private val messageIdCounts = mutableMapOf() + private val pendingUserMessages = mutableListOf() + + private val agentSubscriber = ControllerAgentSubscriber() + private val controllerClosed = atomic(false) + + init { + scope.launch { + agentRepository.activeAgent.collectLatest { agent -> + _state.update { it.copy(activeAgent = agent) } + if (agent != null) { + connectToAgent(agent) + } else { + disconnectFromAgent() + } + } + } + } + + private suspend fun connectToAgent(agentConfig: AgentConfig) { + disconnectFromAgent() + + try { + val headers = agentConfig.customHeaders.toMutableMap() + authManager.applyAuth(agentConfig.authMethod, headers) + + val backgroundTool = ChangeBackgroundToolExecutor(object : BackgroundChangeHandler { + override suspend fun applyBackground(style: BackgroundStyle) { + _state.update { it.copy(background = style) } + } + }) + + val clientToolRegistry = DefaultToolRegistry().apply { + registerTool(backgroundTool) + } + + currentAgent = agentFactory.createAgent( + config = agentConfig, + headers = headers, + toolRegistry = clientToolRegistry, + userId = userIdManager.getUserId(), + systemPrompt = agentConfig.systemPrompt + ) + + agentSubscription = currentAgent?.subscribe(agentSubscriber) + + currentThreadId = "thread_${Clock.System.now().toEpochMilliseconds()}" + + _state.update { it.copy(isConnected = true, error = null, background = BackgroundStyle.Default) } + + addSupplementalMessage( + DisplayMessage( + id = generateMessageId(), + role = MessageRole.SYSTEM, + content = "${Strings.CONNECTED_TO_PREFIX}${agentConfig.name}" + ) + ) + } catch (e: Exception) { + logger.e(e) { "Failed to connect to agent" } + _state.update { + it.copy( + isConnected = false, + error = "${Strings.FAILED_TO_CONNECT_PREFIX}${e.message}" + ) + } + } + } + + private fun disconnectFromAgent() { + currentJob?.cancel() + currentJob = null + agentSubscription?.unsubscribe() + agentSubscription = null + currentAgent = null + currentThreadId = null + toolCallBuffer.clear() + pendingToolCalls.clear() + streamingMessageIds.clear() + supplementalMessages.clear() + ephemeralMessages.clear() + messageStore.clear() + messageIdCounts.clear() + pendingUserMessages.clear() + + _state.update { + it.copy( + isConnected = false, + messages = emptyList(), + background = BackgroundStyle.Default + ) + } + } + + fun sendMessage(content: String) { + if (content.isBlank() || currentAgent == null || controllerClosed.value) return + + val trimmed = content.trim() + val userMessage = DisplayMessage( + id = generateMessageId(), + role = MessageRole.USER, + content = trimmed, + isStreaming = true + ) + pendingUserMessages += userMessage + refreshMessages() + + startConversation(trimmed) + } + + private fun startConversation(content: String) { + currentJob?.cancel() + + currentJob = scope.launch { + _state.update { it.copy(isLoading = true) } + + try { + currentAgent?.sendMessage( + message = content, + threadId = currentThreadId ?: "default" + )?.collect { event -> + logger.d { "Received event: ${event::class.simpleName}" } + } + } catch (e: Exception) { + logger.e(e) { "Error running agent" } + addSupplementalMessage( + DisplayMessage( + id = generateMessageId(), + role = MessageRole.ERROR, + content = "${Strings.ERROR_PREFIX}${e.message}" + ) + ) + } finally { + _state.update { it.copy(isLoading = false) } + finalizeStreamingState() + clearAllEphemeralMessages() + } + } + } + + internal fun handleAgentEvent(event: BaseEvent) { + logger.d { "Handling event: ${event::class.simpleName}" } + agentSubscriber.handleManualEvent(event) + } + + fun cancelCurrentOperation() { + currentJob?.cancel() + + _state.update { it.copy(isLoading = false) } + finalizeStreamingState() + clearAllEphemeralMessages() + } + + fun clearError() { + _state.update { it.copy(error = null) } + } + + fun close() { + if (!controllerClosed.compareAndSet(expect = false, update = true)) return + + cancelCurrentOperation() + disconnectFromAgent() + if (ownsScope) { + scope.cancel() + } + } + + internal fun updateMessagesFromAgent(messages: List) { + if (messages.isEmpty()) { + if (messageStore.isNotEmpty()) { + messageStore.clear() + messageIdCounts.clear() + refreshMessages() + } + return + } + + var changed = false + + messages.forEach { message -> + changed = applyMessageUpdate(message) || changed + } + + if (changed) { + val conversation = messageStore.mapNotNull { stored -> + stored.message.toDisplayMessage()?.copy(id = stored.displayId) + } + reconcilePendingUserMessages(conversation) + refreshMessages() + } + } + + private fun Message.toDisplayMessage(): DisplayMessage? = when (this) { + is DeveloperMessage -> DisplayMessage( + id = id, + role = MessageRole.DEVELOPER, + content = content, + isStreaming = streamingMessageIds.contains(id) + ) + is SystemMessage -> DisplayMessage( + id = id, + role = MessageRole.SYSTEM, + content = content ?: "", + isStreaming = streamingMessageIds.contains(id) + ) + is AssistantMessage -> DisplayMessage( + id = id, + role = MessageRole.ASSISTANT, + content = formatAssistantContent(this), + isStreaming = streamingMessageIds.contains(id) + ) + is UserMessage -> DisplayMessage( + id = id, + role = MessageRole.USER, + content = content, + isStreaming = streamingMessageIds.contains(id) + ) + is ToolMessage -> null + } + + private fun formatAssistantContent(message: AssistantMessage): String = + message.content?.takeIf { it.isNotBlank() } ?: "" + + private fun reconcilePendingUserMessages(agentMessages: List) { + if (pendingUserMessages.isEmpty()) return + + val userContents = agentMessages + .filter { it.role == MessageRole.USER } + .map { it.content } + .toMutableList() + + if (userContents.isEmpty()) return + + val remaining = mutableListOf() + for (pending in pendingUserMessages.asReversed()) { + val matchIndex = userContents.indexOfLast { it == pending.content } + if (matchIndex >= 0) { + userContents.removeAt(matchIndex) + } else { + remaining.add(0, pending) + } + } + + pendingUserMessages.clear() + pendingUserMessages.addAll(remaining) + } + + private fun applyMessageUpdate(message: Message): Boolean { + val existing = messageStore.filter { it.message.id == message.id } + val identical = existing.firstOrNull { it.message == message } + if (identical != null) { + return false + } + + return when { + message is UserMessage -> { + val occurrence = messageIdCounts[message.id] ?: 0 + val stableId = if (occurrence == 0) message.id else "${message.id}#$occurrence" + messageIdCounts[message.id] = occurrence + 1 + messageStore.add(StoredMessage(message, stableId)) + true + } + existing.isEmpty() -> { + val occurrence = messageIdCounts[message.id] ?: 0 + val stableId = if (occurrence == 0) message.id else "${message.id}#$occurrence" + messageIdCounts[message.id] = occurrence + 1 + messageStore.add(StoredMessage(message, stableId)) + true + } + else -> { + val target = existing.last() + target.message = message + true + } + } + } + + private fun summarizeArguments(arguments: String): String { + val trimmed = arguments.trim() + return if (trimmed.length <= 80) trimmed else trimmed.take(77) + "…" + } + + private fun addSupplementalMessage(message: DisplayMessage) { + supplementalMessages[message.id] = message + refreshMessages() + } + + private fun setEphemeralMessage(content: String, type: EphemeralType, icon: String = "") { + val message = DisplayMessage( + id = generateMessageId(), + role = when (type) { + EphemeralType.TOOL_CALL -> MessageRole.TOOL_CALL + EphemeralType.STEP -> MessageRole.STEP_INFO + }, + content = "$icon $content".trim(), + ephemeralGroupId = type.name, + ephemeralType = type + ) + ephemeralMessages[type] = message + refreshMessages() + } + + private fun clearEphemeralMessage(type: EphemeralType) { + if (ephemeralMessages.remove(type) != null) { + refreshMessages() + } + } + + private fun clearAllEphemeralMessages() { + if (ephemeralMessages.isNotEmpty()) { + ephemeralMessages.clear() + refreshMessages() + } + } + + private fun processStreamingAndEphemeral(event: BaseEvent) { + var needsRefresh = false + when (event) { + is TextMessageStartEvent -> { + streamingMessageIds += event.messageId + needsRefresh = true + } + is TextMessageContentEvent -> Unit + is TextMessageEndEvent -> { + streamingMessageIds -= event.messageId + needsRefresh = true + } + is ToolCallStartEvent -> { + toolCallBuffer[event.toolCallId] = StringBuilder() + pendingToolCalls[event.toolCallId] = event.toolCallName + if (event.toolCallName != "change_background") { + setEphemeralMessage( + content = "Calling ${event.toolCallName}…", + type = EphemeralType.TOOL_CALL, + icon = "🔧" + ) + } + } + is ToolCallArgsEvent -> { + toolCallBuffer[event.toolCallId]?.append(event.delta) + val argsPreview = toolCallBuffer[event.toolCallId]?.toString().orEmpty() + val toolName = pendingToolCalls[event.toolCallId] + if (toolName != null && toolName != "change_background") { + setEphemeralMessage( + content = "Calling $toolName with: ${summarizeArguments(argsPreview)}", + type = EphemeralType.TOOL_CALL, + icon = "🔧" + ) + } + } + is ToolCallEndEvent -> { + val toolName = pendingToolCalls.remove(event.toolCallId) + toolCallBuffer.remove(event.toolCallId) + if (toolName != "change_background") { + scope.launch { + delay(1000) + clearEphemeralMessage(EphemeralType.TOOL_CALL) + } + } + } + is StepStartedEvent -> { + setEphemeralMessage( + content = event.stepName, + type = EphemeralType.STEP, + icon = "●" + ) + } + is StepFinishedEvent -> { + scope.launch { + delay(500) + clearEphemeralMessage(EphemeralType.STEP) + } + } + is RunErrorEvent -> { + addSupplementalMessage( + DisplayMessage( + id = generateMessageId(), + role = MessageRole.ERROR, + content = "${Strings.AGENT_ERROR_PREFIX}${event.message}" + ) + ) + } + is RunFinishedEvent -> clearAllEphemeralMessages() + is StateDeltaEvent, is StateSnapshotEvent -> Unit + else -> Unit + } + if (needsRefresh) { + refreshMessages() + } + } + + private fun finalizeStreamingState() { + streamingMessageIds.clear() + for (i in pendingUserMessages.indices) { + pendingUserMessages[i] = pendingUserMessages[i].copy(isStreaming = false) + } + refreshMessages() + } + + private fun refreshMessages() { + val conversation = messageStore.mapNotNull { stored -> + stored.message.toDisplayMessage()?.copy(id = stored.displayId) + } + val pending = pendingUserMessages.toList() + val supplemental = supplementalMessages.values.toList() + val ephemerals = ephemeralMessages.values.toList() + _state.update { + it.copy(messages = supplemental + conversation + pending + ephemerals) + } + } + + private inner class ControllerAgentSubscriber : AgentSubscriber { + override suspend fun onRunInitialized(params: AgentSubscriberParams): AgentStateMutation? { + updateMessagesFromAgent(params.messages) + return null + } + + override suspend fun onMessagesChanged(params: AgentStateChangedParams) { + updateMessagesFromAgent(params.messages) + } + + override suspend fun onEvent(params: AgentEventParams): AgentStateMutation? { + processStreamingAndEphemeral(params.event) + return null + } + + override suspend fun onRunFinalized(params: AgentSubscriberParams): AgentStateMutation? { + finalizeStreamingState() + return null + } + + fun handleManualEvent(event: BaseEvent) { + logger.d { "Manual event received: ${event::class.simpleName} (id=${(event as? TextMessageStartEvent)?.messageId ?: (event as? TextMessageContentEvent)?.messageId ?: "n/a"})" } + processStreamingAndEphemeral(event) + } + } + + private fun generateMessageId(): String = "msg_${Clock.System.now().toEpochMilliseconds()}" + +} + +/** + * Immutable view state for chat surfaces. + */ +data class ChatState( + val activeAgent: AgentConfig? = null, + val messages: List = emptyList(), + val ephemeralMessage: DisplayMessage? = null, + val isLoading: Boolean = false, + val isConnected: Boolean = false, + val error: String? = null, + val background: BackgroundStyle = BackgroundStyle.Default +) + +/** Classic chat roles shown in the UI layers. */ +enum class MessageRole { + USER, ASSISTANT, SYSTEM, DEVELOPER, ERROR, TOOL_CALL, STEP_INFO +} + +/** Distinguishes transient tool/step messages. */ +enum class EphemeralType { + TOOL_CALL, STEP +} + +/** Representation of rendered chat messages for UIs. */ +data class DisplayMessage( + val id: String, + val role: MessageRole, + val content: String, + val timestamp: Long = Clock.System.now().toEpochMilliseconds(), + val isStreaming: Boolean = false, + val ephemeralGroupId: String? = null, + val ephemeralType: EphemeralType? = null +) diff --git a/sdks/community/kotlin/examples/chatapp/shared/src/commonMain/kotlin/com/agui/example/chatapp/data/auth/ApiKeyAuthProvider.kt b/sdks/community/kotlin/examples/chatapp-shared/src/commonMain/kotlin/com/agui/example/chatapp/data/auth/ApiKeyAuthProvider.kt similarity index 100% rename from sdks/community/kotlin/examples/chatapp/shared/src/commonMain/kotlin/com/agui/example/chatapp/data/auth/ApiKeyAuthProvider.kt rename to sdks/community/kotlin/examples/chatapp-shared/src/commonMain/kotlin/com/agui/example/chatapp/data/auth/ApiKeyAuthProvider.kt diff --git a/sdks/community/kotlin/examples/chatapp/shared/src/commonMain/kotlin/com/agui/example/chatapp/data/auth/AuthManager.kt b/sdks/community/kotlin/examples/chatapp-shared/src/commonMain/kotlin/com/agui/example/chatapp/data/auth/AuthManager.kt similarity index 100% rename from sdks/community/kotlin/examples/chatapp/shared/src/commonMain/kotlin/com/agui/example/chatapp/data/auth/AuthManager.kt rename to sdks/community/kotlin/examples/chatapp-shared/src/commonMain/kotlin/com/agui/example/chatapp/data/auth/AuthManager.kt diff --git a/sdks/community/kotlin/examples/chatapp/shared/src/commonMain/kotlin/com/agui/example/chatapp/data/auth/AuthProvider.kt b/sdks/community/kotlin/examples/chatapp-shared/src/commonMain/kotlin/com/agui/example/chatapp/data/auth/AuthProvider.kt similarity index 100% rename from sdks/community/kotlin/examples/chatapp/shared/src/commonMain/kotlin/com/agui/example/chatapp/data/auth/AuthProvider.kt rename to sdks/community/kotlin/examples/chatapp-shared/src/commonMain/kotlin/com/agui/example/chatapp/data/auth/AuthProvider.kt diff --git a/sdks/community/kotlin/examples/chatapp/shared/src/commonMain/kotlin/com/agui/example/chatapp/data/auth/BasicAuthProvider.kt b/sdks/community/kotlin/examples/chatapp-shared/src/commonMain/kotlin/com/agui/example/chatapp/data/auth/BasicAuthProvider.kt similarity index 100% rename from sdks/community/kotlin/examples/chatapp/shared/src/commonMain/kotlin/com/agui/example/chatapp/data/auth/BasicAuthProvider.kt rename to sdks/community/kotlin/examples/chatapp-shared/src/commonMain/kotlin/com/agui/example/chatapp/data/auth/BasicAuthProvider.kt diff --git a/sdks/community/kotlin/examples/chatapp/shared/src/commonMain/kotlin/com/agui/example/chatapp/data/auth/BearerTokenAuthProvider.kt b/sdks/community/kotlin/examples/chatapp-shared/src/commonMain/kotlin/com/agui/example/chatapp/data/auth/BearerTokenAuthProvider.kt similarity index 100% rename from sdks/community/kotlin/examples/chatapp/shared/src/commonMain/kotlin/com/agui/example/chatapp/data/auth/BearerTokenAuthProvider.kt rename to sdks/community/kotlin/examples/chatapp-shared/src/commonMain/kotlin/com/agui/example/chatapp/data/auth/BearerTokenAuthProvider.kt diff --git a/sdks/community/kotlin/examples/chatapp/shared/src/commonMain/kotlin/com/agui/example/chatapp/data/model/AgentConfig.kt b/sdks/community/kotlin/examples/chatapp-shared/src/commonMain/kotlin/com/agui/example/chatapp/data/model/AgentConfig.kt similarity index 100% rename from sdks/community/kotlin/examples/chatapp/shared/src/commonMain/kotlin/com/agui/example/chatapp/data/model/AgentConfig.kt rename to sdks/community/kotlin/examples/chatapp-shared/src/commonMain/kotlin/com/agui/example/chatapp/data/model/AgentConfig.kt diff --git a/sdks/community/kotlin/examples/chatapp/shared/src/commonMain/kotlin/com/agui/example/chatapp/data/model/AuthMethod.kt b/sdks/community/kotlin/examples/chatapp-shared/src/commonMain/kotlin/com/agui/example/chatapp/data/model/AuthMethod.kt similarity index 100% rename from sdks/community/kotlin/examples/chatapp/shared/src/commonMain/kotlin/com/agui/example/chatapp/data/model/AuthMethod.kt rename to sdks/community/kotlin/examples/chatapp-shared/src/commonMain/kotlin/com/agui/example/chatapp/data/model/AuthMethod.kt diff --git a/sdks/community/kotlin/examples/chatapp/shared/src/commonMain/kotlin/com/agui/example/chatapp/data/repository/AgentRepository.kt b/sdks/community/kotlin/examples/chatapp-shared/src/commonMain/kotlin/com/agui/example/chatapp/data/repository/AgentRepository.kt similarity index 100% rename from sdks/community/kotlin/examples/chatapp/shared/src/commonMain/kotlin/com/agui/example/chatapp/data/repository/AgentRepository.kt rename to sdks/community/kotlin/examples/chatapp-shared/src/commonMain/kotlin/com/agui/example/chatapp/data/repository/AgentRepository.kt diff --git a/sdks/community/kotlin/examples/chatapp/shared/src/commonMain/kotlin/com/agui/example/chatapp/util/Platform.kt b/sdks/community/kotlin/examples/chatapp-shared/src/commonMain/kotlin/com/agui/example/chatapp/util/Platform.kt similarity index 100% rename from sdks/community/kotlin/examples/chatapp/shared/src/commonMain/kotlin/com/agui/example/chatapp/util/Platform.kt rename to sdks/community/kotlin/examples/chatapp-shared/src/commonMain/kotlin/com/agui/example/chatapp/util/Platform.kt diff --git a/sdks/community/kotlin/examples/chatapp/shared/src/commonMain/kotlin/com/agui/example/chatapp/util/StringResourceProvider.kt b/sdks/community/kotlin/examples/chatapp-shared/src/commonMain/kotlin/com/agui/example/chatapp/util/StringResourceProvider.kt similarity index 100% rename from sdks/community/kotlin/examples/chatapp/shared/src/commonMain/kotlin/com/agui/example/chatapp/util/StringResourceProvider.kt rename to sdks/community/kotlin/examples/chatapp-shared/src/commonMain/kotlin/com/agui/example/chatapp/util/StringResourceProvider.kt diff --git a/sdks/community/kotlin/examples/chatapp/shared/src/commonMain/kotlin/com/agui/example/chatapp/util/UserIdManager.kt b/sdks/community/kotlin/examples/chatapp-shared/src/commonMain/kotlin/com/agui/example/chatapp/util/UserIdManager.kt similarity index 96% rename from sdks/community/kotlin/examples/chatapp/shared/src/commonMain/kotlin/com/agui/example/chatapp/util/UserIdManager.kt rename to sdks/community/kotlin/examples/chatapp-shared/src/commonMain/kotlin/com/agui/example/chatapp/util/UserIdManager.kt index 7160e1331..a5001c12b 100644 --- a/sdks/community/kotlin/examples/chatapp/shared/src/commonMain/kotlin/com/agui/example/chatapp/util/UserIdManager.kt +++ b/sdks/community/kotlin/examples/chatapp-shared/src/commonMain/kotlin/com/agui/example/chatapp/util/UserIdManager.kt @@ -27,6 +27,10 @@ class UserIdManager(private val settings: Settings) { } } } + + fun resetInstance() { + instance.value = null + } } /** @@ -66,4 +70,4 @@ class UserIdManager(private val settings: Settings) { fun hasUserId(): Boolean { return settings.getStringOrNull(USER_ID_KEY) != null } -} \ No newline at end of file +} diff --git a/sdks/community/kotlin/examples/chatapp-shared/src/commonMain/kotlin/com/agui/example/tools/ChangeBackgroundToolExecutor.kt b/sdks/community/kotlin/examples/chatapp-shared/src/commonMain/kotlin/com/agui/example/tools/ChangeBackgroundToolExecutor.kt new file mode 100644 index 000000000..8c0fc8092 --- /dev/null +++ b/sdks/community/kotlin/examples/chatapp-shared/src/commonMain/kotlin/com/agui/example/tools/ChangeBackgroundToolExecutor.kt @@ -0,0 +1,162 @@ +package com.agui.example.tools + +import com.agui.core.types.Tool +import com.agui.core.types.ToolCall +import com.agui.tools.AbstractToolExecutor +import com.agui.tools.ToolExecutionContext +import com.agui.tools.ToolExecutionResult +import com.agui.tools.ToolValidationResult +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonPrimitive +import kotlinx.serialization.json.booleanOrNull +import kotlinx.serialization.json.buildJsonObject +import kotlinx.serialization.json.jsonObject +import kotlinx.serialization.json.jsonPrimitive +import kotlinx.serialization.json.put +import kotlinx.serialization.json.putJsonObject + +/** + * Tool executor that changes the visual background of the chat demos. + * + * The tool receives color information from the agent and forwards it to the + * host application through the provided [BackgroundChangeHandler]. Each demo + * is responsible for interpreting the [BackgroundStyle] in a platform + * specific way (e.g. changing a Compose surface colour or a SwiftUI + * background view). + */ +class ChangeBackgroundToolExecutor( + private val backgroundChangeHandler: BackgroundChangeHandler +) : AbstractToolExecutor( + tool = Tool( + name = "change_background", + description = "Update the application's background or surface colour", + parameters = buildJsonObject { + put("type", "object") + putJsonObject("properties") { + putJsonObject("color") { + put("type", "string") + put( + "description", + "Colour in hex format (e.g. #RRGGBB or #RRGGBBAA) to apply to the background" + ) + } + putJsonObject("description") { + put("type", "string") + put( + "description", + "Optional human readable description of the new background" + ) + } + putJsonObject("reset") { + put("type", "boolean") + put( + "description", + "Set to true to reset the background to the default theme" + ) + put("default", JsonPrimitive(false)) + } + } + } + ) +) { + + override suspend fun executeInternal(context: ToolExecutionContext): ToolExecutionResult { + val args = try { + Json.parseToJsonElement(context.toolCall.function.arguments).jsonObject + } catch (error: Exception) { + return ToolExecutionResult.failure("Invalid JSON arguments: ${error.message}") + } + + val reset = args["reset"]?.jsonPrimitive?.booleanOrNull ?: false + if (reset) { + backgroundChangeHandler.applyBackground(BackgroundStyle.Default) + return ToolExecutionResult.success( + result = buildJsonObject { + put("status", "reset") + }, + message = "Background reset to default" + ) + } + + val color = args["color"]?.jsonPrimitive?.content + ?: return ToolExecutionResult.failure("Missing required parameter: color") + + if (!color.matches(HEX_COLOUR_REGEX)) { + return ToolExecutionResult.failure( + "Invalid colour value: $color. Expected formats: #RRGGBB or #RRGGBBAA" + ) + } + + val description = args["description"]?.jsonPrimitive?.content + val style = BackgroundStyle( + colorHex = color, + description = description + ) + + return try { + backgroundChangeHandler.applyBackground(style) + ToolExecutionResult.success( + result = buildJsonObject { + put("status", "applied") + put("color", color) + if (description != null) { + put("description", description) + } + }, + message = "Background updated" + ) + } catch (error: Exception) { + ToolExecutionResult.failure("Failed to change background: ${error.message}") + } + } + + override fun validate(toolCall: ToolCall): ToolValidationResult { + val args = try { + Json.parseToJsonElement(toolCall.function.arguments).jsonObject + } catch (error: Exception) { + return ToolValidationResult.failure("Invalid JSON arguments: ${error.message}") + } + + val reset = args["reset"]?.jsonPrimitive?.booleanOrNull ?: false + if (reset) { + return ToolValidationResult.success() + } + + val color = args["color"]?.jsonPrimitive?.content + ?: return ToolValidationResult.failure("Missing required parameter: color") + + return if (color.matches(HEX_COLOUR_REGEX)) { + ToolValidationResult.success() + } else { + ToolValidationResult.failure( + "Invalid colour value: $color. Expected formats: #RRGGBB or #RRGGBBAA" + ) + } + } + + override fun getMaxExecutionTimeMs(): Long? = 10_000L + + private companion object { + val HEX_COLOUR_REGEX = Regex("^#([0-9A-Fa-f]{6}|[0-9A-Fa-f]{8})$") + } +} + +/** + * Representation of a visual background request sent from the agent. + */ +data class BackgroundStyle( + val colorHex: String?, + val description: String? = null +) { + companion object { + val Default = BackgroundStyle(colorHex = null, description = null) + } +} + +/** + * Implemented by host applications to react to [ChangeBackgroundToolExecutor] + * requests. + */ +interface BackgroundChangeHandler { + suspend fun applyBackground(style: BackgroundStyle) +} diff --git a/sdks/community/kotlin/examples/chatapp-shared/src/commonTest/kotlin/com/agui/example/chatapp/chat/ChatControllerTest.kt b/sdks/community/kotlin/examples/chatapp-shared/src/commonTest/kotlin/com/agui/example/chatapp/chat/ChatControllerTest.kt new file mode 100644 index 000000000..6ff99ff66 --- /dev/null +++ b/sdks/community/kotlin/examples/chatapp-shared/src/commonTest/kotlin/com/agui/example/chatapp/chat/ChatControllerTest.kt @@ -0,0 +1,192 @@ +package com.agui.example.chatapp.chat + +import com.agui.client.agent.AgentSubscriber +import com.agui.client.agent.AgentSubscription +import com.agui.core.types.AssistantMessage +import com.agui.core.types.BaseEvent +import com.agui.core.types.RunErrorEvent +import com.agui.core.types.ToolCallEndEvent +import com.agui.core.types.ToolCallStartEvent +import com.agui.core.types.UserMessage +import com.agui.example.chatapp.data.model.AgentConfig +import com.agui.example.chatapp.data.model.AuthMethod +import com.agui.example.chatapp.data.repository.AgentRepository +import com.agui.example.chatapp.testutil.FakeSettings +import com.agui.example.chatapp.util.UserIdManager +import com.agui.tools.DefaultToolRegistry +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertTrue +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.cancel +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.flow +import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.advanceTimeBy +import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.runTest +import kotlinx.datetime.Instant + +@OptIn(ExperimentalCoroutinesApi::class) +class ChatControllerTest { + + @Test + fun sendMessage_streamingCompletesAndStoresMessages() = runTest { + val dispatcher = StandardTestDispatcher(testScheduler) + val scope = TestScope(dispatcher) + val settings = FakeSettings() + AgentRepository.resetInstance() + UserIdManager.resetInstance() + + val factory = StubChatAgentFactory() + val repository = AgentRepository.getInstance(settings) + val userIdManager = UserIdManager.getInstance(settings) + val controller = ChatController( + externalScope = scope, + agentFactory = factory, + settings = settings, + agentRepository = repository, + userIdManager = userIdManager + ) + val agent = AgentConfig( + id = "agent-1", + name = "Test Agent", + url = "https://example.agents.dev", + authMethod = AuthMethod.None(), + createdAt = Instant.fromEpochMilliseconds(0) + ) + repository.addAgent(agent) + repository.setActiveAgent(agent) + advanceUntilIdle() + + val stub = factory.createdAgents.single() + stub.nextSendFlow = flow { } + + controller.sendMessage("Hi there") + advanceUntilIdle() + + val pendingSnapshot = controller.state.value.messages.filter { it.role == MessageRole.USER && it.content == "Hi there" } + assertEquals(1, pendingSnapshot.size) + assertTrue(pendingSnapshot.isNotEmpty()) + + controller.updateMessagesFromAgent( + listOf( + UserMessage(id = "user-1", content = "Hi there"), + AssistantMessage(id = "msg-agent", content = "Hello") + ) + ) + + val messages = controller.state.value.messages + val userMessages = messages.filter { it.role == MessageRole.USER && it.content == "Hi there" } + assertEquals(1, userMessages.size) + assertFalse(userMessages.single().isStreaming) + val assistant = messages.last { it.role == MessageRole.ASSISTANT } + assertEquals("Hello", assistant.content) + assertFalse(assistant.isStreaming) + + val recorded = stub.sentMessages.single() + assertEquals("Hi there", recorded.first) + assertTrue(recorded.second.isNotBlank()) + + controller.close() + scope.cancel() + AgentRepository.resetInstance() + } + + @Test + fun toolCallEventsManageEphemeralMessages() = runTest { + val dispatcher = StandardTestDispatcher(testScheduler) + val scope = TestScope(dispatcher) + val settings = FakeSettings() + AgentRepository.resetInstance() + UserIdManager.resetInstance() + + val repository = AgentRepository.getInstance(settings) + val userIdManager = UserIdManager.getInstance(settings) + val controller = ChatController( + externalScope = scope, + agentFactory = StubChatAgentFactory(), + settings = settings, + agentRepository = repository, + userIdManager = userIdManager + ) + + controller.handleAgentEvent(ToolCallStartEvent(toolCallId = "call-1", toolCallName = "search")) + assertTrue(controller.state.value.messages.any { it.role == MessageRole.TOOL_CALL }) + + controller.handleAgentEvent(ToolCallEndEvent(toolCallId = "call-1")) + advanceTimeBy(1000) + advanceUntilIdle() + + assertFalse(controller.state.value.messages.any { it.role == MessageRole.TOOL_CALL }) + + controller.close() + scope.cancel() + AgentRepository.resetInstance() + } + + @Test + fun runErrorEventAddsErrorMessage() = runTest { + val dispatcher = StandardTestDispatcher(testScheduler) + val scope = TestScope(dispatcher) + val settings = FakeSettings() + AgentRepository.resetInstance() + UserIdManager.resetInstance() + + val repository = AgentRepository.getInstance(settings) + val userIdManager = UserIdManager.getInstance(settings) + val controller = ChatController( + externalScope = scope, + agentFactory = StubChatAgentFactory(), + settings = settings, + agentRepository = repository, + userIdManager = userIdManager + ) + + controller.handleAgentEvent(RunErrorEvent(message = "Boom", rawEvent = null, timestamp = null)) + + val messages = controller.state.value.messages + assertEquals(1, messages.size) + assertEquals(MessageRole.ERROR, messages.first().role) + + controller.close() + scope.cancel() + AgentRepository.resetInstance() + } + + private class StubChatAgentFactory : ChatAgentFactory { + val createdAgents = mutableListOf() + + override fun createAgent( + config: AgentConfig, + headers: Map, + toolRegistry: DefaultToolRegistry, + userId: String, + systemPrompt: String? + ): ChatAgent { + return StubChatAgent().also { createdAgents += it } + } + } + + private class StubChatAgent : ChatAgent { + var nextSendFlow: Flow? = null + val sentMessages = mutableListOf>() + private val subscribers = mutableListOf() + + override fun sendMessage(message: String, threadId: String): Flow? { + sentMessages += message to threadId + return nextSendFlow + } + + override fun subscribe(subscriber: AgentSubscriber): AgentSubscription { + subscribers += subscriber + return object : AgentSubscription { + override fun unsubscribe() { + subscribers.remove(subscriber) + } + } + } + } +} diff --git a/sdks/community/kotlin/examples/chatapp-shared/src/commonTest/kotlin/com/agui/example/chatapp/data/AgentRepositoryTest.kt b/sdks/community/kotlin/examples/chatapp-shared/src/commonTest/kotlin/com/agui/example/chatapp/data/AgentRepositoryTest.kt new file mode 100644 index 000000000..7d65d24f9 --- /dev/null +++ b/sdks/community/kotlin/examples/chatapp-shared/src/commonTest/kotlin/com/agui/example/chatapp/data/AgentRepositoryTest.kt @@ -0,0 +1,96 @@ +package com.agui.example.chatapp.data + +import com.agui.example.chatapp.data.model.AgentConfig +import com.agui.example.chatapp.data.model.AuthMethod +import com.agui.example.chatapp.data.repository.AgentRepository +import com.agui.example.chatapp.testutil.FakeSettings +import kotlin.test.AfterTest +import kotlin.test.BeforeTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertNotNull +import kotlin.test.assertNull +import kotlin.test.assertTrue +import kotlinx.coroutines.test.runTest +import kotlinx.datetime.Instant + +class AgentRepositoryTest { + private lateinit var settings: FakeSettings + private lateinit var repository: AgentRepository + + @BeforeTest + fun setUp() { + AgentRepository.resetInstance() + settings = FakeSettings() + repository = AgentRepository.getInstance(settings) + } + + @AfterTest + fun tearDown() { + AgentRepository.resetInstance() + } + + @Test + fun addAgent_persistsAgentList() = runTest { + val agent = AgentConfig( + id = "agent-1", + name = "Test Agent", + url = "https://example.agents.dev", + authMethod = AuthMethod.None(), + createdAt = Instant.fromEpochMilliseconds(0) + ) + + repository.addAgent(agent) + + assertEquals(listOf(agent), repository.agents.value) + assertTrue(settings.hasKey("agents")) + assertTrue(settings.getStringOrNull("agents")!!.contains("agent-1")) + } + + @Test + fun setActiveAgent_updatesStateAndSession() = runTest { + val agent = AgentConfig( + id = "agent-42", + name = "Active Agent", + url = "https://example.agents.dev", + authMethod = AuthMethod.None(), + createdAt = Instant.fromEpochMilliseconds(0) + ) + repository.addAgent(agent) + + repository.setActiveAgent(agent) + + val active = repository.activeAgent.value + assertNotNull(active) + assertEquals(agent.id, active.id) + assertNotNull(active.lastUsedAt) + + val session = repository.currentSession.value + assertNotNull(session) + assertEquals(agent.id, session.agentId) + + assertEquals(agent.id, settings.getStringOrNull("active_agent")) + assertTrue(settings.getStringOrNull("agents")!!.contains("lastUsedAt")) + } + + @Test + fun deleteAgent_removesAgentAndClearsActiveState() = runTest { + val agent = AgentConfig( + id = "agent-delete", + name = "Delete Me", + url = "https://example.agents.dev", + authMethod = AuthMethod.None(), + createdAt = Instant.fromEpochMilliseconds(0) + ) + repository.addAgent(agent) + repository.setActiveAgent(agent) + + repository.deleteAgent(agent.id) + + assertTrue(repository.agents.value.isEmpty()) + assertNull(repository.activeAgent.value) + assertNull(repository.currentSession.value) + assertFalse(settings.hasKey("active_agent")) + } +} diff --git a/sdks/community/kotlin/examples/chatapp-shared/src/commonTest/kotlin/com/agui/example/chatapp/data/AuthManagerTest.kt b/sdks/community/kotlin/examples/chatapp-shared/src/commonTest/kotlin/com/agui/example/chatapp/data/AuthManagerTest.kt new file mode 100644 index 000000000..b5a04d78f --- /dev/null +++ b/sdks/community/kotlin/examples/chatapp-shared/src/commonTest/kotlin/com/agui/example/chatapp/data/AuthManagerTest.kt @@ -0,0 +1,66 @@ +package com.agui.example.chatapp.data + +import com.agui.example.chatapp.data.auth.AuthManager +import com.agui.example.chatapp.data.auth.AuthProvider +import com.agui.example.chatapp.data.model.AuthMethod +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertFalse +import kotlinx.coroutines.test.runTest + +class AuthManagerTest { + + @Test + fun applyAuth_withApiKey_addsHeader() = runTest { + val manager = AuthManager() + val headers = mutableMapOf() + + manager.applyAuth(AuthMethod.ApiKey(key = "secret", headerName = "X-Secret"), headers) + + assertEquals("secret", headers["X-Secret"]) + } + + @Test + fun registerProvider_customProviderTakesPriority() = runTest { + val manager = AuthManager() + val headers = mutableMapOf() + val calls = mutableListOf() + + val customProvider = object : AuthProvider { + override fun canHandle(authMethod: AuthMethod): Boolean = authMethod is AuthMethod.ApiKey + + override suspend fun applyAuth(authMethod: AuthMethod, headers: MutableMap) { + calls += "apply" + headers["Authorization"] = "Custom ${ (authMethod as AuthMethod.ApiKey).key }" + } + + override suspend fun refreshAuth(authMethod: AuthMethod): AuthMethod { + calls += "refresh" + return authMethod + } + + override suspend fun isAuthValid(authMethod: AuthMethod): Boolean { + calls += "validate" + return true + } + } + + manager.registerProvider(customProvider) + manager.applyAuth(AuthMethod.ApiKey(key = "override"), headers) + + assertEquals("Custom override", headers["Authorization"]) + assertFalse(headers.containsKey("X-API-Key")) + assertEquals(listOf("apply"), calls) + } + + @Test + fun applyAuth_withoutProvider_throws() = runTest { + val manager = AuthManager() + val headers = mutableMapOf() + + assertFailsWith { + manager.applyAuth(AuthMethod.Custom(type = "unknown", config = emptyMap()), headers) + } + } +} diff --git a/sdks/community/kotlin/examples/chatapp-shared/src/commonTest/kotlin/com/agui/example/chatapp/testutil/FakeSettings.kt b/sdks/community/kotlin/examples/chatapp-shared/src/commonTest/kotlin/com/agui/example/chatapp/testutil/FakeSettings.kt new file mode 100644 index 000000000..f011bc80f --- /dev/null +++ b/sdks/community/kotlin/examples/chatapp-shared/src/commonTest/kotlin/com/agui/example/chatapp/testutil/FakeSettings.kt @@ -0,0 +1,74 @@ +package com.agui.example.chatapp.testutil + +import com.russhwolf.settings.Settings + +/** + * Simple in-memory [Settings] implementation for unit tests. + */ +class FakeSettings : Settings { + private val data = mutableMapOf() + + override val keys: Set + get() = data.keys + + override val size: Int + get() = data.size + + override fun clear() { + data.clear() + } + + override fun remove(key: String) { + data.remove(key) + } + + override fun hasKey(key: String): Boolean = data.containsKey(key) + + override fun putInt(key: String, value: Int) { + data[key] = value + } + + override fun getInt(key: String, defaultValue: Int): Int = data[key] as? Int ?: defaultValue + + override fun getIntOrNull(key: String): Int? = data[key] as? Int + + override fun putLong(key: String, value: Long) { + data[key] = value + } + + override fun getLong(key: String, defaultValue: Long): Long = data[key] as? Long ?: defaultValue + + override fun getLongOrNull(key: String): Long? = data[key] as? Long + + override fun putString(key: String, value: String) { + data[key] = value + } + + override fun getString(key: String, defaultValue: String): String = data[key] as? String ?: defaultValue + + override fun getStringOrNull(key: String): String? = data[key] as? String + + override fun putFloat(key: String, value: Float) { + data[key] = value + } + + override fun getFloat(key: String, defaultValue: Float): Float = data[key] as? Float ?: defaultValue + + override fun getFloatOrNull(key: String): Float? = data[key] as? Float + + override fun putDouble(key: String, value: Double) { + data[key] = value + } + + override fun getDouble(key: String, defaultValue: Double): Double = data[key] as? Double ?: defaultValue + + override fun getDoubleOrNull(key: String): Double? = data[key] as? Double + + override fun putBoolean(key: String, value: Boolean) { + data[key] = value + } + + override fun getBoolean(key: String, defaultValue: Boolean): Boolean = data[key] as? Boolean ?: defaultValue + + override fun getBooleanOrNull(key: String): Boolean? = data[key] as? Boolean +} diff --git a/sdks/community/kotlin/examples/chatapp/shared/src/desktopMain/kotlin/com/agui/example/chatapp/util/DesktopPlatform.kt b/sdks/community/kotlin/examples/chatapp-shared/src/desktopMain/kotlin/com/agui/example/chatapp/util/DesktopPlatform.kt similarity index 100% rename from sdks/community/kotlin/examples/chatapp/shared/src/desktopMain/kotlin/com/agui/example/chatapp/util/DesktopPlatform.kt rename to sdks/community/kotlin/examples/chatapp-shared/src/desktopMain/kotlin/com/agui/example/chatapp/util/DesktopPlatform.kt diff --git a/sdks/community/kotlin/examples/chatapp/shared/src/iosMain/kotlin/com/agui/example/chatapp/util/IosPlatform.kt b/sdks/community/kotlin/examples/chatapp-shared/src/iosMain/kotlin/com/agui/example/chatapp/util/IosPlatform.kt similarity index 100% rename from sdks/community/kotlin/examples/chatapp/shared/src/iosMain/kotlin/com/agui/example/chatapp/util/IosPlatform.kt rename to sdks/community/kotlin/examples/chatapp-shared/src/iosMain/kotlin/com/agui/example/chatapp/util/IosPlatform.kt diff --git a/sdks/community/kotlin/examples/chatapp-swiftui/README.md b/sdks/community/kotlin/examples/chatapp-swiftui/README.md new file mode 100644 index 000000000..da53dc977 --- /dev/null +++ b/sdks/community/kotlin/examples/chatapp-swiftui/README.md @@ -0,0 +1,80 @@ +# AG-UI Kotlin SDK SwiftUI Sample Client + +This sample demonstrates how to combine the core **AG-UI Kotlin libraries** with a **SwiftUI** interface that follows native iOS architecture guidelines. The multiplatform business logic now lives in the separate `../chatapp-shared` module, while this project adds a lightweight Kotlin bridge that exposes the shared flows to Swift. + +## Features + +- 📱 Native SwiftUI experience for iPhone and iPad +- 🤖 Real-time streaming chat backed by the Kotlin AG-UI client +- 🧑‍🤝‍🧑 Multi-agent management with persistent storage +- 🔐 Flexible authentication (None, API Key, Bearer, Basic, OAuth2, Custom) +- 🧰 Tool execution with inline confirmation prompts +- 🧵 Threaded conversations with ephemeral state indicators + +## Project Structure + +``` +chatapp-swiftui/ +├── iosApp/ # SwiftUI sources and XcodeGen project definition +├── shared/ # Kotlin bridge that wraps chatapp-shared for Swift consumption +├── build.gradle.kts +├── settings.gradle.kts +├── gradlew / gradlew.bat +└── README.md +``` +The Gradle build reuses `../chatapp-shared` via an included project reference. Kotlin UI code is implemented natively in Swift. + +## Prerequisites + +- macOS with Xcode 15+ +- Android Studio or IntelliJ IDEA (for Kotlin development) +- Kotlin 2.0+ toolchain (installed by Gradle wrapper) +- [XcodeGen](https://github.com/yonaskolb/XcodeGen) for generating the Xcode project + +## Getting Started + +1. **Build the Kotlin framework** + + ```bash + ./gradlew :shared:assembleXCFramework + ``` + + The task outputs `shared.xcframework` to `shared/build/XCFrameworks/release/` which the SwiftUI project consumes. + +2. **Generate the Xcode project** + + ```bash + cd iosApp + xcodegen generate + ``` + +3. **Open the project** + + Open `ChatAppSwiftUI.xcodeproj` in Xcode, select a simulator or device, and run the app. + +### Swift Package configuration + +The generated project includes a local Swift package that wraps the Kotlin framework. Re-run the Gradle build whenever Kotlin sources change to refresh the binary. + +## SwiftUI Architecture + +The Swift layer follows a unidirectional data flow: + +- `ChatAppStore` bridges Kotlin Flows to Combine-friendly `@Published` properties using the `ChatViewModelBridge` and `AgentRepositoryBridge` helpers exposed from the `shared` bridge module. +- SwiftUI views (`ChatView`, `AgentListView`, `AgentFormView`) subscribe to the store and dispatch user intents back to Kotlin for processing. +- Kotlin remains responsible for persistence, AG-UI protocol streaming, authentication, and tool coordination through the shared `ChatController`, leaving presentation to SwiftUI. + +## Testing & Verification + +- Kotlin unit tests remain available via the shared module: `./gradlew :shared:check` +- SwiftUI preview snapshots can be built within Xcode once the framework has been generated. + +## Troubleshooting + +- If the Swift compiler cannot locate `shared.xcframework`, ensure the Gradle build completed successfully and that Xcode is pointed at the release output directory. +- Authentication secrets are stored using the same secure storage backing as the Kotlin sample via `NSUserDefaults`. +- Tool confirmation dialogs appear as SwiftUI alerts, mirroring the Compose UX. + +## License + +This sample inherits the AG-UI repository license. diff --git a/sdks/community/kotlin/examples/chatapp-swiftui/build.gradle.kts b/sdks/community/kotlin/examples/chatapp-swiftui/build.gradle.kts new file mode 100644 index 000000000..5a5071510 --- /dev/null +++ b/sdks/community/kotlin/examples/chatapp-swiftui/build.gradle.kts @@ -0,0 +1,39 @@ +buildscript { + repositories { + google() + mavenCentral() + } + dependencies { + classpath("com.android.tools.build:gradle:8.12.0") + } +} + +plugins { + id("org.jetbrains.kotlinx.kover") version "0.7.6" + + kotlin("multiplatform") apply false + kotlin("plugin.serialization") apply false +} + +allprojects { + repositories { + google() + mavenCentral() + // Compose Multiplatform artifacts used by the shared module are hosted on JetBrains Space. + // We still depend on the shared chat module for protocol logic, so keep the Compose repo. + maven("https://maven.pkg.jetbrains.space/public/p/compose/dev") + mavenLocal() + } +} + +koverReport { + defaults { + verify { + onCheck = false + } + } +} + +tasks.register("clean", Delete::class) { + delete(rootProject.buildDir) +} diff --git a/sdks/community/kotlin/examples/chatapp-swiftui/gradle.properties b/sdks/community/kotlin/examples/chatapp-swiftui/gradle.properties new file mode 100644 index 000000000..6730aee8e --- /dev/null +++ b/sdks/community/kotlin/examples/chatapp-swiftui/gradle.properties @@ -0,0 +1,37 @@ +# Gradle properties +org.gradle.jvmargs=-Xmx4096m -Dfile.encoding=UTF-8 +org.gradle.parallel=true +org.gradle.caching=true +org.gradle.configuration-cache=true +org.gradle.console=plain + +# Kotlin +kotlin.code.style=official +kotlin.mpp.androidSourceSetLayoutVersion=2 +kotlin.mpp.applyDefaultHierarchyTemplate=false +kotlin.native.cacheKind=none +kotlin.mpp.enableCInteropCommonization=true + +# Compose +compose.experimental.jscanvas.enabled=true +compose.experimental.macos.enabled=true +compose.experimental.uikit.enabled=true + +# Android +android.useAndroidX=true +android.nonTransitiveRClass=true + +# iOS +xcodeproj=./iosApp + +# Shared core configuration +agui.enableAndroid=false + +# K2 Compiler Settings +kotlin.compiler.version=2.2.20 +kotlin.compiler.languageVersion=2.2 +kotlin.compiler.apiVersion=2.2 +kotlin.compiler.k2=true + +# Disable Kotlin Native bundling service +kotlin.native.disableCompilerDaemon=true diff --git a/sdks/community/kotlin/examples/chatapp-swiftui/gradle/libs.versions.toml b/sdks/community/kotlin/examples/chatapp-swiftui/gradle/libs.versions.toml new file mode 100644 index 000000000..87e16a53b --- /dev/null +++ b/sdks/community/kotlin/examples/chatapp-swiftui/gradle/libs.versions.toml @@ -0,0 +1,94 @@ +[versions] +activity-compose = "1.10.1" +agui-core = "0.2.3" +appcompat = "1.7.1" +core = "1.6.1" +core-ktx = "1.16.0" +junit = "4.13.2" +junit-version = "1.2.1" +kotlin = "2.2.20" +#Downgrading to avoid an R8 error +ktor = "3.1.3" +kotlinx-serialization = "1.8.1" +kotlinx-coroutines = "1.10.2" +kotlinx-datetime = "0.6.2" +android-gradle = "8.10.1" +kotlin-logging = "3.0.5" +logback-android = "3.0.0" +multiplatform-settings-coroutines = "1.2.0" +okio = "3.13.0" +runner = "1.6.2" +slf4j = "2.0.9" +voyager-navigator = "1.0.0" +markdown-renderer = "0.37.0" +compose = "1.9.1" +compose-material3 = "1.4.0" + +[libraries] +# Ktor +activity-compose = { module = "androidx.activity:activity-compose", version.ref = "activity-compose" } +agui-client = { module = "com.agui:kotlin-client", version.ref = "agui-core" } +agui-core = { module = "com.agui:kotlin-core", version.ref = "agui-core" } +agui-tools = { module = "com.agui:kotlin-tools", version.ref = "agui-core" } +androidx-ui-tooling = { module = "androidx.compose.ui:ui-tooling", version.ref = "compose" } +appcompat = { module = "androidx.appcompat:appcompat", version.ref = "appcompat" } +core = { module = "androidx.test:core", version.ref = "core" } +core-ktx = { module = "androidx.core:core-ktx", version.ref = "core-ktx" } +ext-junit = { module = "androidx.test.ext:junit", version.ref = "junit-version" } +junit = { module = "junit:junit", version.ref = "junit" } +ktor-client-core = { module = "io.ktor:ktor-client-core", version.ref = "ktor" } +ktor-client-content-negotiation = { module = "io.ktor:ktor-client-content-negotiation", version.ref = "ktor" } +ktor-serialization-kotlinx-json = { module = "io.ktor:ktor-serialization-kotlinx-json", version.ref = "ktor" } +ktor-client-logging = { module = "io.ktor:ktor-client-logging", version.ref = "ktor" } +ktor-client-android = { module = "io.ktor:ktor-client-android", version.ref = "ktor" } +ktor-client-darwin = { module = "io.ktor:ktor-client-darwin", version.ref = "ktor" } +ktor-client-java = { module = "io.ktor:ktor-client-java", version.ref = "ktor" } +ktor-client-cio = { module = "io.ktor:ktor-client-cio", version.ref = "ktor" } +ktor-client-mock = { module = "io.ktor:ktor-client-mock", version.ref = "ktor" } + +# Kotlinx +kotlinx-coroutines-core = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-core", version.ref = "kotlinx-coroutines" } +kotlinx-coroutines-test = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-test", version.ref = "kotlinx-coroutines" } +kotlinx-serialization-json = { module = "org.jetbrains.kotlinx:kotlinx-serialization-json", version.ref = "kotlinx-serialization" } +kotlinx-datetime = { module = "org.jetbrains.kotlinx:kotlinx-datetime", version.ref = "kotlinx-datetime" } + +# Logging +kotlin-logging = { module = "io.github.microutils:kotlin-logging", version.ref = "kotlin-logging" } +logback-android = { module = "com.github.tony19:logback-android", version.ref = "logback-android" } +multiplatform-settings = { module = "com.russhwolf:multiplatform-settings", version.ref = "multiplatform-settings-coroutines" } +multiplatform-settings-coroutines = { module = "com.russhwolf:multiplatform-settings-coroutines", version.ref = "multiplatform-settings-coroutines" } +okio = { module = "com.squareup.okio:okio", version.ref = "okio" } +runner = { module = "androidx.test:runner", version.ref = "runner" } +slf4j-simple = { module = "org.slf4j:slf4j-simple", version.ref = "slf4j" } +androidx-compose-runtime = { module = "androidx.compose.runtime:runtime", version.ref = "compose" } +androidx-compose-runtime-saveable = { module = "androidx.compose.runtime:runtime-saveable", version.ref = "compose" } +androidx-compose-foundation = { module = "androidx.compose.foundation:foundation", version.ref = "compose" } +androidx-compose-material = { module = "androidx.compose.material:material", version.ref = "compose" } +androidx-compose-material3 = { module = "androidx.compose.material3:material3", version.ref = "compose-material3" } +androidx-compose-ui = { module = "androidx.compose.ui:ui", version.ref = "compose" } +androidx-compose-ui-tooling-preview = { module = "androidx.compose.ui:ui-tooling-preview", version.ref = "compose" } +androidx-compose-ui-test-manifest = { module = "androidx.compose.ui:ui-test-manifest", version.ref = "compose" } +ui-test-junit4 = { module = "androidx.compose.ui:ui-test-junit4", version.ref = "compose" } +voyager-navigator = { module = "cafe.adriel.voyager:voyager-navigator", version.ref = "voyager-navigator" } +voyager-screenmodel = { module = "cafe.adriel.voyager:voyager-screenmodel", version.ref = "voyager-navigator" } +voyager-transitions = { module = "cafe.adriel.voyager:voyager-transitions", version.ref = "voyager-navigator" } +markdown-renderer-m3 = { module = "com.mikepenz:multiplatform-markdown-renderer-m3", version.ref = "markdown-renderer" } + +[plugins] +kotlin-multiplatform = { id = "org.jetbrains.kotlin.multiplatform", version.ref = "kotlin" } +kotlin-serialization = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "kotlin" } +android-library = { id = "com.android.library", version.ref = "android-gradle" } + +[bundles] +ktor-common = [ + "ktor-client-core", + "ktor-client-content-negotiation", + "ktor-serialization-kotlinx-json", + "ktor-client-logging" +] + +kotlinx-common = [ + "kotlinx-coroutines-core", + "kotlinx-serialization-json", + "kotlinx-datetime" +] diff --git a/sdks/community/kotlin/examples/chatapp-swiftui/gradle/wrapper/gradle-wrapper.jar b/sdks/community/kotlin/examples/chatapp-swiftui/gradle/wrapper/gradle-wrapper.jar new file mode 100644 index 000000000..8bdaf60c7 Binary files /dev/null and b/sdks/community/kotlin/examples/chatapp-swiftui/gradle/wrapper/gradle-wrapper.jar differ diff --git a/sdks/community/kotlin/examples/chatapp-swiftui/gradle/wrapper/gradle-wrapper.properties b/sdks/community/kotlin/examples/chatapp-swiftui/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 000000000..ca025c83a --- /dev/null +++ b/sdks/community/kotlin/examples/chatapp-swiftui/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,7 @@ +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-8.14-bin.zip +networkTimeout=10000 +validateDistributionUrl=true +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists diff --git a/sdks/community/kotlin/examples/chatapp-swiftui/gradlew b/sdks/community/kotlin/examples/chatapp-swiftui/gradlew new file mode 100755 index 000000000..ef07e0162 --- /dev/null +++ b/sdks/community/kotlin/examples/chatapp-swiftui/gradlew @@ -0,0 +1,251 @@ +#!/bin/sh + +# +# Copyright © 2015 the original authors. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# +# SPDX-License-Identifier: Apache-2.0 +# + +############################################################################## +# +# Gradle start up script for POSIX generated by Gradle. +# +# Important for running: +# +# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is +# noncompliant, but you have some other compliant shell such as ksh or +# bash, then to run this script, type that shell name before the whole +# command line, like: +# +# ksh Gradle +# +# Busybox and similar reduced shells will NOT work, because this script +# requires all of these POSIX shell features: +# * functions; +# * expansions «$var», «${var}», «${var:-default}», «${var+SET}», +# «${var#prefix}», «${var%suffix}», and «$( cmd )»; +# * compound commands having a testable exit status, especially «case»; +# * various built-in commands including «command», «set», and «ulimit». +# +# Important for patching: +# +# (2) This script targets any POSIX shell, so it avoids extensions provided +# by Bash, Ksh, etc; in particular arrays are avoided. +# +# The "traditional" practice of packing multiple parameters into a +# space-separated string is a well documented source of bugs and security +# problems, so this is (mostly) avoided, by progressively accumulating +# options in "$@", and eventually passing that to Java. +# +# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS, +# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly; +# see the in-line comments for details. +# +# There are tweaks for specific operating systems such as AIX, CygWin, +# Darwin, MinGW, and NonStop. +# +# (3) This script is generated from the Groovy template +# https://github.com/gradle/gradle/blob/HEAD/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt +# within the Gradle project. +# +# You can find Gradle at https://github.com/gradle/gradle/. +# +############################################################################## + +# Attempt to set APP_HOME + +# Resolve links: $0 may be a link +app_path=$0 + +# Need this for daisy-chained symlinks. +while + APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path + [ -h "$app_path" ] +do + ls=$( ls -ld "$app_path" ) + link=${ls#*' -> '} + case $link in #( + /*) app_path=$link ;; #( + *) app_path=$APP_HOME$link ;; + esac +done + +# This is normally unused +# shellcheck disable=SC2034 +APP_BASE_NAME=${0##*/} +# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036) +APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || exit + +# Use the maximum available, or set MAX_FD != -1 to use that value. +MAX_FD=maximum + +warn () { + echo "$*" +} >&2 + +die () { + echo + echo "$*" + echo + exit 1 +} >&2 + +# OS specific support (must be 'true' or 'false'). +cygwin=false +msys=false +darwin=false +nonstop=false +case "$( uname )" in #( + CYGWIN* ) cygwin=true ;; #( + Darwin* ) darwin=true ;; #( + MSYS* | MINGW* ) msys=true ;; #( + NONSTOP* ) nonstop=true ;; +esac + +CLASSPATH="\\\"\\\"" + + +# Determine the Java command to use to start the JVM. +if [ -n "$JAVA_HOME" ] ; then + if [ -x "$JAVA_HOME/jre/sh/java" ] ; then + # IBM's JDK on AIX uses strange locations for the executables + JAVACMD=$JAVA_HOME/jre/sh/java + else + JAVACMD=$JAVA_HOME/bin/java + fi + if [ ! -x "$JAVACMD" ] ; then + die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +else + JAVACMD=java + if ! command -v java >/dev/null 2>&1 + then + die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +fi + +# Increase the maximum file descriptors if we can. +if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then + case $MAX_FD in #( + max*) + # In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + MAX_FD=$( ulimit -H -n ) || + warn "Could not query maximum file descriptor limit" + esac + case $MAX_FD in #( + '' | soft) :;; #( + *) + # In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + ulimit -n "$MAX_FD" || + warn "Could not set maximum file descriptor limit to $MAX_FD" + esac +fi + +# Collect all arguments for the java command, stacking in reverse order: +# * args from the command line +# * the main class name +# * -classpath +# * -D...appname settings +# * --module-path (only if needed) +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables. + +# For Cygwin or MSYS, switch paths to Windows format before running java +if "$cygwin" || "$msys" ; then + APP_HOME=$( cygpath --path --mixed "$APP_HOME" ) + CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" ) + + JAVACMD=$( cygpath --unix "$JAVACMD" ) + + # Now convert the arguments - kludge to limit ourselves to /bin/sh + for arg do + if + case $arg in #( + -*) false ;; # don't mess with options #( + /?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath + [ -e "$t" ] ;; #( + *) false ;; + esac + then + arg=$( cygpath --path --ignore --mixed "$arg" ) + fi + # Roll the args list around exactly as many times as the number of + # args, so each arg winds up back in the position where it started, but + # possibly modified. + # + # NB: a `for` loop captures its iteration list before it begins, so + # changing the positional parameters here affects neither the number of + # iterations, nor the values presented in `arg`. + shift # remove old arg + set -- "$@" "$arg" # push replacement arg + done +fi + + +# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' + +# Collect all arguments for the java command: +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments, +# and any embedded shellness will be escaped. +# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be +# treated as '${Hostname}' itself on the command line. + +set -- \ + "-Dorg.gradle.appname=$APP_BASE_NAME" \ + -classpath "$CLASSPATH" \ + -jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \ + "$@" + +# Stop when "xargs" is not available. +if ! command -v xargs >/dev/null 2>&1 +then + die "xargs is not available" +fi + +# Use "xargs" to parse quoted args. +# +# With -n1 it outputs one arg per line, with the quotes and backslashes removed. +# +# In Bash we could simply go: +# +# readarray ARGS < <( xargs -n1 <<<"$var" ) && +# set -- "${ARGS[@]}" "$@" +# +# but POSIX shell has neither arrays nor command substitution, so instead we +# post-process each arg (as a line of input to sed) to backslash-escape any +# character that might be a shell metacharacter, then use eval to reverse +# that process (while maintaining the separation between arguments), and wrap +# the whole thing up as a single "set" statement. +# +# This will of course break if any of these variables contains a newline or +# an unmatched quote. +# + +eval "set -- $( + printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" | + xargs -n1 | + sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' | + tr '\n' ' ' + )" '"$@"' + +exec "$JAVACMD" "$@" diff --git a/sdks/community/kotlin/examples/chatapp-swiftui/gradlew.bat b/sdks/community/kotlin/examples/chatapp-swiftui/gradlew.bat new file mode 100644 index 000000000..5eed7ee84 --- /dev/null +++ b/sdks/community/kotlin/examples/chatapp-swiftui/gradlew.bat @@ -0,0 +1,94 @@ +@rem +@rem Copyright 2015 the original author or authors. +@rem +@rem Licensed under the Apache License, Version 2.0 (the "License"); +@rem you may not use this file except in compliance with the License. +@rem You may obtain a copy of the License at +@rem +@rem https://www.apache.org/licenses/LICENSE-2.0 +@rem +@rem Unless required by applicable law or agreed to in writing, software +@rem distributed under the License is distributed on an "AS IS" BASIS, +@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +@rem See the License for the specific language governing permissions and +@rem limitations under the License. +@rem +@rem SPDX-License-Identifier: Apache-2.0 +@rem + +@if "%DEBUG%"=="" @echo off +@rem ########################################################################## +@rem +@rem Gradle startup script for Windows +@rem +@rem ########################################################################## + +@rem Set local scope for the variables with windows NT shell +if "%OS%"=="Windows_NT" setlocal + +set DIRNAME=%~dp0 +if "%DIRNAME%"=="" set DIRNAME=. +@rem This is normally unused +set APP_BASE_NAME=%~n0 +set APP_HOME=%DIRNAME% + +@rem Resolve any "." and ".." in APP_HOME to make it shorter. +for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi + +@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" + +@rem Find java.exe +if defined JAVA_HOME goto findJavaFromJavaHome + +set JAVA_EXE=java.exe +%JAVA_EXE% -version >NUL 2>&1 +if %ERRORLEVEL% equ 0 goto execute + +echo. 1>&2 +echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 + +goto fail + +:findJavaFromJavaHome +set JAVA_HOME=%JAVA_HOME:"=% +set JAVA_EXE=%JAVA_HOME%/bin/java.exe + +if exist "%JAVA_EXE%" goto execute + +echo. 1>&2 +echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 + +goto fail + +:execute +@rem Setup the command line + +set CLASSPATH= + + +@rem Execute Gradle +"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %* + +:end +@rem End local scope for the variables with windows NT shell +if %ERRORLEVEL% equ 0 goto mainEnd + +:fail +rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of +rem the _cmd.exe /c_ return code! +set EXIT_CODE=%ERRORLEVEL% +if %EXIT_CODE% equ 0 set EXIT_CODE=1 +if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE% +exit /b %EXIT_CODE% + +:mainEnd +if "%OS%"=="Windows_NT" endlocal + +:omega diff --git a/sdks/community/kotlin/examples/chatapp-swiftui/iosApp/ChatAppSwiftUI.xcodeproj/project.pbxproj b/sdks/community/kotlin/examples/chatapp-swiftui/iosApp/ChatAppSwiftUI.xcodeproj/project.pbxproj new file mode 100644 index 000000000..c9503218f --- /dev/null +++ b/sdks/community/kotlin/examples/chatapp-swiftui/iosApp/ChatAppSwiftUI.xcodeproj/project.pbxproj @@ -0,0 +1,405 @@ +// !$*UTF8*$! +{ + archiveVersion = 1; + classes = { + }; + objectVersion = 63; + objects = { + +/* Begin PBXBuildFile section */ + 109542E8CEB1207D09FD0829 /* shared.xcframework in Embed Frameworks */ = {isa = PBXBuildFile; fileRef = 38D17DED6FE7A7AB5B29A1D1 /* shared.xcframework */; settings = {ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy, ); }; }; + 34BCED3877BCC3CC8513A8A2 /* shared.xcframework in Frameworks */ = {isa = PBXBuildFile; fileRef = 38D17DED6FE7A7AB5B29A1D1 /* shared.xcframework */; }; + 48C4363CB57504E8F370799C /* ChatAppStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 171E9C0458435B14109212D5 /* ChatAppStore.swift */; }; + 507324CE9017A9351BB828D4 /* RootView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 199F3EB1B4EA1AFE202643DB /* RootView.swift */; }; + 598B83E1EF513C870E5C1FC5 /* ChatView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 37A513A00CF92688E27BB12E /* ChatView.swift */; }; + 813698874EF8683FD183766A /* ChatAppSwiftUIApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1D2FEC23EEBC2439F1CA6D45 /* ChatAppSwiftUIApp.swift */; }; + 8C57C245558535DF5F6B53F2 /* MarkdownUI in Frameworks */ = {isa = PBXBuildFile; productRef = 5BB892AC733C98BAD184A4BE /* MarkdownUI */; }; + A8055047E1F567F04BFC1CB6 /* AgentFormView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E8169B41E9F48D074B3EC374 /* AgentFormView.swift */; }; + DF4BFFFD64ED916B84C56E73 /* AgentListView.swift in Sources */ = {isa = PBXBuildFile; fileRef = AB1A92CE7AE1057C9C944842 /* AgentListView.swift */; }; +/* End PBXBuildFile section */ + +/* Begin PBXCopyFilesBuildPhase section */ + 4B76C59AE89DB8D57877544F /* Embed Frameworks */ = { + isa = PBXCopyFilesBuildPhase; + buildActionMask = 2147483647; + dstPath = ""; + dstSubfolderSpec = 10; + files = ( + 109542E8CEB1207D09FD0829 /* shared.xcframework in Embed Frameworks */, + ); + name = "Embed Frameworks"; + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXCopyFilesBuildPhase section */ + +/* Begin PBXFileReference section */ + 171E9C0458435B14109212D5 /* ChatAppStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatAppStore.swift; sourceTree = ""; }; + 199F3EB1B4EA1AFE202643DB /* RootView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RootView.swift; sourceTree = ""; }; + 1D2FEC23EEBC2439F1CA6D45 /* ChatAppSwiftUIApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatAppSwiftUIApp.swift; sourceTree = ""; }; + 37A513A00CF92688E27BB12E /* ChatView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatView.swift; sourceTree = ""; }; + 38D17DED6FE7A7AB5B29A1D1 /* shared.xcframework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcframework; name = shared.xcframework; path = ../shared/build/XCFrameworks/release/shared.xcframework; sourceTree = ""; }; + AB1A92CE7AE1057C9C944842 /* AgentListView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AgentListView.swift; sourceTree = ""; }; + E8169B41E9F48D074B3EC374 /* AgentFormView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AgentFormView.swift; sourceTree = ""; }; + E86132EDA7173F80E815D058 /* ChatAppSwiftUI.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = ChatAppSwiftUI.app; sourceTree = BUILT_PRODUCTS_DIR; }; +/* End PBXFileReference section */ + +/* Begin PBXFrameworksBuildPhase section */ + 86F61A8B991D6AF50DA025BA /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + 8C57C245558535DF5F6B53F2 /* MarkdownUI in Frameworks */, + 34BCED3877BCC3CC8513A8A2 /* shared.xcframework in Frameworks */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXFrameworksBuildPhase section */ + +/* Begin PBXGroup section */ + 2788C1E37BD0ABA1D739806B /* Store */ = { + isa = PBXGroup; + children = ( + 171E9C0458435B14109212D5 /* ChatAppStore.swift */, + ); + path = Store; + sourceTree = ""; + }; + 2F1CD112648A55FD5602E8FB /* Products */ = { + isa = PBXGroup; + children = ( + E86132EDA7173F80E815D058 /* ChatAppSwiftUI.app */, + ); + name = Products; + sourceTree = ""; + }; + 4AC6D8B2137CFE570A81F442 /* Sources */ = { + isa = PBXGroup; + children = ( + 5A9BDD91B299A8B86DF8A009 /* App */, + 2788C1E37BD0ABA1D739806B /* Store */, + 7B41EF088960D0BF92E6D08C /* Views */, + ); + path = Sources; + sourceTree = ""; + }; + 5A25C2092CF32DD3532BFFE6 /* Frameworks */ = { + isa = PBXGroup; + children = ( + 38D17DED6FE7A7AB5B29A1D1 /* shared.xcframework */, + ); + name = Frameworks; + sourceTree = ""; + }; + 5A9BDD91B299A8B86DF8A009 /* App */ = { + isa = PBXGroup; + children = ( + 1D2FEC23EEBC2439F1CA6D45 /* ChatAppSwiftUIApp.swift */, + ); + path = App; + sourceTree = ""; + }; + 5EA0BDF5DE55A2A4CBA9998E = { + isa = PBXGroup; + children = ( + 4AC6D8B2137CFE570A81F442 /* Sources */, + 5A25C2092CF32DD3532BFFE6 /* Frameworks */, + 2F1CD112648A55FD5602E8FB /* Products */, + ); + sourceTree = ""; + }; + 7B41EF088960D0BF92E6D08C /* Views */ = { + isa = PBXGroup; + children = ( + E8169B41E9F48D074B3EC374 /* AgentFormView.swift */, + 37A513A00CF92688E27BB12E /* ChatView.swift */, + 199F3EB1B4EA1AFE202643DB /* RootView.swift */, + 933E1664F6B5C0B72E6F1CD9 /* Components */, + ); + path = Views; + sourceTree = ""; + }; + 933E1664F6B5C0B72E6F1CD9 /* Components */ = { + isa = PBXGroup; + children = ( + AB1A92CE7AE1057C9C944842 /* AgentListView.swift */, + ); + path = Components; + sourceTree = ""; + }; +/* End PBXGroup section */ + +/* Begin PBXNativeTarget section */ + 58FCD1206C8ACCA77BE14268 /* ChatAppSwiftUI */ = { + isa = PBXNativeTarget; + buildConfigurationList = 2B515E4F8376311C12D60948 /* Build configuration list for PBXNativeTarget "ChatAppSwiftUI" */; + buildPhases = ( + 7B75A59E3A66C8F1601C81A2 /* Sources */, + 86F61A8B991D6AF50DA025BA /* Frameworks */, + 4B76C59AE89DB8D57877544F /* Embed Frameworks */, + ); + buildRules = ( + ); + dependencies = ( + ); + name = ChatAppSwiftUI; + packageProductDependencies = ( + 5BB892AC733C98BAD184A4BE /* MarkdownUI */, + ); + productName = ChatAppSwiftUI; + productReference = E86132EDA7173F80E815D058 /* ChatAppSwiftUI.app */; + productType = "com.apple.product-type.application"; + }; +/* End PBXNativeTarget section */ + +/* Begin PBXProject section */ + 1210CA39C48FEE65618D0569 /* Project object */ = { + isa = PBXProject; + attributes = { + BuildIndependentTargetsInParallel = YES; + LastUpgradeCheck = 1430; + }; + buildConfigurationList = 6438DE8047FDF8C2FBD6BF9A /* Build configuration list for PBXProject "ChatAppSwiftUI" */; + compatibilityVersion = "Xcode 14.0"; + developmentRegion = en; + hasScannedForEncodings = 0; + knownRegions = ( + Base, + en, + ); + mainGroup = 5EA0BDF5DE55A2A4CBA9998E; + minimizedProjectReferenceProxies = 1; + packageReferences = ( + E2DD793E13F15275DB01F342 /* XCRemoteSwiftPackageReference "MarkdownUI" */, + ); + projectDirPath = ""; + projectRoot = ""; + targets = ( + 58FCD1206C8ACCA77BE14268 /* ChatAppSwiftUI */, + ); + }; +/* End PBXProject section */ + +/* Begin PBXSourcesBuildPhase section */ + 7B75A59E3A66C8F1601C81A2 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + A8055047E1F567F04BFC1CB6 /* AgentFormView.swift in Sources */, + DF4BFFFD64ED916B84C56E73 /* AgentListView.swift in Sources */, + 48C4363CB57504E8F370799C /* ChatAppStore.swift in Sources */, + 813698874EF8683FD183766A /* ChatAppSwiftUIApp.swift in Sources */, + 598B83E1EF513C870E5C1FC5 /* ChatView.swift in Sources */, + 507324CE9017A9351BB828D4 /* RootView.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXSourcesBuildPhase section */ + +/* Begin XCBuildConfiguration section */ + 756A888DC67DCEAC6E1715BE /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CODE_SIGN_IDENTITY = "iPhone Developer"; + DEVELOPMENT_TEAM = 96FAHJ5ZD7; + FRAMEWORK_SEARCH_PATHS = ( + "$(inherited)", + "\"../shared/build/XCFrameworks/release\"", + ); + INFOPLIST_FILE = Resources/Info.plist; + IPHONEOS_DEPLOYMENT_TARGET = 17.0; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + PRODUCT_BUNDLE_IDENTIFIER = com.agui.example.chatappswiftui; + SDKROOT = iphoneos; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = Release; + }; + 9E35932EED6D808E6215E647 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++14"; + CLANG_CXX_LIBRARY = "libc++"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_ENABLE_OBJC_WEAK = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; + ENABLE_NS_ASSERTIONS = NO; + ENABLE_STRICT_OBJC_MSGSEND = YES; + GCC_C_LANGUAGE_STANDARD = gnu11; + GCC_NO_COMMON_BLOCKS = YES; + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 17.0; + MTL_ENABLE_DEBUG_INFO = NO; + MTL_FAST_MATH = YES; + PRODUCT_NAME = "$(TARGET_NAME)"; + SDKROOT = iphoneos; + SWIFT_COMPILATION_MODE = wholemodule; + SWIFT_OPTIMIZATION_LEVEL = "-O"; + SWIFT_VERSION = 5.9; + }; + name = Release; + }; + AA98E64F6D2D57ACCB7F5C1C /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++14"; + CLANG_CXX_LIBRARY = "libc++"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_ENABLE_OBJC_WEAK = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = dwarf; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_TESTABILITY = YES; + GCC_C_LANGUAGE_STANDARD = gnu11; + GCC_DYNAMIC_NO_PIC = NO; + GCC_NO_COMMON_BLOCKS = YES; + GCC_OPTIMIZATION_LEVEL = 0; + GCC_PREPROCESSOR_DEFINITIONS = ( + "$(inherited)", + "DEBUG=1", + ); + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 17.0; + MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE; + MTL_FAST_MATH = YES; + ONLY_ACTIVE_ARCH = YES; + PRODUCT_NAME = "$(TARGET_NAME)"; + SDKROOT = iphoneos; + SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG; + SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + SWIFT_VERSION = 5.9; + }; + name = Debug; + }; + E52D15C66D5E4E76DF2360FA /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CODE_SIGN_IDENTITY = "iPhone Developer"; + DEVELOPMENT_TEAM = 96FAHJ5ZD7; + FRAMEWORK_SEARCH_PATHS = ( + "$(inherited)", + "\"../shared/build/XCFrameworks/release\"", + ); + INFOPLIST_FILE = Resources/Info.plist; + IPHONEOS_DEPLOYMENT_TARGET = 17.0; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + PRODUCT_BUNDLE_IDENTIFIER = com.agui.example.chatappswiftui; + SDKROOT = iphoneos; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = Debug; + }; +/* End XCBuildConfiguration section */ + +/* Begin XCConfigurationList section */ + 2B515E4F8376311C12D60948 /* Build configuration list for PBXNativeTarget "ChatAppSwiftUI" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + E52D15C66D5E4E76DF2360FA /* Debug */, + 756A888DC67DCEAC6E1715BE /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Debug; + }; + 6438DE8047FDF8C2FBD6BF9A /* Build configuration list for PBXProject "ChatAppSwiftUI" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + AA98E64F6D2D57ACCB7F5C1C /* Debug */, + 9E35932EED6D808E6215E647 /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Debug; + }; +/* End XCConfigurationList section */ + +/* Begin XCRemoteSwiftPackageReference section */ + E2DD793E13F15275DB01F342 /* XCRemoteSwiftPackageReference "MarkdownUI" */ = { + isa = XCRemoteSwiftPackageReference; + repositoryURL = "https://github.com/gonzalezreal/MarkdownUI.git"; + requirement = { + kind = upToNextMajorVersion; + minimumVersion = 2.4.1; + }; + }; +/* End XCRemoteSwiftPackageReference section */ + +/* Begin XCSwiftPackageProductDependency section */ + 5BB892AC733C98BAD184A4BE /* MarkdownUI */ = { + isa = XCSwiftPackageProductDependency; + package = E2DD793E13F15275DB01F342 /* XCRemoteSwiftPackageReference "MarkdownUI" */; + productName = MarkdownUI; + }; +/* End XCSwiftPackageProductDependency section */ + }; + rootObject = 1210CA39C48FEE65618D0569 /* Project object */; +} diff --git a/sdks/community/kotlin/examples/chatapp-swiftui/iosApp/ChatAppSwiftUI.xcodeproj/project.xcworkspace/contents.xcworkspacedata b/sdks/community/kotlin/examples/chatapp-swiftui/iosApp/ChatAppSwiftUI.xcodeproj/project.xcworkspace/contents.xcworkspacedata new file mode 100644 index 000000000..919434a62 --- /dev/null +++ b/sdks/community/kotlin/examples/chatapp-swiftui/iosApp/ChatAppSwiftUI.xcodeproj/project.xcworkspace/contents.xcworkspacedata @@ -0,0 +1,7 @@ + + + + + diff --git a/sdks/community/kotlin/examples/chatapp-swiftui/iosApp/ChatAppSwiftUI.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/sdks/community/kotlin/examples/chatapp-swiftui/iosApp/ChatAppSwiftUI.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved new file mode 100644 index 000000000..f061ff129 --- /dev/null +++ b/sdks/community/kotlin/examples/chatapp-swiftui/iosApp/ChatAppSwiftUI.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -0,0 +1,33 @@ +{ + "originHash" : "6b700d656a9467e1b1c7a8728e9567d912ebf702943807c297397498a5527fef", + "pins" : [ + { + "identity" : "markdownui", + "kind" : "remoteSourceControl", + "location" : "https://github.com/gonzalezreal/MarkdownUI.git", + "state" : { + "revision" : "5f613358148239d0292c0cef674a3c2314737f9e", + "version" : "2.4.1" + } + }, + { + "identity" : "networkimage", + "kind" : "remoteSourceControl", + "location" : "https://github.com/gonzalezreal/NetworkImage", + "state" : { + "revision" : "2849f5323265386e200484b0d0f896e73c3411b9", + "version" : "6.0.1" + } + }, + { + "identity" : "swift-cmark", + "kind" : "remoteSourceControl", + "location" : "https://github.com/swiftlang/swift-cmark.git", + "state" : { + "revision" : "b97d09472e847a416629f026eceae0e2afcfad65", + "version" : "0.7.0" + } + } + ], + "version" : 3 +} diff --git a/sdks/community/kotlin/examples/chatapp-swiftui/iosApp/Resources/Assets.xcassets/AppIcon.appiconset/Contents.json b/sdks/community/kotlin/examples/chatapp-swiftui/iosApp/Resources/Assets.xcassets/AppIcon.appiconset/Contents.json new file mode 100644 index 000000000..505494027 --- /dev/null +++ b/sdks/community/kotlin/examples/chatapp-swiftui/iosApp/Resources/Assets.xcassets/AppIcon.appiconset/Contents.json @@ -0,0 +1,14 @@ +{ + "images" : [ + { + "idiom" : "universal", + "platform" : "ios", + "size" : "1024x1024", + "filename" : "AppIcon.png" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/sdks/community/kotlin/examples/chatapp-swiftui/iosApp/Resources/Assets.xcassets/Contents.json b/sdks/community/kotlin/examples/chatapp-swiftui/iosApp/Resources/Assets.xcassets/Contents.json new file mode 100644 index 000000000..73c00596a --- /dev/null +++ b/sdks/community/kotlin/examples/chatapp-swiftui/iosApp/Resources/Assets.xcassets/Contents.json @@ -0,0 +1,6 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/sdks/community/kotlin/examples/chatapp-swiftui/iosApp/Resources/Info.plist b/sdks/community/kotlin/examples/chatapp-swiftui/iosApp/Resources/Info.plist new file mode 100644 index 000000000..f9ac32dd8 --- /dev/null +++ b/sdks/community/kotlin/examples/chatapp-swiftui/iosApp/Resources/Info.plist @@ -0,0 +1,28 @@ + + + + + CFBundleDevelopmentRegion + $(DEVELOPMENT_LANGUAGE) + CFBundleExecutable + $(EXECUTABLE_NAME) + CFBundleIdentifier + $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + $(PRODUCT_NAME) + CFBundlePackageType + APPL + CFBundleShortVersionString + 1.0 + CFBundleVersion + 1 + UIApplicationSupportsIndirectInputEvents + + UILaunchStoryboardName + LaunchScreen + UIUserInterfaceStyle + Automatic + + diff --git a/sdks/community/kotlin/examples/chatapp-swiftui/iosApp/Resources/LaunchScreen.storyboard b/sdks/community/kotlin/examples/chatapp-swiftui/iosApp/Resources/LaunchScreen.storyboard new file mode 100644 index 000000000..4caf676ef --- /dev/null +++ b/sdks/community/kotlin/examples/chatapp-swiftui/iosApp/Resources/LaunchScreen.storyboard @@ -0,0 +1,26 @@ + + + + + + + + + + + + + + + + + + + + + diff --git a/sdks/community/kotlin/examples/chatapp-swiftui/iosApp/Sources/App/ChatAppSwiftUIApp.swift b/sdks/community/kotlin/examples/chatapp-swiftui/iosApp/Sources/App/ChatAppSwiftUIApp.swift new file mode 100644 index 000000000..b95aa8e62 --- /dev/null +++ b/sdks/community/kotlin/examples/chatapp-swiftui/iosApp/Sources/App/ChatAppSwiftUIApp.swift @@ -0,0 +1,14 @@ +import SwiftUI +import shared + +@main +struct ChatAppSwiftUIApp: App { + @StateObject private var store = ChatAppStore() + + var body: some Scene { + WindowGroup { + RootView() + .environmentObject(store) + } + } +} diff --git a/sdks/community/kotlin/examples/chatapp-swiftui/iosApp/Sources/Store/ChatAppStore.swift b/sdks/community/kotlin/examples/chatapp-swiftui/iosApp/Sources/Store/ChatAppStore.swift new file mode 100644 index 000000000..4a58644b3 --- /dev/null +++ b/sdks/community/kotlin/examples/chatapp-swiftui/iosApp/Sources/Store/ChatAppStore.swift @@ -0,0 +1,337 @@ +import Foundation +import Combine +import SwiftUI +import shared + +final class ChatAppStore: ObservableObject { + @Published private(set) var chatState: ChatStateSnapshot + @Published private(set) var agents: [AgentSnapshot] + @Published var selectedAgentId: String? + @Published var formMode: AgentFormMode? + @Published var draft: AgentDraft = AgentDraft() + @Published var isPerformingAgentMutation = false + @Published var repositoryError: String? + + private let chatBridge: ChatViewModelBridge + private let repositoryBridge: AgentRepositoryBridge + + private var chatSubscription: FlowSubscription? + private var agentsSubscription: FlowSubscription? + private var activeAgentSubscription: FlowSubscription? + + init(chatBridge: ChatViewModelBridge = ChatViewModelBridge(), + repositoryBridge: AgentRepositoryBridge = AgentRepositoryBridge()) { + self.chatBridge = chatBridge + self.repositoryBridge = repositoryBridge + self.chatState = chatBridge.currentState() + self.agents = repositoryBridge.currentAgents() + self.selectedAgentId = repositoryBridge.currentActiveAgent()?.id ?? chatState.activeAgent?.id + subscribe() + } + + deinit { + chatSubscription?.cancel() + agentsSubscription?.cancel() + activeAgentSubscription?.cancel() + chatBridge.close() + repositoryBridge.close() + } + + private func subscribe() { + chatSubscription = chatBridge.observeState { [weak self] snapshot in + guard let self else { return } + self.chatState = snapshot + if let activeId = snapshot.activeAgent?.id { + self.selectedAgentId = activeId + } + } + + agentsSubscription = repositoryBridge.observeAgents { [weak self] agents in + self?.agents = agents + } + + activeAgentSubscription = repositoryBridge.observeActiveAgent { [weak self] agent in + self?.selectedAgentId = agent?.id + } + } + + // MARK: - Chat actions + + func sendMessage(_ text: String) { + chatBridge.sendMessage(content: text) + } + + func cancelStreaming() { + chatBridge.cancelCurrentOperation() + } + + func dismissError() { + chatBridge.clearError() + } + + // MARK: - Agent management + + func setActiveAgent(id: String?) { + selectedAgentId = id + repositoryBridge.setActiveAgent(agentId: id) { [weak self] error in + guard let self, let error else { return } + self.repositoryError = error.message ?? "Unknown error" + } + } + + func presentCreateAgent() { + draft = AgentDraft() + formMode = .create + } + + func presentEditAgent(agent: AgentSnapshot) { + draft = AgentDraft(snapshot: agent) + formMode = .edit(agent) + } + + func dismissAgentForm() { + formMode = nil + } + + func saveAgent() { + guard let mode = formMode else { return } + + let headers = draft.headers.compactMap { $0.toHeaderEntry() } + let authSnapshot = draft.toAuthMethod() + let systemPrompt = draft.systemPrompt.isEmpty ? nil : draft.systemPrompt + let description = draft.description.isEmpty ? nil : draft.description + + isPerformingAgentMutation = true + + switch mode { + case .create: + let config = ChatBridgeFactory.shared.createAgentConfig( + name: draft.name, + url: draft.url, + description: description, + authMethod: authSnapshot, + headers: headers, + systemPrompt: systemPrompt + ) + + repositoryBridge.addAgent(agent: config) { [weak self] error in + guard let self else { return } + self.isPerformingAgentMutation = false + if let error { + self.repositoryError = error.message ?? "Unknown error" + } else { + self.formMode = nil + self.setActiveAgent(id: config.id) + } + } + case .edit(let existing): + let config = ChatBridgeFactory.shared.updateAgentConfig( + existing: existing, + name: draft.name, + url: draft.url, + description: description, + authMethod: authSnapshot, + headers: headers, + systemPrompt: systemPrompt + ) + + repositoryBridge.updateAgent(agent: config) { [weak self] error in + guard let self else { return } + self.isPerformingAgentMutation = false + if let error { + self.repositoryError = error.message ?? "Unknown error" + } else { + self.formMode = nil + } + } + } + } + + func deleteAgent(id: String) { + repositoryBridge.deleteAgent(agentId: id) { [weak self] error in + guard let self, let error else { return } + self.repositoryError = error.message ?? "Unknown error" + } + } +} + +// MARK: - Agent form support + +enum AgentFormMode: Equatable { + case create + case edit(AgentSnapshot) + + static func == (lhs: AgentFormMode, rhs: AgentFormMode) -> Bool { + switch (lhs, rhs) { + case (.create, .create): return true + case let (.edit(l), .edit(r)): return l.id == r.id + default: return false + } + } +} + +struct AgentDraft { + var name: String = "" + var url: String = "" + var description: String = "" + var systemPrompt: String = "" + var headers: [HeaderField] = [] + var authSelection: AuthMethodSelection = .none + + // Auth specific fields + var apiKey: String = "" + var apiHeaderName: String = "X-API-Key" + var bearerToken: String = "" + var basicUsername: String = "" + var basicPassword: String = "" + var oauthClientId: String = "" + var oauthClientSecret: String = "" + var oauthAuthorizationURL: String = "" + var oauthTokenURL: String = "" + var oauthScopes: String = "" + var oauthAccessToken: String = "" + var oauthRefreshToken: String = "" + var customType: String = "" + var customConfiguration: [HeaderField] = [] + + init() {} + + init(snapshot: AgentSnapshot) { + name = snapshot.name + url = snapshot.url + description = snapshot.description_ ?? "" + systemPrompt = snapshot.systemPrompt ?? "" + headers = snapshot.customHeaders.map { HeaderField(key: $0.key, value: $0.value) } + authSelection = AuthMethodSelection(kindIdentifier: snapshot.authMethod.kind) + + switch authSelection { + case .apiKey: + apiKey = snapshot.authMethod.key ?? "" + apiHeaderName = snapshot.authMethod.headerName ?? "X-API-Key" + case .bearerToken: + bearerToken = snapshot.authMethod.token ?? "" + case .basicAuth: + basicUsername = snapshot.authMethod.username ?? "" + basicPassword = snapshot.authMethod.password ?? "" + case .oauth2: + oauthClientId = snapshot.authMethod.clientId ?? "" + oauthClientSecret = snapshot.authMethod.clientSecret ?? "" + oauthAuthorizationURL = snapshot.authMethod.authorizationUrl ?? "" + oauthTokenURL = snapshot.authMethod.tokenUrl ?? "" + oauthScopes = snapshot.authMethod.scopes.joined(separator: ", ") + oauthAccessToken = snapshot.authMethod.accessToken ?? "" + oauthRefreshToken = snapshot.authMethod.refreshToken ?? "" + case .custom: + customType = snapshot.authMethod.customType ?? "" + customConfiguration = snapshot.authMethod.customConfiguration.map { HeaderField(key: $0.key, value: $0.value) } + case .none: + break + } + } + + func toAuthMethod() -> AuthMethodSnapshot { + switch authSelection { + case .none: + return makeSnapshot(kind: .none) + case .apiKey: + return makeSnapshot(kind: .apiKey, key: apiKey, headerName: apiHeaderName) + case .bearerToken: + return makeSnapshot(kind: .bearerToken, token: bearerToken) + case .basicAuth: + return makeSnapshot(kind: .basicAuth, username: basicUsername, password: basicPassword) + case .oauth2: + let scopes = oauthScopes + .split(separator: ",") + .map { $0.trimmingCharacters(in: .whitespacesAndNewlines) } + .filter { !$0.isEmpty } + + return ChatBridgeFactory.shared.createOAuth2Auth( + clientId: oauthClientId, + clientSecret: oauthClientSecret.isEmpty ? nil : oauthClientSecret, + authorizationUrl: oauthAuthorizationURL, + tokenUrl: oauthTokenURL, + scopes: scopes, + accessToken: oauthAccessToken.isEmpty ? nil : oauthAccessToken, + refreshToken: oauthRefreshToken.isEmpty ? nil : oauthRefreshToken + ) + case .custom: + let entries = customConfiguration.compactMap { $0.toHeaderEntry() } + return ChatBridgeFactory.shared.createCustomAuth(type: customType, entries: entries) + } + } + + private func makeSnapshot( + kind: AuthMethodSelection, + key: String? = nil, + headerName: String? = nil, + token: String? = nil, + username: String? = nil, + password: String? = nil, + clientId: String? = nil, + clientSecret: String? = nil, + authorizationUrl: String? = nil, + tokenUrl: String? = nil, + scopes: [String] = [], + accessToken: String? = nil, + refreshToken: String? = nil, + customType: String? = nil, + customConfiguration: [HeaderEntry] = [] + ) -> AuthMethodSnapshot { + AuthMethodSnapshot( + kind: kind.rawValue, + key: key, + headerName: headerName, + token: token, + username: username, + password: password, + clientId: clientId, + clientSecret: clientSecret, + authorizationUrl: authorizationUrl, + tokenUrl: tokenUrl, + scopes: scopes, + accessToken: accessToken, + refreshToken: refreshToken, + customType: customType, + customConfiguration: customConfiguration + ) + } +} + +enum AuthMethodSelection: String, CaseIterable, Identifiable { + case none + case apiKey + case bearerToken + case basicAuth + case oauth2 + case custom + + init(kindIdentifier: String) { + self = AuthMethodSelection(rawValue: kindIdentifier) ?? .none + } + + var id: String { rawValue } + + var title: String { + switch self { + case .none: return "None" + case .apiKey: return "API Key" + case .bearerToken: return "Bearer Token" + case .basicAuth: return "Basic Auth" + case .oauth2: return "OAuth 2.0" + case .custom: return "Custom" + } + } +} + +struct HeaderField: Identifiable, Hashable { + let id: UUID = UUID() + var key: String + var value: String + + func toHeaderEntry() -> HeaderEntry? { + let trimmedKey = key.trimmingCharacters(in: .whitespacesAndNewlines) + let trimmedValue = value.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmedKey.isEmpty, !trimmedValue.isEmpty else { return nil } + return HeaderEntry(key: trimmedKey, value: trimmedValue) + } +} diff --git a/sdks/community/kotlin/examples/chatapp-swiftui/iosApp/Sources/Views/AgentFormView.swift b/sdks/community/kotlin/examples/chatapp-swiftui/iosApp/Sources/Views/AgentFormView.swift new file mode 100644 index 000000000..a32d942c7 --- /dev/null +++ b/sdks/community/kotlin/examples/chatapp-swiftui/iosApp/Sources/Views/AgentFormView.swift @@ -0,0 +1,196 @@ +import SwiftUI +import shared + +struct AgentFormView: View { + @EnvironmentObject private var store: ChatAppStore + @Environment(\.dismiss) private var dismiss + + let mode: AgentFormMode + + private var title: String { + switch mode { + case .create: return "New Agent" + case .edit(let agent): return "Edit \(agent.name)" + } + } + + var body: some View { + NavigationStack { + Form { + agentDetailsSection + authenticationSection + headersSection + systemPromptSection + if store.isPerformingAgentMutation { + Section { + HStack { + Spacer() + ProgressView() + Spacer() + } + } + } + } + .navigationTitle(title) + .toolbar { + ToolbarItem(placement: .cancellationAction) { + Button("Cancel") { store.dismissAgentForm(); dismiss() } + } + ToolbarItem(placement: .confirmationAction) { + Button(action: saveAgent) { + Text("Save") + } + .disabled(!isValid) + } + } + } + .onChange(of: store.formMode) { mode in + if mode == nil { + dismiss() + } + } + } + + private var agentDetailsSection: some View { + Section("Details") { + TextField("Name", text: binding(\.name)) + TextField("Endpoint URL", text: binding(\.url)) + .textContentType(.URL) + .keyboardType(.URL) + TextField("Description", text: binding(\.description), axis: .vertical) + TextField("System Prompt", text: binding(\.systemPrompt), axis: .vertical) + } + } + + private var authenticationSection: some View { + Section("Authentication") { + Picker("Method", selection: binding(\.authSelection)) { + ForEach(AuthMethodSelection.allCases) { method in + Text(method.title).tag(method) + } + } + .pickerStyle(.segmented) + + switch store.draft.authSelection { + case .none: + Text("No authentication headers will be added.") + .font(.footnote) + .foregroundColor(.secondary) + case .apiKey: + TextField("API Key", text: binding(\.apiKey)) + TextField("Header Name", text: binding(\.apiHeaderName)) + case .bearerToken: + SecureField("Bearer Token", text: binding(\.bearerToken)) + case .basicAuth: + TextField("Username", text: binding(\.basicUsername)) + SecureField("Password", text: binding(\.basicPassword)) + case .oauth2: + TextField("Client ID", text: binding(\.oauthClientId)) + SecureField("Client Secret", text: binding(\.oauthClientSecret)) + TextField("Authorization URL", text: binding(\.oauthAuthorizationURL)) + .textContentType(.URL) + .keyboardType(.URL) + TextField("Token URL", text: binding(\.oauthTokenURL)) + .textContentType(.URL) + .keyboardType(.URL) + TextField("Scopes (comma separated)", text: binding(\.oauthScopes)) + TextField("Access Token", text: binding(\.oauthAccessToken)) + TextField("Refresh Token", text: binding(\.oauthRefreshToken)) + case .custom: + TextField("Type", text: binding(\.customType)) + KeyValueEditor(title: "Configuration", items: binding(\.customConfiguration)) + } + } + } + + private var headersSection: some View { + Section("Custom Headers") { + KeyValueEditor(title: "Headers", items: binding(\.headers)) + } + } + + private var systemPromptSection: some View { + Section("Preview") { + if store.draft.headers.isEmpty, store.draft.systemPrompt.isEmpty, store.draft.description.isEmpty { + Text("Configure headers, description, or prompt to preview.") + .font(.footnote) + .foregroundColor(.secondary) + } else { + VStack(alignment: .leading, spacing: 8) { + if !store.draft.description.isEmpty { + Label("Description", systemImage: "text.justify") + .font(.caption) + Text(store.draft.description) + } + if !store.draft.systemPrompt.isEmpty { + Label("System Prompt", systemImage: "sparkles") + .font(.caption) + Text(store.draft.systemPrompt) + .font(.callout) + } + if !store.draft.headers.isEmpty { + Label("Headers", systemImage: "tray.full") + .font(.caption) + ForEach(store.draft.headers) { header in + HStack { + Text(header.key) + .font(.caption) + Spacer() + Text(header.value) + .font(.caption) + } + } + } + } + } + } + } + + private var isValid: Bool { + !store.draft.name.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty && + !store.draft.url.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + } + + private func saveAgent() { + store.saveAgent() + } + + private func binding(_ keyPath: WritableKeyPath) -> Binding { + Binding( + get: { store.draft[keyPath: keyPath] }, + set: { store.draft[keyPath: keyPath] = $0 } + ) + } +} + +private struct KeyValueEditor: View { + let title: String + @Binding var items: [HeaderField] + + var body: some View { + VStack(alignment: .leading, spacing: 8) { + ForEach($items) { $item in + HStack { + TextField("Key", text: $item.key) + TextField("Value", text: $item.value) + } + } + + Button { + items.append(HeaderField(key: "", value: "")) + } label: { + Label("Add \(title.hasSuffix("s") ? String(title.dropLast()) : title)", systemImage: "plus.circle") + } + .buttonStyle(.borderless) + + if !items.isEmpty { + Button(role: .destructive) { + items.removeLast() + } label: { + Label("Remove Last", systemImage: "trash") + } + .buttonStyle(.borderless) + } + } + } +} diff --git a/sdks/community/kotlin/examples/chatapp-swiftui/iosApp/Sources/Views/ChatView.swift b/sdks/community/kotlin/examples/chatapp-swiftui/iosApp/Sources/Views/ChatView.swift new file mode 100644 index 000000000..3bddff284 --- /dev/null +++ b/sdks/community/kotlin/examples/chatapp-swiftui/iosApp/Sources/Views/ChatView.swift @@ -0,0 +1,233 @@ +import SwiftUI +import MarkdownUI +import shared + +struct ChatView: View { + @EnvironmentObject private var store: ChatAppStore + + let state: ChatStateSnapshot + let onSend: (String) -> Void + + @State private var messageText: String = "" + + var body: some View { + let backgroundColor = state.background.color(default: Color(UIColor.systemBackground)) + + Group { + if state.activeAgent == nil { + ContentUnavailableView( + "Select an agent", + systemImage: "person.crop.circle.badge.questionmark", + description: Text("Choose or create an agent to begin chatting.") + ) + .padding() + } else { + VStack(spacing: 0) { + conversationScrollView + + if let ephemeral = state.ephemeralMessage { + EphemeralBanner(message: ephemeral) + .transition(.move(edge: .bottom).combined(with: .opacity)) + } + + Divider() + inputArea + } + .background(backgroundColor) + } + } + .animation(.default, value: state.messages.count) + } + + private var conversationScrollView: some View { + ScrollViewReader { proxy in + ScrollView { + LazyVStack(alignment: .leading, spacing: 12) { + ForEach(state.messages, id: \.id) { message in + ChatMessageBubble(message: message) + .id(message.id) + } + } + .padding(.horizontal) + .padding(.vertical, 16) + } + .background(state.background.color(default: Color(UIColor.systemGroupedBackground))) + .onChange(of: state.messages.last?.id) { id in + guard let id else { return } + withAnimation { + proxy.scrollTo(id, anchor: .bottom) + } + } + } + } + + private var inputArea: some View { + VStack(spacing: 8) { + HStack(alignment: .bottom, spacing: 12) { + TextField("Type a message", text: $messageText, axis: .vertical) + .textFieldStyle(.roundedBorder) + .lineLimit(1...6) + .disabled(!state.isConnected) + + Button { + let trimmed = messageText.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { return } + onSend(trimmed) + messageText = "" + } label: { + Image(systemName: "paperplane.fill") + .font(.system(size: 18, weight: .semibold)) + } + .buttonStyle(.borderedProminent) + .disabled(!state.isConnected) + } + + if state.isLoading { + HStack(spacing: 8) { + ProgressView() + Text("Waiting for response…") + .font(.footnote) + .foregroundColor(.secondary) + Spacer() + Button("Cancel", role: .cancel, action: store.cancelStreaming) + } + } + } + .padding() + .background(Material.bar) + } +} + +private extension BackgroundSnapshot { + func color(default defaultColor: Color) -> Color { + guard let hex = colorHex?.trimmingCharacters(in: .whitespacesAndNewlines).replacingOccurrences(of: "#", with: ""), + let value = UInt64(hex, radix: 16) else { + return defaultColor + } + + switch hex.count { + case 6: + let red = Double((value & 0xFF0000) >> 16) / 255.0 + let green = Double((value & 0x00FF00) >> 8) / 255.0 + let blue = Double(value & 0x0000FF) / 255.0 + return Color(red: red, green: green, blue: blue) + case 8: + // Expect RRGGBBAA ordering from the tool. + let red = Double((value & 0xFF000000) >> 24) / 255.0 + let green = Double((value & 0x00FF0000) >> 16) / 255.0 + let blue = Double((value & 0x0000FF00) >> 8) / 255.0 + let alpha = Double(value & 0x000000FF) / 255.0 + return Color(red: red, green: green, blue: blue, opacity: alpha) + default: + return defaultColor + } + } +} + +private struct ChatMessageBubble: View { + let message: DisplayMessageSnapshot + + private var alignment: HorizontalAlignment { + switch message.role { + case MessageRole.user: return .trailing + default: return .leading + } + } + + private var bubbleColor: Color { + switch message.role { + case MessageRole.user: return Color.accentColor + case MessageRole.assistant: return Color(UIColor.secondarySystemBackground) + case MessageRole.system: return Color(UIColor.systemGray5) + case MessageRole.error: return Color.red.opacity(0.15) + case MessageRole.toolCall: return Color.yellow.opacity(0.2) + case MessageRole.stepInfo: return Color.blue.opacity(0.12) + default: return Color(UIColor.tertiarySystemBackground) + } + } + + private var textColor: Color { + switch message.role { + case MessageRole.user: return .white + case MessageRole.error: return .red + default: return .primary + } + } + + private var leadingIcon: String? { + switch message.role { + case MessageRole.assistant: return "sparkles" + case MessageRole.system: return "info.circle" + case MessageRole.error: return "exclamationmark.triangle" + case MessageRole.toolCall: return "wrench.adjustable" + case MessageRole.stepInfo: return "bolt.fill" + default: return nil + } + } + + var body: some View { + HStack { + if alignment == .trailing { Spacer(minLength: 40) } + + VStack(alignment: alignment == .trailing ? .trailing : .leading, spacing: 6) { + HStack(alignment: .top, spacing: 6) { + if let icon = leadingIcon { + Image(systemName: icon) + .font(.caption) + .foregroundColor(.secondary) + } + if message.content.isEmpty && message.isStreaming { + Text("…") + .foregroundColor(textColor) + .font(.body) + } else { + Markdown(message.content) + .markdownTheme(.basic) + .markdownTextStyle(\.text) { + ForegroundColor(textColor) + } + .markdownTextStyle(\.strong) { + FontWeight(.heavy) + ForegroundColor(textColor) + BackgroundColor(textColor.opacity(alignment == .trailing ? 0.3 : 0.15)) + } + .markdownTextStyle(\.link) { + ForegroundColor(textColor) + } + .textSelection(.enabled) + } + } + .frame(maxWidth: .infinity, alignment: alignment == .trailing ? .trailing : .leading) + .padding(12) + .background(bubbleColor) + .clipShape(RoundedRectangle(cornerRadius: 16, style: .continuous)) + + Text( + Date(timeIntervalSince1970: TimeInterval(message.timestamp) / 1000) + .formatted(date: .omitted, time: .shortened) + ) + .font(.caption2) + .foregroundColor(.secondary) + } + + if alignment == .leading { Spacer(minLength: 40) } + } + } +} + +private struct EphemeralBanner: View { + let message: DisplayMessageSnapshot + + var body: some View { + HStack(spacing: 12) { + Image(systemName: message.role == MessageRole.toolCall ? "wrench.adjustable" : "bolt.fill") + .foregroundColor(.accentColor) + Text(message.content) + .font(.footnote) + .foregroundColor(.accentColor) + Spacer() + } + .padding(12) + .background(Color.accentColor.opacity(0.1)) + } +} diff --git a/sdks/community/kotlin/examples/chatapp-swiftui/iosApp/Sources/Views/Components/AgentListView.swift b/sdks/community/kotlin/examples/chatapp-swiftui/iosApp/Sources/Views/Components/AgentListView.swift new file mode 100644 index 000000000..b8fd26556 --- /dev/null +++ b/sdks/community/kotlin/examples/chatapp-swiftui/iosApp/Sources/Views/Components/AgentListView.swift @@ -0,0 +1,135 @@ +import SwiftUI +import shared + +struct AgentListView: View { + let agents: [AgentSnapshot] + let selectedAgentId: String? + let onSelect: (String?) -> Void + let onAdd: () -> Void + let onEdit: (AgentSnapshot) -> Void + let onDelete: (String) -> Void + + var body: some View { + List { + Section(header: Text("Agents")) { + if agents.isEmpty { + ContentUnavailableView( + "No agents", + systemImage: "person.crop.circle.badge.questionmark", + description: Text("Add an agent to start chatting.") + ) + .listRowBackground(Color.clear) + } else { + ForEach(agents, id: \.id) { agent in + AgentRow(agent: agent, isActive: agent.id == selectedAgentId) + .contentShape(Rectangle()) + .onTapGesture { onSelect(agent.id) } + .contextMenu { + Button("Chat with \(agent.name)") { onSelect(agent.id) } + Button("Edit") { onEdit(agent) } + Button(role: .destructive) { + onDelete(agent.id) + } label: { + Label("Delete", systemImage: "trash") + } + } + .swipeActions(edge: .trailing, allowsFullSwipe: true) { + Button("Edit") { onEdit(agent) } + .tint(.blue) + + Button(role: .destructive) { + onDelete(agent.id) + } label: { + Label("Delete", systemImage: "trash") + } + } + } + } + } + + Section { + Button { + onAdd() + } label: { + Label("Add Agent", systemImage: "plus") + } + } + } + .listStyle(.insetGrouped) + } +} + +private struct AgentRow: View { + let agent: AgentSnapshot + let isActive: Bool + + var body: some View { + HStack(alignment: .top, spacing: 12) { + Image(systemName: isActive ? "bubble.left.and.bubble.right.fill" : "bubble.left") + .foregroundColor(isActive ? .accentColor : .secondary) + VStack(alignment: .leading, spacing: 4) { + HStack { + Text(agent.name) + .font(.headline) + if isActive { + Capsule() + .fill(Color.accentColor.opacity(0.2)) + .overlay(Text("Active").font(.caption).foregroundColor(.accentColor)) + .frame(width: 60, height: 20) + } + } + Text(agent.url) + .font(.subheadline) + .foregroundColor(.secondary) + let description = agent.description_ ?? "" + if !description.isEmpty { + Text(description) + .font(.footnote) + .foregroundColor(.secondary) + .lineLimit(2) + } + } + Spacer() + } + .padding(.vertical, 6) + } +} + +struct AgentListMenu: View { + let agents: [AgentSnapshot] + let activeId: String? + let onSelect: (String?) -> Void + let onEdit: (AgentSnapshot) -> Void + let onDelete: (String) -> Void + let onCreate: () -> Void + + var body: some View { + if agents.isEmpty { + Button("Add Agent", action: onCreate) + } else { + Section("Active") { + ForEach(agents, id: \.id) { agent in + Button { + onSelect(agent.id) + } label: { + Label(agent.name, systemImage: agent.id == activeId ? "checkmark" : "person") + } + } + } + + Section("Manage") { + ForEach(agents, id: \.id) { agent in + Button("Edit \(agent.name)") { onEdit(agent) } + } + ForEach(agents, id: \.id) { agent in + Button(role: .destructive) { + onDelete(agent.id) + } label: { + Label("Remove \(agent.name)", systemImage: "trash") + } + } + Button("Add Agent", action: onCreate) + } + } + } +} diff --git a/sdks/community/kotlin/examples/chatapp-swiftui/iosApp/Sources/Views/RootView.swift b/sdks/community/kotlin/examples/chatapp-swiftui/iosApp/Sources/Views/RootView.swift new file mode 100644 index 000000000..d4fc6cde0 --- /dev/null +++ b/sdks/community/kotlin/examples/chatapp-swiftui/iosApp/Sources/Views/RootView.swift @@ -0,0 +1,119 @@ +import SwiftUI +import shared + +struct RootView: View { + @EnvironmentObject private var store: ChatAppStore + @Environment(\.horizontalSizeClass) private var horizontalSizeClass + + @State private var agentToEdit: AgentSnapshot? + + var body: some View { + Group { + if horizontalSizeClass == .regular { + NavigationSplitView { + AgentListView( + agents: store.agents, + selectedAgentId: store.selectedAgentId, + onSelect: { store.setActiveAgent(id: $0) }, + onAdd: store.presentCreateAgent, + onEdit: { store.presentEditAgent(agent: $0) }, + onDelete: { store.deleteAgent(id: $0) } + ) + .frame(minWidth: 280) + } detail: { + ChatView(state: store.chatState) { message in + store.sendMessage(message) + } + .navigationTitle(store.chatState.activeAgent?.name ?? "Chat") + .toolbar { + ToolbarItem(placement: .navigationBarTrailing) { + Button { + store.presentCreateAgent() + } label: { + Label("Add Agent", systemImage: "plus") + } + } + } + } + } else { + NavigationStack { + ChatView(state: store.chatState) { message in + store.sendMessage(message) + } + .navigationTitle(store.chatState.activeAgent?.name ?? "Chat") + .toolbar { + ToolbarItem(placement: .navigationBarLeading) { + Menu { + AgentListMenu( + agents: store.agents, + activeId: store.selectedAgentId, + onSelect: store.setActiveAgent, + onEdit: store.presentEditAgent, + onDelete: store.deleteAgent, + onCreate: store.presentCreateAgent + ) + } label: { + Label("Agents", systemImage: "person.3.sequence") + } + } + + ToolbarItem(placement: .navigationBarTrailing) { + Button { + store.presentCreateAgent() + } label: { + Label("Add Agent", systemImage: "plus") + } + } + } + } + } + } + .sheet(item: Binding( + get: { store.formMode.map(FormSheetWrapper.init) }, + set: { newValue in + if newValue == nil { store.dismissAgentForm() } + } + )) { wrapper in + AgentFormView(mode: wrapper.mode) + .environmentObject(store) + } + .alert(item: Binding( + get: { store.repositoryError.map { IdentifiableError(message: $0) } }, + set: { _ in store.repositoryError = nil } + )) { error in + Alert(title: Text("Error"), message: Text(error.message), dismissButton: .default(Text("OK"))) + } + .alert(isPresented: Binding( + get: { store.chatState.error != nil }, + set: { value in if !value { store.dismissError() } } + )) { + Alert( + title: Text("Conversation Error"), + message: Text(store.chatState.error ?? "Unknown error"), + dismissButton: .default(Text("OK"), action: store.dismissError) + ) + } + } +} + +private struct FormSheetWrapper: Identifiable, Equatable { + let mode: AgentFormMode + + var id: String { + switch mode { + case .create: + return "create" + case .edit(let agent): + return "edit-\(agent.id)" + } + } + + static func == (lhs: FormSheetWrapper, rhs: FormSheetWrapper) -> Bool { + lhs.id == rhs.id + } +} + +private struct IdentifiableError: Identifiable { + let id = UUID() + let message: String +} diff --git a/sdks/community/kotlin/examples/chatapp-swiftui/iosApp/project.yml b/sdks/community/kotlin/examples/chatapp-swiftui/iosApp/project.yml new file mode 100644 index 000000000..20a07243a --- /dev/null +++ b/sdks/community/kotlin/examples/chatapp-swiftui/iosApp/project.yml @@ -0,0 +1,37 @@ +name: ChatAppSwiftUI +options: + bundleIdPrefix: com.agui.example + deploymentTarget: + iOS: 17.0 +packages: + MarkdownUI: + url: https://github.com/gonzalezreal/MarkdownUI.git + from: 2.4.1 +settings: + base: + SWIFT_VERSION: 5.9 + IPHONEOS_DEPLOYMENT_TARGET: 17.0 +targets: + ChatAppSwiftUI: + type: application + platform: iOS + deploymentTarget: 17.0 + sources: + - path: Sources + resources: + - path: Resources + settings: + base: + PRODUCT_BUNDLE_IDENTIFIER: com.agui.example.chatappswiftui + dependencies: + - package: MarkdownUI + product: MarkdownUI + - framework: ../shared/build/XCFrameworks/release/shared.xcframework + embed: true + codeSign: true + info: + path: Resources/Info.plist + properties: + UILaunchStoryboardName: LaunchScreen + UIUserInterfaceStyle: Automatic + UIApplicationSupportsIndirectInputEvents: true diff --git a/sdks/community/kotlin/examples/chatapp-swiftui/settings.gradle.kts b/sdks/community/kotlin/examples/chatapp-swiftui/settings.gradle.kts new file mode 100644 index 000000000..62bda14ee --- /dev/null +++ b/sdks/community/kotlin/examples/chatapp-swiftui/settings.gradle.kts @@ -0,0 +1,41 @@ +rootProject.name = "chatapp-swiftui" + +include(":chatapp-shared") +project(":chatapp-shared").projectDir = File(rootDir, "../chatapp-shared") + +include(":shared") +project(":shared").projectDir = File(rootDir, "shared") + +pluginManagement { + repositories { + google() + gradlePluginPortal() + mavenCentral() + // Compose Multiplatform plugin + artifacts for the shared chat module live here. + maven("https://maven.pkg.jetbrains.space/public/p/compose/dev") + } + + plugins { + val kotlinVersion = "2.2.20" + val composeVersion = "1.9.0-rc02" + val agpVersion = "8.12.0" + + kotlin("multiplatform") version kotlinVersion + kotlin("plugin.serialization") version kotlinVersion + kotlin("plugin.compose") version kotlinVersion + kotlin("android") version kotlinVersion + id("org.jetbrains.compose") version composeVersion + id("com.android.application") version agpVersion + id("com.android.library") version agpVersion + } +} + +dependencyResolutionManagement { + repositories { + google() + mavenCentral() + // Compose runtime/material artifacts required by the shared chat module. + maven("https://maven.pkg.jetbrains.space/public/p/compose/dev") + mavenLocal() + } +} diff --git a/sdks/community/kotlin/examples/chatapp-swiftui/shared/build.gradle.kts b/sdks/community/kotlin/examples/chatapp-swiftui/shared/build.gradle.kts new file mode 100644 index 000000000..446430624 --- /dev/null +++ b/sdks/community/kotlin/examples/chatapp-swiftui/shared/build.gradle.kts @@ -0,0 +1,55 @@ +import org.jetbrains.kotlin.gradle.plugin.mpp.apple.XCFramework + +plugins { + kotlin("multiplatform") +} + +kotlin { + val xcFramework = XCFramework() + val iosTargets = listOf(iosX64(), iosArm64(), iosSimulatorArm64()) + + iosTargets.forEach { target -> + target.binaries.framework { + baseName = "shared" + isStatic = true + xcFramework.add(this) + export(project(":chatapp-shared")) + } + } + + sourceSets { + val commonMain by getting { + dependencies { + api(project(":chatapp-shared")) + implementation(libs.kotlinx.coroutines.core) + implementation(libs.kotlinx.datetime) + } + } + + val commonTest by getting { + dependencies { + implementation(kotlin("test")) + } + } + + val iosMain by creating { + dependsOn(commonMain) + val iosX64Main by getting + val iosArm64Main by getting + val iosSimulatorArm64Main by getting + iosX64Main.dependsOn(this) + iosArm64Main.dependsOn(this) + iosSimulatorArm64Main.dependsOn(this) + } + + val iosTest by creating { + dependsOn(commonTest) + val iosX64Test by getting + val iosArm64Test by getting + val iosSimulatorArm64Test by getting + iosX64Test.dependsOn(this) + iosArm64Test.dependsOn(this) + iosSimulatorArm64Test.dependsOn(this) + } + } +} diff --git a/sdks/community/kotlin/examples/chatapp-swiftui/shared/src/commonMain/kotlin/com/agui/example/chatapp/bridge/SwiftBridge.kt b/sdks/community/kotlin/examples/chatapp-swiftui/shared/src/commonMain/kotlin/com/agui/example/chatapp/bridge/SwiftBridge.kt new file mode 100644 index 000000000..b8388a0b5 --- /dev/null +++ b/sdks/community/kotlin/examples/chatapp-swiftui/shared/src/commonMain/kotlin/com/agui/example/chatapp/bridge/SwiftBridge.kt @@ -0,0 +1,418 @@ +package com.agui.example.chatapp.bridge + +import com.agui.example.chatapp.chat.ChatController +import com.agui.example.chatapp.chat.ChatState +import com.agui.example.chatapp.chat.DisplayMessage +import com.agui.example.chatapp.chat.EphemeralType +import com.agui.example.chatapp.chat.MessageRole +import com.agui.example.chatapp.data.model.AgentConfig +import com.agui.example.chatapp.data.model.AuthMethod +import com.agui.example.chatapp.data.repository.AgentRepository +import com.agui.example.chatapp.util.getPlatformSettings +import com.agui.example.tools.BackgroundStyle +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.MainScope +import kotlinx.coroutines.Job +import kotlinx.coroutines.cancel +import kotlinx.coroutines.flow.collectLatest +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import kotlinx.datetime.Instant + +/** + * Simple key/value tuple used for bridging dictionaries into Swift. + */ +data class HeaderEntry( + val key: String, + val value: String +) + +/** + * Snapshot of an [AgentConfig] that is friendlier to consume from Swift. + */ +data class AgentSnapshot( + val id: String, + val name: String, + val url: String, + val description: String?, + val authMethod: AuthMethodSnapshot, + val isActive: Boolean, + val createdAtMillis: Long, + val lastUsedAtMillis: Long?, + val customHeaders: List, + val systemPrompt: String? +) + +// Internal enum keeps conversions between Kotlin models and the string identifiers exposed to Swift. +private enum class AuthMethodKind(val identifier: String) { + NONE("none"), + API_KEY("apiKey"), + BEARER_TOKEN("bearerToken"), + BASIC_AUTH("basicAuth"), + OAUTH2("oauth2"), + CUSTOM("custom"); + + companion object { + fun fromIdentifier(identifier: String?): AuthMethodKind = + values().firstOrNull { it.identifier.equals(identifier, ignoreCase = true) } ?: NONE + } +} + +data class AuthMethodSnapshot( + val kind: String, + val key: String? = null, + val headerName: String? = null, + val token: String? = null, + val username: String? = null, + val password: String? = null, + val clientId: String? = null, + val clientSecret: String? = null, + val authorizationUrl: String? = null, + val tokenUrl: String? = null, + val scopes: List = emptyList(), + val accessToken: String? = null, + val refreshToken: String? = null, + val customType: String? = null, + val customConfiguration: List = emptyList() +) + +/** + * Snapshot of a [DisplayMessage] that can be rendered directly in SwiftUI. + */ +data class DisplayMessageSnapshot( + val id: String, + val role: MessageRole, + val content: String, + val timestamp: Long, + val isStreaming: Boolean, + val ephemeralGroupId: String?, + val ephemeralType: EphemeralType? +) + +/** Snapshot of the current chat background styling. */ +data class BackgroundSnapshot( + val colorHex: String?, + val description: String? +) + +/** + * Complete snapshot of [ChatState] designed for Swift consumption. + */ +data class ChatStateSnapshot( + val activeAgent: AgentSnapshot?, + val messages: List, + val ephemeralMessage: DisplayMessageSnapshot?, + val isLoading: Boolean, + val isConnected: Boolean, + val error: String?, + val background: BackgroundSnapshot +) + +/** + * Handle returned to Swift for cancelling coroutine backed observers. + */ +class FlowSubscription internal constructor(private val job: Job) { + fun cancel() { + job.cancel() + } +} + +private fun AgentConfig.toSnapshot(): AgentSnapshot = AgentSnapshot( + id = id, + name = name, + url = url, + description = description, + authMethod = authMethod.toSnapshot(), + isActive = isActive, + createdAtMillis = createdAt.toEpochMilliseconds(), + lastUsedAtMillis = lastUsedAt?.toEpochMilliseconds(), + customHeaders = customHeaders.map { HeaderEntry(it.key, it.value) }, + systemPrompt = systemPrompt +) + +private fun DisplayMessage.toSnapshot(stableId: String = id): DisplayMessageSnapshot = DisplayMessageSnapshot( + id = stableId, + role = role, + content = content, + timestamp = timestamp, + isStreaming = isStreaming, + ephemeralGroupId = ephemeralGroupId, + ephemeralType = ephemeralType +) + +private fun BackgroundStyle.toSnapshot(): BackgroundSnapshot = + BackgroundSnapshot( + colorHex = colorHex, + description = description + ) + +private fun ChatState.toSnapshot(): ChatStateSnapshot = ChatStateSnapshot( + activeAgent = activeAgent?.toSnapshot(), + messages = messages.map { message -> + val stableId = buildString { + append(message.id) + append(":") + append(message.timestamp) + } + message.toSnapshot(stableId) + }, + ephemeralMessage = ephemeralMessage?.let { message -> + val stableId = buildString { + append(message.id) + append(":") + append(message.timestamp) + } + message.toSnapshot(stableId) + }, + isLoading = isLoading, + isConnected = isConnected, + error = error, + background = background.toSnapshot() +) + +private fun AuthMethod.toSnapshot(): AuthMethodSnapshot = when (this) { + is AuthMethod.None -> AuthMethodSnapshot(kind = AuthMethodKind.NONE.identifier) + is AuthMethod.ApiKey -> AuthMethodSnapshot( + kind = AuthMethodKind.API_KEY.identifier, + key = key, + headerName = headerName + ) + is AuthMethod.BearerToken -> AuthMethodSnapshot( + kind = AuthMethodKind.BEARER_TOKEN.identifier, + token = token + ) + is AuthMethod.BasicAuth -> AuthMethodSnapshot( + kind = AuthMethodKind.BASIC_AUTH.identifier, + username = username, + password = password + ) + is AuthMethod.OAuth2 -> AuthMethodSnapshot( + kind = AuthMethodKind.OAUTH2.identifier, + clientId = clientId, + clientSecret = clientSecret, + authorizationUrl = authorizationUrl, + tokenUrl = tokenUrl, + scopes = scopes, + accessToken = accessToken, + refreshToken = refreshToken + ) + is AuthMethod.Custom -> AuthMethodSnapshot( + kind = AuthMethodKind.CUSTOM.identifier, + customType = type, + customConfiguration = config.map { HeaderEntry(it.key, it.value) } + ) +} + +private fun AuthMethodSnapshot.toAuthMethod(): AuthMethod = when (AuthMethodKind.fromIdentifier(kind)) { + AuthMethodKind.NONE -> AuthMethod.None() + AuthMethodKind.API_KEY -> AuthMethod.ApiKey( + key = key ?: "", + headerName = headerName ?: "X-API-Key" + ) + AuthMethodKind.BEARER_TOKEN -> AuthMethod.BearerToken(token = token ?: "") + AuthMethodKind.BASIC_AUTH -> AuthMethod.BasicAuth( + username = username ?: "", + password = password ?: "" + ) + AuthMethodKind.OAUTH2 -> AuthMethod.OAuth2( + clientId = clientId ?: "", + clientSecret = clientSecret, + authorizationUrl = authorizationUrl ?: "", + tokenUrl = tokenUrl ?: "", + scopes = scopes, + accessToken = accessToken, + refreshToken = refreshToken + ) + AuthMethodKind.CUSTOM -> AuthMethod.Custom( + type = customType ?: "", + config = ChatBridgeFactory.mapFromEntries(customConfiguration) + ) +} + +class ChatViewModelBridge(private val controller: ChatController) { + private val scope = MainScope() + + constructor() : this(ChatController()) + + fun observeState(onEach: (ChatStateSnapshot) -> Unit): FlowSubscription { + val job = scope.launch { + controller.state.collectLatest { state -> + withContext(Dispatchers.Main) { + onEach(state.toSnapshot()) + } + } + } + return FlowSubscription(job) + } + + fun currentState(): ChatStateSnapshot = controller.state.value.toSnapshot() + + fun sendMessage(content: String) { + controller.sendMessage(content) + } + + fun cancelCurrentOperation() { + controller.cancelCurrentOperation() + } + + fun clearError() { + controller.clearError() + } + + fun close() { + scope.cancel() + controller.close() + } +} + +class AgentRepositoryBridge( + private val repository: AgentRepository +) { + private val scope = MainScope() + + constructor() : this(AgentRepository.getInstance(getPlatformSettings())) + + fun observeAgents(onEach: (List) -> Unit): FlowSubscription { + val job = scope.launch { + repository.agents.collectLatest { agents -> + withContext(Dispatchers.Main) { + onEach(agents.map { it.toSnapshot() }) + } + } + } + return FlowSubscription(job) + } + + fun observeActiveAgent(onEach: (AgentSnapshot?) -> Unit): FlowSubscription { + val job = scope.launch { + repository.activeAgent.collectLatest { agent -> + withContext(Dispatchers.Main) { + onEach(agent?.toSnapshot()) + } + } + } + return FlowSubscription(job) + } + + fun currentAgents(): List = repository.agents.value.map { it.toSnapshot() } + + fun currentActiveAgent(): AgentSnapshot? = repository.activeAgent.value?.toSnapshot() + + fun addAgent(agent: AgentConfig, completion: (Throwable?) -> Unit) { + scope.launch { + runCatching { repository.addAgent(agent) } + .onSuccess { withContext(Dispatchers.Main) { completion(null) } } + .onFailure { error -> withContext(Dispatchers.Main) { completion(error) } } + } + } + + fun updateAgent(agent: AgentConfig, completion: (Throwable?) -> Unit) { + scope.launch { + runCatching { repository.updateAgent(agent) } + .onSuccess { withContext(Dispatchers.Main) { completion(null) } } + .onFailure { error -> withContext(Dispatchers.Main) { completion(error) } } + } + } + fun deleteAgent(agentId: String, completion: (Throwable?) -> Unit) { + scope.launch { + runCatching { repository.deleteAgent(agentId) } + .onSuccess { withContext(Dispatchers.Main) { completion(null) } } + .onFailure { error -> withContext(Dispatchers.Main) { completion(error) } } + } + } + + fun setActiveAgent(agentId: String?, completion: (Throwable?) -> Unit) { + scope.launch { + runCatching { + val target = agentId?.let { repository.getAgent(it) } + repository.setActiveAgent(target) + }.onSuccess { + withContext(Dispatchers.Main) { completion(null) } + }.onFailure { error -> + withContext(Dispatchers.Main) { completion(error) } + } + } + } + + fun close() { + scope.cancel() + } +} + + +object ChatBridgeFactory { + @Suppress("unused") + val shared: ChatBridgeFactory get() = this + + fun createAgentConfig( + name: String, + url: String, + description: String?, + authMethod: AuthMethodSnapshot, + headers: List, + systemPrompt: String? + ): AgentConfig = AgentConfig( + id = AgentConfig.generateId(), + name = name, + url = url, + description = description, + authMethod = authMethod.toAuthMethod(), + customHeaders = headers.associate { it.key to it.value }, + systemPrompt = systemPrompt + ) + + fun updateAgentConfig( + existing: AgentSnapshot, + name: String, + url: String, + description: String?, + authMethod: AuthMethodSnapshot, + headers: List, + systemPrompt: String? + ): AgentConfig = AgentConfig( + id = existing.id, + name = name, + url = url, + description = description, + authMethod = authMethod.toAuthMethod(), + isActive = existing.isActive, + createdAt = Instant.fromEpochMilliseconds(existing.createdAtMillis), + lastUsedAt = existing.lastUsedAtMillis?.let { Instant.fromEpochMilliseconds(it) }, + customHeaders = headers.associate { it.key to it.value }, + systemPrompt = systemPrompt + ) + + fun headersFromMap(map: Map): List = + map.map { HeaderEntry(it.key, it.value) } + + fun mapFromEntries(entries: List): Map = + entries.associate { it.key to it.value } + + fun createOAuth2Auth( + clientId: String, + clientSecret: String?, + authorizationUrl: String, + tokenUrl: String, + scopes: List, + accessToken: String?, + refreshToken: String? + ): AuthMethodSnapshot = AuthMethodSnapshot( + kind = AuthMethodKind.OAUTH2.identifier, + clientId = clientId, + clientSecret = clientSecret, + authorizationUrl = authorizationUrl, + tokenUrl = tokenUrl, + scopes = scopes, + accessToken = accessToken, + refreshToken = refreshToken + ) + + fun createCustomAuth( + type: String, + entries: List + ): AuthMethodSnapshot = AuthMethodSnapshot( + kind = AuthMethodKind.CUSTOM.identifier, + customType = type, + customConfiguration = entries + ) +} diff --git a/sdks/community/kotlin/examples/chatapp-wearos/README.md b/sdks/community/kotlin/examples/chatapp-wearos/README.md new file mode 100644 index 000000000..388146210 --- /dev/null +++ b/sdks/community/kotlin/examples/chatapp-wearos/README.md @@ -0,0 +1,47 @@ +# ChatApp Wear OS Sample + +A standalone Wear OS sample that reuses the `chatapp-shared` Kotlin multiplatform core to deliver an AG-UI chat experience on a watch. The app demonstrates how to consume the shared networking, state, and tool-confirmation layers while rendering a Wear-optimized interface with `androidx.wear.compose:compose-material3`. + +## Highlights +- Connects to AG-UI agents through the `ChatController` exposed by `chatapp-shared` +- Shows streaming responses, tool confirmations, and error recovery in a compact wearable layout +- Lets you add, edit, and activate agents directly on the watch via the built-in agent manager +- Ships as a standalone Wear OS application (no phone companion required) + +## Project Layout +``` +chatapp-wearos/ + ├─ wearApp/ # Wear OS application module + │ ├─ src/main/java/com/agui/example/chatwear/ui + │ └─ src/main/res + ├─ build.gradle.kts # Shared plugin declarations + └─ settings.gradle.kts # Includes chatapp-shared for reuse +``` + +## Configuring a Default Agent +The app can seed an initial agent using Gradle properties. Add the following entries to your `~/.gradle/gradle.properties` (or `local.properties`) file: + +``` +chatapp.wear.defaultAgentUrl=https://your-agent-host/v1 +chatapp.wear.defaultAgentName=Wear Demo Agent +chatapp.wear.defaultAgentDescription=Sample configuration for the Wear OS demo +chatapp.wear.defaultAgentApiKey=sk-your-api-key +chatapp.wear.defaultAgentApiKeyHeader=X-API-Key +chatapp.wear.quickPrompts=Hello|What can you do?|Summarize today’s updates +``` + +Leave the API key fields blank if your agent does not require authentication. When no defaults are provided, open **Manage agents** on the watch to configure one manually. + +## Building & Running + +From the repository root: + +```bash +./gradlew :sdks:community:kotlin:examples:chatapp-wearos:wearApp:assembleDebug +``` + +Use Android Studio Hedgehog (or newer) with a Wear OS emulator or device running API 30+ to install the generated APK. + +## Next Steps +- Point the sample at your own AG-UI agent and experiment with quick prompts +- Extend the UI with Tiles, Complications, or voice input to compose messages hands-free diff --git a/sdks/community/kotlin/examples/chatapp-wearos/build.gradle.kts b/sdks/community/kotlin/examples/chatapp-wearos/build.gradle.kts new file mode 100644 index 000000000..f9f986c08 --- /dev/null +++ b/sdks/community/kotlin/examples/chatapp-wearos/build.gradle.kts @@ -0,0 +1,5 @@ +plugins { + alias(libs.plugins.android.application) apply false + alias(libs.plugins.kotlin.android) apply false + alias(libs.plugins.kotlin.serialization) apply false +} diff --git a/sdks/community/kotlin/examples/chatapp-wearos/gradle.properties b/sdks/community/kotlin/examples/chatapp-wearos/gradle.properties new file mode 100644 index 000000000..3ae0fcb37 --- /dev/null +++ b/sdks/community/kotlin/examples/chatapp-wearos/gradle.properties @@ -0,0 +1,5 @@ +android.useAndroidX=true +org.gradle.jvmargs=-Xmx2g -Dkotlin.daemon.jvm.options=-Xmx2g +chatapp.wear.defaultAgentUrl=https://Oct19Demo.up.railway.app/chat +chatapp.wear.defaultAgentName=ADK +chatapp.wear.quickPrompts=Hello|What can you do?|Summarize today's updates diff --git a/sdks/community/kotlin/examples/chatapp-wearos/gradle/libs.versions.toml b/sdks/community/kotlin/examples/chatapp-wearos/gradle/libs.versions.toml new file mode 100644 index 000000000..bfd072cb9 --- /dev/null +++ b/sdks/community/kotlin/examples/chatapp-wearos/gradle/libs.versions.toml @@ -0,0 +1,108 @@ +[versions] +activity-compose = "1.10.1" +agui-core = "0.2.3" +appcompat = "1.7.1" +core = "1.6.1" +core-ktx = "1.16.0" +junit = "4.13.2" +junit-version = "1.2.1" +kotlin = "2.2.20" +ktor = "3.1.3" +kotlinx-serialization = "1.8.1" +kotlinx-coroutines = "1.10.2" +kotlinx-datetime = "0.6.2" +android-gradle = "8.10.1" +kotlin-logging = "3.0.5" +logback-android = "3.0.0" +multiplatform-settings-coroutines = "1.2.0" +okio = "3.13.0" +runner = "1.6.2" +slf4j = "2.0.9" +voyager-navigator = "1.0.0" +markdown-renderer = "0.37.0" +compose = "1.9.1" +compose-material3 = "1.4.0" +compose-bom = "2024.10.00" +lifecycle = "2.8.7" +wear-compose = "1.5.0" + +[libraries] +activity-compose = { module = "androidx.activity:activity-compose", version.ref = "activity-compose" } +androidx-activity-compose = { module = "androidx.activity:activity-compose", version.ref = "activity-compose" } +agui-client = { module = "com.agui:kotlin-client", version.ref = "agui-core" } +agui-core = { module = "com.agui:kotlin-core", version.ref = "agui-core" } +agui-tools = { module = "com.agui:kotlin-tools", version.ref = "agui-core" } +androidx-ui-tooling = { module = "androidx.compose.ui:ui-tooling", version.ref = "compose" } +appcompat = { module = "androidx.appcompat:appcompat", version.ref = "appcompat" } +core = { module = "androidx.test:core", version.ref = "core" } +core-ktx = { module = "androidx.core:core-ktx", version.ref = "core-ktx" } +androidx-core-ktx = { module = "androidx.core:core-ktx", version.ref = "core-ktx" } +ext-junit = { module = "androidx.test.ext:junit", version.ref = "junit-version" } +junit = { module = "junit:junit", version.ref = "junit" } +ktor-client-core = { module = "io.ktor:ktor-client-core", version.ref = "ktor" } +ktor-client-content-negotiation = { module = "io.ktor:ktor-client-content-negotiation", version.ref = "ktor" } +ktor-serialization-kotlinx-json = { module = "io.ktor:ktor-serialization-kotlinx-json", version.ref = "ktor" } +ktor-client-logging = { module = "io.ktor:ktor-client-logging", version.ref = "ktor" } +ktor-client-android = { module = "io.ktor:ktor-client-android", version.ref = "ktor" } +ktor-client-darwin = { module = "io.ktor:ktor-client-darwin", version.ref = "ktor" } +ktor-client-java = { module = "io.ktor:ktor-client-java", version.ref = "ktor" } +ktor-client-cio = { module = "io.ktor:ktor-client-cio", version.ref = "ktor" } +ktor-client-mock = { module = "io.ktor:ktor-client-mock", version.ref = "ktor" } + +kotlinx-coroutines-core = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-core", version.ref = "kotlinx-coroutines" } +kotlinx-coroutines-test = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-test", version.ref = "kotlinx-coroutines" } +kotlinx-serialization-json = { module = "org.jetbrains.kotlinx:kotlinx-serialization-json", version.ref = "kotlinx-serialization" } +kotlinx-datetime = { module = "org.jetbrains.kotlinx:kotlinx-datetime", version.ref = "kotlinx-datetime" } + +kotlin-logging = { module = "io.github.microutils:kotlin-logging", version.ref = "kotlin-logging" } +logback-android = { module = "com.github.tony19:logback-android", version.ref = "logback-android" } +multiplatform-settings = { module = "com.russhwolf:multiplatform-settings", version.ref = "multiplatform-settings-coroutines" } +multiplatform-settings-coroutines = { module = "com.russhwolf:multiplatform-settings-coroutines", version.ref = "multiplatform-settings-coroutines" } +okio = { module = "com.squareup.okio:okio", version.ref = "okio" } +runner = { module = "androidx.test:runner", version.ref = "runner" } +slf4j-simple = { module = "org.slf4j:slf4j-simple", version.ref = "slf4j" } +androidx-compose-runtime = { module = "androidx.compose.runtime:runtime", version.ref = "compose" } +androidx-compose-runtime-saveable = { module = "androidx.compose.runtime:runtime-saveable", version.ref = "compose" } +androidx-compose-foundation = { module = "androidx.compose.foundation:foundation", version.ref = "compose" } +androidx-compose-material = { module = "androidx.compose.material:material", version.ref = "compose" } +androidx-compose-material3 = { module = "androidx.compose.material3:material3", version.ref = "compose-material3" } +androidx-compose-ui = { module = "androidx.compose.ui:ui", version.ref = "compose" } +androidx-compose-ui-text = { module = "androidx.compose.ui:ui-text", version.ref = "compose" } +androidx-compose-ui-tooling-preview = { module = "androidx.compose.ui:ui-tooling-preview", version.ref = "compose" } +androidx-compose-ui-tooling = { module = "androidx.compose.ui:ui-tooling", version.ref = "compose" } +androidx-compose-ui-test-manifest = { module = "androidx.compose.ui:ui-test-manifest", version.ref = "compose" } +androidx-compose-ui-util = { module = "androidx.compose.ui:ui-util", version.ref = "compose" } +ui-test-junit4 = { module = "androidx.compose.ui:ui-test-junit4", version.ref = "compose" } +voyager-navigator = { module = "cafe.adriel.voyager:voyager-navigator", version.ref = "voyager-navigator" } +voyager-screenmodel = { module = "cafe.adriel.voyager:voyager-screenmodel", version.ref = "voyager-navigator" } +voyager-transitions = { module = "cafe.adriel.voyager:voyager-transitions", version.ref = "voyager-navigator" } +markdown-renderer-m3 = { module = "com.mikepenz:multiplatform-markdown-renderer-m3", version.ref = "markdown-renderer" } +androidx-compose-bom = { module = "androidx.compose:compose-bom", version.ref = "compose-bom" } +androidx-lifecycle-runtime-compose = { module = "androidx.lifecycle:lifecycle-runtime-compose", version.ref = "lifecycle" } +androidx-lifecycle-viewmodel-compose = { module = "androidx.lifecycle:lifecycle-viewmodel-compose", version.ref = "lifecycle" } +wear-compose-foundation = { module = "androidx.wear.compose:compose-foundation", version.ref = "wear-compose" } +wear-compose-material = { module = "androidx.wear.compose:compose-material", version.ref = "wear-compose" } +wear-compose-material3 = { module = "androidx.wear.compose:compose-material3", version.ref = "wear-compose" } +wear-compose-navigation = { module = "androidx.wear.compose:compose-navigation", version.ref = "wear-compose" } + +[plugins] +kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" } +kotlin-serialization = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "kotlin" } +kotlin-compose = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" } +android-application = { id = "com.android.application", version.ref = "android-gradle" } +kotlin-multiplatform = { id = "org.jetbrains.kotlin.multiplatform", version.ref = "kotlin" } +android-library = { id = "com.android.library", version.ref = "android-gradle" } + +[bundles] +ktor-common = [ + "ktor-client-core", + "ktor-client-content-negotiation", + "ktor-serialization-kotlinx-json", + "ktor-client-logging" +] + +kotlinx-common = [ + "kotlinx-coroutines-core", + "kotlinx-serialization-json", + "kotlinx-datetime" +] diff --git a/sdks/community/kotlin/examples/chatapp-wearos/gradle/wrapper/gradle-wrapper.jar b/sdks/community/kotlin/examples/chatapp-wearos/gradle/wrapper/gradle-wrapper.jar new file mode 100644 index 000000000..980502d16 Binary files /dev/null and b/sdks/community/kotlin/examples/chatapp-wearos/gradle/wrapper/gradle-wrapper.jar differ diff --git a/sdks/community/kotlin/examples/chatapp-wearos/gradle/wrapper/gradle-wrapper.properties b/sdks/community/kotlin/examples/chatapp-wearos/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 000000000..128196a7a --- /dev/null +++ b/sdks/community/kotlin/examples/chatapp-wearos/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,7 @@ +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-9.0-milestone-1-bin.zip +networkTimeout=10000 +validateDistributionUrl=true +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists diff --git a/sdks/community/kotlin/examples/chatapp-wearos/gradlew b/sdks/community/kotlin/examples/chatapp-wearos/gradlew new file mode 100755 index 000000000..faf93008b --- /dev/null +++ b/sdks/community/kotlin/examples/chatapp-wearos/gradlew @@ -0,0 +1,251 @@ +#!/bin/sh + +# +# Copyright © 2015-2021 the original authors. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# +# SPDX-License-Identifier: Apache-2.0 +# + +############################################################################## +# +# Gradle start up script for POSIX generated by Gradle. +# +# Important for running: +# +# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is +# noncompliant, but you have some other compliant shell such as ksh or +# bash, then to run this script, type that shell name before the whole +# command line, like: +# +# ksh Gradle +# +# Busybox and similar reduced shells will NOT work, because this script +# requires all of these POSIX shell features: +# * functions; +# * expansions «$var», «${var}», «${var:-default}», «${var+SET}», +# «${var#prefix}», «${var%suffix}», and «$( cmd )»; +# * compound commands having a testable exit status, especially «case»; +# * various built-in commands including «command», «set», and «ulimit». +# +# Important for patching: +# +# (2) This script targets any POSIX shell, so it avoids extensions provided +# by Bash, Ksh, etc; in particular arrays are avoided. +# +# The "traditional" practice of packing multiple parameters into a +# space-separated string is a well documented source of bugs and security +# problems, so this is (mostly) avoided, by progressively accumulating +# options in "$@", and eventually passing that to Java. +# +# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS, +# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly; +# see the in-line comments for details. +# +# There are tweaks for specific operating systems such as AIX, CygWin, +# Darwin, MinGW, and NonStop. +# +# (3) This script is generated from the Groovy template +# https://github.com/gradle/gradle/blob/HEAD/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt +# within the Gradle project. +# +# You can find Gradle at https://github.com/gradle/gradle/. +# +############################################################################## + +# Attempt to set APP_HOME + +# Resolve links: $0 may be a link +app_path=$0 + +# Need this for daisy-chained symlinks. +while + APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path + [ -h "$app_path" ] +do + ls=$( ls -ld "$app_path" ) + link=${ls#*' -> '} + case $link in #( + /*) app_path=$link ;; #( + *) app_path=$APP_HOME$link ;; + esac +done + +# This is normally unused +# shellcheck disable=SC2034 +APP_BASE_NAME=${0##*/} +# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036) +APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || exit + +# Use the maximum available, or set MAX_FD != -1 to use that value. +MAX_FD=maximum + +warn () { + echo "$*" +} >&2 + +die () { + echo + echo "$*" + echo + exit 1 +} >&2 + +# OS specific support (must be 'true' or 'false'). +cygwin=false +msys=false +darwin=false +nonstop=false +case "$( uname )" in #( + CYGWIN* ) cygwin=true ;; #( + Darwin* ) darwin=true ;; #( + MSYS* | MINGW* ) msys=true ;; #( + NONSTOP* ) nonstop=true ;; +esac + +CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar + + +# Determine the Java command to use to start the JVM. +if [ -n "$JAVA_HOME" ] ; then + if [ -x "$JAVA_HOME/jre/sh/java" ] ; then + # IBM's JDK on AIX uses strange locations for the executables + JAVACMD=$JAVA_HOME/jre/sh/java + else + JAVACMD=$JAVA_HOME/bin/java + fi + if [ ! -x "$JAVACMD" ] ; then + die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +else + JAVACMD=java + if ! command -v java >/dev/null 2>&1 + then + die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +fi + +# Increase the maximum file descriptors if we can. +if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then + case $MAX_FD in #( + max*) + # In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + MAX_FD=$( ulimit -H -n ) || + warn "Could not query maximum file descriptor limit" + esac + case $MAX_FD in #( + '' | soft) :;; #( + *) + # In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + ulimit -n "$MAX_FD" || + warn "Could not set maximum file descriptor limit to $MAX_FD" + esac +fi + +# Collect all arguments for the java command, stacking in reverse order: +# * args from the command line +# * the main class name +# * -classpath +# * -D...appname settings +# * --module-path (only if needed) +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables. + +# For Cygwin or MSYS, switch paths to Windows format before running java +if "$cygwin" || "$msys" ; then + APP_HOME=$( cygpath --path --mixed "$APP_HOME" ) + CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" ) + + JAVACMD=$( cygpath --unix "$JAVACMD" ) + + # Now convert the arguments - kludge to limit ourselves to /bin/sh + for arg do + if + case $arg in #( + -*) false ;; # don't mess with options #( + /?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath + [ -e "$t" ] ;; #( + *) false ;; + esac + then + arg=$( cygpath --path --ignore --mixed "$arg" ) + fi + # Roll the args list around exactly as many times as the number of + # args, so each arg winds up back in the position where it started, but + # possibly modified. + # + # NB: a `for` loop captures its iteration list before it begins, so + # changing the positional parameters here affects neither the number of + # iterations, nor the values presented in `arg`. + shift # remove old arg + set -- "$@" "$arg" # push replacement arg + done +fi + + +# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' + +# Collect all arguments for the java command: +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments, +# and any embedded shellness will be escaped. +# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be +# treated as '${Hostname}' itself on the command line. + +set -- \ + "-Dorg.gradle.appname=$APP_BASE_NAME" \ + -classpath "$CLASSPATH" \ + org.gradle.wrapper.GradleWrapperMain \ + "$@" + +# Stop when "xargs" is not available. +if ! command -v xargs >/dev/null 2>&1 +then + die "xargs is not available" +fi + +# Use "xargs" to parse quoted args. +# +# With -n1 it outputs one arg per line, with the quotes and backslashes removed. +# +# In Bash we could simply go: +# +# readarray ARGS < <( xargs -n1 <<<"$var" ) && +# set -- "${ARGS[@]}" "$@" +# +# but POSIX shell has neither arrays nor command substitution, so instead we +# post-process each arg (as a line of input to sed) to backslash-escape any +# character that might be a shell metacharacter, then use eval to reverse +# that process (while maintaining the separation between arguments), and wrap +# the whole thing up as a single "set" statement. +# +# This will of course break if any of these variables contains a newline or +# an unmatched quote. +# + +eval "set -- $( + printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" | + xargs -n1 | + sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' | + tr '\n' ' ' + )" '"$@"' + +exec "$JAVACMD" "$@" diff --git a/sdks/community/kotlin/examples/chatapp-wearos/gradlew.bat b/sdks/community/kotlin/examples/chatapp-wearos/gradlew.bat new file mode 100644 index 000000000..9b42019c7 --- /dev/null +++ b/sdks/community/kotlin/examples/chatapp-wearos/gradlew.bat @@ -0,0 +1,94 @@ +@rem +@rem Copyright 2015 the original author or authors. +@rem +@rem Licensed under the Apache License, Version 2.0 (the "License"); +@rem you may not use this file except in compliance with the License. +@rem You may obtain a copy of the License at +@rem +@rem https://www.apache.org/licenses/LICENSE-2.0 +@rem +@rem Unless required by applicable law or agreed to in writing, software +@rem distributed under the License is distributed on an "AS IS" BASIS, +@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +@rem See the License for the specific language governing permissions and +@rem limitations under the License. +@rem +@rem SPDX-License-Identifier: Apache-2.0 +@rem + +@if "%DEBUG%"=="" @echo off +@rem ########################################################################## +@rem +@rem Gradle startup script for Windows +@rem +@rem ########################################################################## + +@rem Set local scope for the variables with windows NT shell +if "%OS%"=="Windows_NT" setlocal + +set DIRNAME=%~dp0 +if "%DIRNAME%"=="" set DIRNAME=. +@rem This is normally unused +set APP_BASE_NAME=%~n0 +set APP_HOME=%DIRNAME% + +@rem Resolve any "." and ".." in APP_HOME to make it shorter. +for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi + +@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" + +@rem Find java.exe +if defined JAVA_HOME goto findJavaFromJavaHome + +set JAVA_EXE=java.exe +%JAVA_EXE% -version >NUL 2>&1 +if %ERRORLEVEL% equ 0 goto execute + +echo. 1>&2 +echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 + +goto fail + +:findJavaFromJavaHome +set JAVA_HOME=%JAVA_HOME:"=% +set JAVA_EXE=%JAVA_HOME%/bin/java.exe + +if exist "%JAVA_EXE%" goto execute + +echo. 1>&2 +echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 + +goto fail + +:execute +@rem Setup the command line + +set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar + + +@rem Execute Gradle +"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %* + +:end +@rem End local scope for the variables with windows NT shell +if %ERRORLEVEL% equ 0 goto mainEnd + +:fail +rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of +rem the _cmd.exe /c_ return code! +set EXIT_CODE=%ERRORLEVEL% +if %EXIT_CODE% equ 0 set EXIT_CODE=1 +if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE% +exit /b %EXIT_CODE% + +:mainEnd +if "%OS%"=="Windows_NT" endlocal + +:omega diff --git a/sdks/community/kotlin/examples/chatapp-wearos/settings.gradle.kts b/sdks/community/kotlin/examples/chatapp-wearos/settings.gradle.kts new file mode 100644 index 000000000..a90038cdd --- /dev/null +++ b/sdks/community/kotlin/examples/chatapp-wearos/settings.gradle.kts @@ -0,0 +1,24 @@ +rootProject.name = "chatapp-wearos" + +include(":wearApp") +include(":chatapp-shared") +project(":chatapp-shared").projectDir = file("../chatapp-shared") + +pluginManagement { + repositories { + google() + gradlePluginPortal() + mavenCentral() + maven("https://maven.pkg.jetbrains.space/public/p/compose/dev") + mavenLocal() + } +} + +dependencyResolutionManagement { + repositories { + google() + mavenCentral() + maven("https://maven.pkg.jetbrains.space/public/p/compose/dev") + mavenLocal() + } +} diff --git a/sdks/community/kotlin/examples/chatapp-wearos/wearApp/build.gradle.kts b/sdks/community/kotlin/examples/chatapp-wearos/wearApp/build.gradle.kts new file mode 100644 index 000000000..032700c07 --- /dev/null +++ b/sdks/community/kotlin/examples/chatapp-wearos/wearApp/build.gradle.kts @@ -0,0 +1,94 @@ +plugins { + alias(libs.plugins.android.application) + alias(libs.plugins.kotlin.android) + alias(libs.plugins.kotlin.compose) +} + +android { + namespace = "com.agui.example.chatwear" + compileSdk = 36 + + val defaultAgentUrl = (project.findProperty("chatapp.wear.defaultAgentUrl") as? String).orEmpty() + val defaultAgentName = (project.findProperty("chatapp.wear.defaultAgentName") as? String).orEmpty() + val defaultAgentDescription = (project.findProperty("chatapp.wear.defaultAgentDescription") as? String).orEmpty() + val defaultAgentApiKey = (project.findProperty("chatapp.wear.defaultAgentApiKey") as? String).orEmpty() + val defaultAgentApiKeyHeader = (project.findProperty("chatapp.wear.defaultAgentApiKeyHeader") as? String).orEmpty() + val defaultQuickPrompts = (project.findProperty("chatapp.wear.quickPrompts") as? String) + ?: "Hello there|Summarize the latest updates|Show a fun fact" + + fun String.escapeForBuildConfig(): String = + this.replace("\\", "\\\\") + .replace("\"", "\\\"") + + defaultConfig { + applicationId = "com.agui.example.chatwear" + minSdk = 30 + targetSdk = 36 + versionCode = 1 + versionName = "1.0" + + buildConfigField("String", "DEFAULT_AGENT_URL", "\"${defaultAgentUrl.escapeForBuildConfig()}\"") + buildConfigField("String", "DEFAULT_AGENT_NAME", "\"${defaultAgentName.escapeForBuildConfig()}\"") + buildConfigField("String", "DEFAULT_AGENT_DESCRIPTION", "\"${defaultAgentDescription.escapeForBuildConfig()}\"") + buildConfigField("String", "DEFAULT_AGENT_API_KEY", "\"${defaultAgentApiKey.escapeForBuildConfig()}\"") + buildConfigField("String", "DEFAULT_AGENT_API_KEY_HEADER", "\"${defaultAgentApiKeyHeader.escapeForBuildConfig()}\"") + buildConfigField("String", "DEFAULT_QUICK_PROMPTS", "\"${defaultQuickPrompts.escapeForBuildConfig()}\"") + } + + buildFeatures { + compose = true + buildConfig = true + } + + composeOptions { + kotlinCompilerExtensionVersion = "1.5.15" + } + + packaging { + resources { + excludes += "/META-INF/{AL2.0,LGPL2.1}" + } + } + + buildTypes { + getByName("release") { + isMinifyEnabled = false + } + } + + compileOptions { + sourceCompatibility = JavaVersion.VERSION_21 + targetCompatibility = JavaVersion.VERSION_21 + } +} + +kotlin { + jvmToolchain(21) +} + +dependencies { + implementation(project(":chatapp-shared")) + + implementation(libs.androidx.core.ktx) + implementation(libs.androidx.activity.compose) + implementation(libs.androidx.lifecycle.runtime.compose) + implementation(libs.androidx.lifecycle.viewmodel.compose) + + implementation(platform(libs.androidx.compose.bom)) + implementation(libs.androidx.compose.runtime) + implementation(libs.androidx.compose.ui) + implementation(libs.androidx.compose.ui.text) + implementation(libs.androidx.compose.foundation) + implementation(libs.androidx.compose.material3) + implementation(libs.androidx.compose.ui.util) + implementation(libs.androidx.core.ktx) + + implementation(libs.wear.compose.foundation) + implementation(libs.wear.compose.material) + implementation(libs.wear.compose.material3) + implementation(libs.wear.compose.navigation) + implementation(libs.markdown.renderer.m3) + + debugImplementation(libs.androidx.compose.ui.tooling) + debugImplementation(libs.androidx.compose.ui.tooling.preview) +} diff --git a/sdks/community/kotlin/examples/chatapp-wearos/wearApp/src/main/AndroidManifest.xml b/sdks/community/kotlin/examples/chatapp-wearos/wearApp/src/main/AndroidManifest.xml new file mode 100644 index 000000000..89eba899a --- /dev/null +++ b/sdks/community/kotlin/examples/chatapp-wearos/wearApp/src/main/AndroidManifest.xml @@ -0,0 +1,33 @@ + + + + + + + + + + + + + + + + + + + + diff --git a/sdks/community/kotlin/examples/chatapp-wearos/wearApp/src/main/java/com/agui/example/chatwear/MainActivity.kt b/sdks/community/kotlin/examples/chatapp-wearos/wearApp/src/main/java/com/agui/example/chatwear/MainActivity.kt new file mode 100644 index 000000000..ea23d38f6 --- /dev/null +++ b/sdks/community/kotlin/examples/chatapp-wearos/wearApp/src/main/java/com/agui/example/chatwear/MainActivity.kt @@ -0,0 +1,18 @@ +package com.agui.example.chatwear + +import android.os.Bundle +import androidx.activity.ComponentActivity +import androidx.activity.compose.setContent +import com.agui.example.chatwear.ui.ChatWearApp +import com.agui.example.chatapp.util.initializeAndroid + +class MainActivity : ComponentActivity() { + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + initializeAndroid(this) + + setContent { + ChatWearApp() + } + } +} diff --git a/sdks/community/kotlin/examples/chatapp-wearos/wearApp/src/main/java/com/agui/example/chatwear/ui/ChatWearApp.kt b/sdks/community/kotlin/examples/chatapp-wearos/wearApp/src/main/java/com/agui/example/chatwear/ui/ChatWearApp.kt new file mode 100644 index 000000000..dc53c7122 --- /dev/null +++ b/sdks/community/kotlin/examples/chatapp-wearos/wearApp/src/main/java/com/agui/example/chatwear/ui/ChatWearApp.kt @@ -0,0 +1,852 @@ +package com.agui.example.chatwear.ui + +import androidx.compose.foundation.background +import androidx.compose.foundation.horizontalScroll +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.material3.AssistChip +import androidx.compose.material3.AssistChipDefaults +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.LocalContentColor +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.OutlinedTextFieldDefaults +import androidx.compose.material3.ProvideTextStyle +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.foundation.text.KeyboardActions +import androidx.compose.foundation.text.KeyboardOptions +import com.mikepenz.markdown.m3.markdownColor +import androidx.compose.ui.platform.LocalSoftwareKeyboardController +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import androidx.lifecycle.viewmodel.compose.viewModel +import androidx.wear.compose.foundation.lazy.ScalingLazyColumn +import androidx.wear.compose.foundation.lazy.items +import androidx.wear.compose.foundation.lazy.rememberScalingLazyListState +import androidx.wear.compose.material3.Button +import androidx.wear.compose.material3.ButtonDefaults +import androidx.wear.compose.material3.MaterialTheme as WearMaterialTheme +import androidx.wear.compose.material3.Text as WearText +import androidx.wear.compose.material.PositionIndicator +import androidx.wear.compose.material.Scaffold +import androidx.wear.compose.material.TimeText +import androidx.wear.compose.material.Vignette +import androidx.wear.compose.material.VignettePosition +import com.agui.example.chatwear.R +import com.agui.example.chatapp.chat.DisplayMessage +import com.agui.example.chatapp.chat.MessageRole +import com.agui.example.chatapp.data.model.AgentConfig +import com.agui.example.chatapp.data.model.AuthMethod +import com.mikepenz.markdown.m3.Markdown +import com.agui.example.chatwear.ui.theme.ChatWearTheme +import androidx.compose.ui.text.input.ImeAction +import com.agui.example.tools.BackgroundStyle + +@Composable +fun ChatWearApp( + modifier: Modifier = Modifier, + viewModel: WearChatViewModel = viewModel() +) { + val chatState by viewModel.chatState.collectAsStateWithLifecycle() + val activeAgent by viewModel.activeAgent.collectAsStateWithLifecycle() + val agents by viewModel.agents.collectAsStateWithLifecycle() + var inputValue by rememberSaveable { mutableStateOf("") } + val quickPrompts = viewModel.quickPrompts + val listState = rememberScalingLazyListState() + var showAgentManager by rememberSaveable { mutableStateOf(false) } + + ChatWearTheme { + if (showAgentManager) { + AgentManagerScreen( + agents = agents, + activeAgent = activeAgent, + onClose = { showAgentManager = false }, + onCreateAgent = viewModel::createAgent, + onUpdateAgent = viewModel::updateAgent, + onDeleteAgent = viewModel::deleteAgent, + onActivateAgent = viewModel::selectAgent + ) + return@ChatWearTheme + } + + val defaultBackground = WearMaterialTheme.colorScheme.background + val backgroundColor = remember(chatState.background, defaultBackground) { + chatState.background.toWearColor(defaultBackground) + } + + Scaffold( + modifier = modifier + .fillMaxSize() + .background(backgroundColor), + timeText = { TimeText() }, + vignette = { + if (chatState.messages.isNotEmpty()) { + Vignette(vignettePosition = VignettePosition.TopAndBottom) + } + }, + positionIndicator = { PositionIndicator(listState) } + ) { + ScalingLazyColumn( + modifier = Modifier + .fillMaxSize() + .background(backgroundColor), + state = listState, + contentPadding = PaddingValues(horizontal = 12.dp, vertical = 16.dp), + verticalArrangement = Arrangement.spacedBy(10.dp) + ) { + item { + AgentStatusCard( + activeAgent = activeAgent, + agents = agents, + isConnected = chatState.isConnected, + onNextAgent = viewModel::selectAgent, + onOpenManager = { showAgentManager = true } + ) + } + + chatState.error?.let { error -> + item { + ErrorCard( + message = error, + onDismiss = viewModel::clearError, + onRetry = { + activeAgent?.let(viewModel::selectAgent) + } + ) + } + } + + items(chatState.messages) { message -> + MessageBubble(message = message) + } + + chatState.ephemeralMessage?.let { ephemeral -> + item { + MessageBubble(message = ephemeral, isEphemeral = true) + } + } + + item { + ChatInputCard( + value = inputValue, + onValueChange = { inputValue = it }, + enabled = activeAgent != null, + onSend = { + val trimmed = inputValue.trim() + if (trimmed.isNotEmpty()) { + viewModel.sendMessage(trimmed) + inputValue = "" + } + } + ) + } + + if (quickPrompts.isNotEmpty()) { + item { + QuickPromptRow( + prompts = quickPrompts, + onPromptSelected = viewModel::sendMessage + ) + } + } + + if (chatState.isLoading) { + item { + LoadingIndicator() + } + } + } + } + } +} + +private fun BackgroundStyle.toWearColor(default: Color): Color { + val hex = colorHex?.removePrefix("#") ?: return default + return when (hex.length) { + 6 -> hex.toLongOrNull(16)?.let { Color((0xFF000000 or it).toInt()) } ?: default + 8 -> { + val rgb = hex.substring(0, 6) + val alpha = hex.substring(6, 8) + val argb = (alpha + rgb).toLongOrNull(16) ?: return default + Color(argb.toInt()) + } + else -> default + } +} + +@Composable +private fun AgentStatusCard( + activeAgent: AgentConfig?, + agents: List, + isConnected: Boolean, + onNextAgent: (AgentConfig) -> Unit, + onOpenManager: () -> Unit, + modifier: Modifier = Modifier +) { + val statusText = when { + activeAgent == null -> "No agent configured" + isConnected -> "Connected" + else -> "Ready" + } + val canCycle = activeAgent != null && agents.size > 1 + Surface( + modifier = modifier.fillMaxWidth(), + color = WearMaterialTheme.colorScheme.surfaceContainer, + contentColor = WearMaterialTheme.colorScheme.onSurface, // <-- FIX 1: Force the correct default + tonalElevation = 6.dp + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 12.dp, vertical = 10.dp) + ) { + Text( + text = activeAgent?.name ?: "Agent", + style = WearMaterialTheme.typography.titleMedium, + maxLines = 1, + overflow = TextOverflow.Ellipsis + // This will now default to the readable 'onSurface' + ) + Spacer(modifier = Modifier.height(4.dp)) + Text( + text = statusText, + style = WearMaterialTheme.typography.bodySmall, + color = WearMaterialTheme.colorScheme.onSurface.copy(alpha = 0.8f) // <-- FIX 2: Use readable 'onSurface' + ) + if (activeAgent == null) { + Spacer(modifier = Modifier.height(6.dp)) + Text( + text = "Set chatapp.wear.defaultAgentUrl in gradle.properties to seed an agent.", + style = WearMaterialTheme.typography.bodySmall, + color = WearMaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 3 + ) + } + Spacer(modifier = Modifier.height(6.dp)) + Button( + onClick = onOpenManager, + modifier = Modifier.fillMaxWidth(), + colors = ButtonDefaults.filledTonalButtonColors( + containerColor = WearMaterialTheme.colorScheme.secondary, + contentColor = WearMaterialTheme.colorScheme.onSecondary + ) + ) { + WearText(text = stringResource(id = R.string.manage_agents)) + } + if (canCycle) { + Spacer(modifier = Modifier.height(6.dp)) + Button( + onClick = { + val index = agents.indexOfFirst { it.id == activeAgent.id } + val next = agents.getOrNull((index + 1) % agents.size) + if (next != null) { + onNextAgent(next) + } + }, + modifier = Modifier.fillMaxWidth(), + colors = ButtonDefaults.filledTonalButtonColors( + containerColor = WearMaterialTheme.colorScheme.tertiary, + contentColor = WearMaterialTheme.colorScheme.onTertiary + ) + ) { + WearText(text = stringResource(id = R.string.switch_agent_button)) + } + } + } + } +} + +@Composable +private fun AgentManagerScreen( + agents: List, + activeAgent: AgentConfig?, + onClose: () -> Unit, + onCreateAgent: (name: String, url: String, description: String, apiKey: String, apiKeyHeader: String) -> Unit, + onUpdateAgent: (agent: AgentConfig, name: String, url: String, description: String, apiKey: String, apiKeyHeader: String) -> Unit, + onDeleteAgent: (AgentConfig) -> Unit, + onActivateAgent: (AgentConfig) -> Unit, + modifier: Modifier = Modifier +) { + val listState = rememberScalingLazyListState() + var showForm by rememberSaveable { mutableStateOf(false) } + var editingAgent by remember { mutableStateOf(null) } + + Scaffold( + modifier = modifier.fillMaxSize(), + timeText = { TimeText() }, + positionIndicator = { PositionIndicator(listState) }, + vignette = { + if (agents.isNotEmpty()) { + Vignette(vignettePosition = VignettePosition.TopAndBottom) + } + } + ) { + ScalingLazyColumn( + modifier = Modifier.fillMaxSize(), + state = listState, + contentPadding = PaddingValues(horizontal = 12.dp, vertical = 16.dp), + verticalArrangement = Arrangement.spacedBy(10.dp) + ) { + item { + Surface( + modifier = Modifier.fillMaxWidth(), + color = WearMaterialTheme.colorScheme.surfaceContainerHigh, + tonalElevation = 6.dp + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(12.dp), + verticalArrangement = Arrangement.spacedBy(6.dp) + ) { + Text( + text = stringResource(id = R.string.agent_manager_title), + style = WearMaterialTheme.typography.titleMedium + ) + Text( + text = stringResource(id = R.string.agent_manager_description), + style = WearMaterialTheme.typography.bodySmall, + color = WearMaterialTheme.colorScheme.onSurfaceVariant + ) + Button( + onClick = onClose, + modifier = Modifier.fillMaxWidth(), + colors = ButtonDefaults.filledTonalButtonColors() + ) { + WearText(text = stringResource(id = R.string.back_to_chat)) + } + } + } + } + + if (showForm) { + item { + AgentEditorCard( + agent = editingAgent, + onSubmit = { name, url, description, apiKey, apiKeyHeader -> + val target = editingAgent + if (target == null) { + onCreateAgent(name, url, description, apiKey, apiKeyHeader) + } else { + onUpdateAgent(target, name, url, description, apiKey, apiKeyHeader) + } + showForm = false + editingAgent = null + }, + onCancel = { + showForm = false + editingAgent = null + } + ) + } + } else { + item { + Button( + onClick = { + editingAgent = null + showForm = true + }, + modifier = Modifier.fillMaxWidth(), + colors = ButtonDefaults.filledTonalButtonColors() + ) { + WearText(text = stringResource(id = R.string.add_agent)) + } + } + } + + items(agents) { agent -> + AgentRow( + agent = agent, + isActive = agent.id == activeAgent?.id, + onActivate = { onActivateAgent(agent) }, + onEdit = { + editingAgent = agent + showForm = true + }, + onDelete = { onDeleteAgent(agent) } + ) + } + + if (agents.isEmpty() && !showForm) { + item { + Text( + text = stringResource(id = R.string.no_agents_message), + style = WearMaterialTheme.typography.bodyMedium, + color = WearMaterialTheme.colorScheme.onSurfaceVariant + ) + } + } + } + } +} + +@Composable +private fun AgentEditorCard( + agent: AgentConfig?, + onSubmit: (name: String, url: String, description: String, apiKey: String, apiKeyHeader: String) -> Unit, + onCancel: () -> Unit, + modifier: Modifier = Modifier +) { + var name by remember(agent) { mutableStateOf(agent?.name.orEmpty()) } + var url by remember(agent) { mutableStateOf(agent?.url.orEmpty()) } + var description by remember(agent) { mutableStateOf(agent?.description.orEmpty()) } + var apiKey by remember(agent) { mutableStateOf((agent?.authMethod as? AuthMethod.ApiKey)?.key.orEmpty()) } + var apiKeyHeader by remember(agent) { mutableStateOf((agent?.authMethod as? AuthMethod.ApiKey)?.headerName.orEmpty()) } + + Surface( + modifier = modifier.fillMaxWidth(), + color = WearMaterialTheme.colorScheme.surfaceContainer, + tonalElevation = 6.dp + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(10.dp), + verticalArrangement = Arrangement.spacedBy(6.dp) + ) { + Text( + text = if (agent == null) stringResource(id = R.string.add_agent) else stringResource(id = R.string.edit_agent), + style = WearMaterialTheme.typography.titleMedium + ) + WearTextField( + label = stringResource(id = R.string.agent_name), + value = name, + onValueChange = { name = it } + ) + WearTextField( + label = stringResource(id = R.string.agent_url), + value = url, + onValueChange = { url = it } + ) + WearTextField( + label = stringResource(id = R.string.agent_description), + value = description, + onValueChange = { description = it }, + singleLine = false + ) + WearTextField( + label = stringResource(id = R.string.agent_api_key), + value = apiKey, + onValueChange = { apiKey = it } + ) + WearTextField( + label = stringResource(id = R.string.agent_api_key_header), + value = apiKeyHeader, + onValueChange = { apiKeyHeader = it } + ) + + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { + Button( + onClick = { onSubmit(name, url, description, apiKey, apiKeyHeader) }, + modifier = Modifier.weight(1f), + enabled = name.isNotBlank() && url.isNotBlank() + ) { + WearText(text = stringResource(id = R.string.save_agent)) + } + TextButton(onClick = onCancel, modifier = Modifier.weight(1f)) { + Text(text = stringResource(id = R.string.cancel_operation)) + } + } + } + } +} + +@Composable +private fun WearTextField( + label: String, + value: String, + onValueChange: (String) -> Unit, + modifier: Modifier = Modifier, + singleLine: Boolean = true +) { + OutlinedTextField( + value = value, + onValueChange = onValueChange, + singleLine = singleLine, + maxLines = if (singleLine) 1 else 4, + label = { Text(text = label) }, + textStyle = WearMaterialTheme.typography.bodyMedium, + colors = OutlinedTextFieldDefaults.colors( + focusedContainerColor = WearMaterialTheme.colorScheme.surfaceContainer, + unfocusedContainerColor = WearMaterialTheme.colorScheme.surfaceContainer, + disabledContainerColor = WearMaterialTheme.colorScheme.surfaceContainer, + focusedTextColor = WearMaterialTheme.colorScheme.onSurface, + unfocusedTextColor = WearMaterialTheme.colorScheme.onSurface, + disabledTextColor = WearMaterialTheme.colorScheme.onSurface.copy(alpha = 0.4f), + focusedBorderColor = WearMaterialTheme.colorScheme.primary, + unfocusedBorderColor = WearMaterialTheme.colorScheme.outline + ), + modifier = modifier.fillMaxWidth() + ) +} + +@Composable +private fun AgentRow( + agent: AgentConfig, + isActive: Boolean, + onActivate: () -> Unit, + onEdit: () -> Unit, + onDelete: () -> Unit, + modifier: Modifier = Modifier +) { + Surface( + modifier = modifier.fillMaxWidth(), + color = WearMaterialTheme.colorScheme.surfaceContainerHigh, + tonalElevation = 6.dp + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(10.dp), + verticalArrangement = Arrangement.spacedBy(6.dp) + ) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Text( + text = agent.name, + style = WearMaterialTheme.typography.titleMedium, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + if (isActive) { + Text( + text = stringResource(id = R.string.active_agent_badge), + style = WearMaterialTheme.typography.labelSmall, + color = WearMaterialTheme.colorScheme.primary + ) + } + } + Text( + text = agent.url, + style = WearMaterialTheme.typography.bodySmall, + maxLines = 2, + overflow = TextOverflow.Ellipsis, + color = WearMaterialTheme.colorScheme.onSurfaceVariant + ) + agent.description?.takeIf { it.isNotBlank() }?.let { desc -> + Text( + text = desc, + style = WearMaterialTheme.typography.bodySmall, + maxLines = 2, + overflow = TextOverflow.Ellipsis + ) + } + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { + Button( + onClick = onActivate, + modifier = Modifier.weight(1f), + enabled = !isActive + ) { + WearText(text = stringResource(id = R.string.make_active)) + } + Button( + onClick = onEdit, + modifier = Modifier.weight(1f), + colors = ButtonDefaults.filledTonalButtonColors() + ) { + WearText(text = stringResource(id = R.string.edit)) + } + Button( + onClick = onDelete, + modifier = Modifier.weight(1f), + colors = ButtonDefaults.filledTonalButtonColors(containerColor = WearMaterialTheme.colorScheme.errorContainer) + ) { + WearText(text = stringResource(id = R.string.delete)) + } + } + } + } +} + +@Composable +private fun MessageBubble( + message: DisplayMessage, + modifier: Modifier = Modifier, + isEphemeral: Boolean = false +) { + // This part is correct: get the background and (guaranteed readable) content color + val (background, contentColor) = when (message.role) { + MessageRole.USER -> WearMaterialTheme.colorScheme.primary to WearMaterialTheme.colorScheme.onPrimary + MessageRole.ASSISTANT -> WearMaterialTheme.colorScheme.surfaceContainerHigh to WearMaterialTheme.colorScheme.onSurface + MessageRole.SYSTEM, MessageRole.DEVELOPER -> WearMaterialTheme.colorScheme.surfaceContainer to WearMaterialTheme.colorScheme.onSurface + MessageRole.ERROR -> WearMaterialTheme.colorScheme.error to WearMaterialTheme.colorScheme.onError + MessageRole.TOOL_CALL -> WearMaterialTheme.colorScheme.tertiary to WearMaterialTheme.colorScheme.onTertiary + MessageRole.STEP_INFO -> WearMaterialTheme.colorScheme.surfaceContainerLow to WearMaterialTheme.colorScheme.onSurfaceVariant + } + + Card( + modifier = modifier.fillMaxWidth(), + colors = CardDefaults.cardColors( + containerColor = background, + contentColor = contentColor // This provides the correct LocalContentColor + ) + ) { + Column(modifier = Modifier.padding(10.dp)) { + Text( + text = message.role.name.lowercase().replaceFirstChar { it.uppercase() }, + style = WearMaterialTheme.typography.labelSmall, + color = LocalContentColor.current.copy(alpha = 0.8f) + ) + Spacer(modifier = Modifier.height(4.dp)) + when { + // This "streaming" block is for *ephemeral* messages and is fine. + // Your "System" message was not ephemeral, it was a regular message. + message.isStreaming -> { + Text( + text = message.content, + style = WearMaterialTheme.typography.bodyMedium, + color = LocalContentColor.current, + maxLines = 6, + overflow = TextOverflow.Ellipsis + ) + } + + // This "ephemeral" block is also fine. + isEphemeral -> { + Text( + text = message.content, + style = WearMaterialTheme.typography.bodyMedium, + color = LocalContentColor.current, + maxLines = 6, + overflow = TextOverflow.Ellipsis + ) + } + + else -> { + // This is the readable contentColor (e.g., onSurface) + // guaranteed by our theme fix in Part 1 + val textColor = contentColor + + // 2. Only use Markdown for the Assistant + if (message.role == MessageRole.ASSISTANT) { + // --- THIS IS THE FIX --- + // Use the correct markdownColor function + Markdown( + content = message.content, + modifier = Modifier.fillMaxWidth(), + colors = markdownColor( + text = textColor, + codeBackground = WearMaterialTheme.colorScheme.surfaceContainerLow, + inlineCodeBackground = WearMaterialTheme.colorScheme.surfaceContainerLow, + dividerColor = textColor.copy(alpha = 0.3f), + tableBackground = Color.Transparent + ) + ) + } else { + // 3. Use plain, readable Text for SYSTEM (and others) + // This was the fix from last time and it works. + ProvideTextStyle( + WearMaterialTheme.typography.bodyMedium.copy(color = textColor) + ) { + Text( + text = message.content + ) + } + } + } + } + + if (isEphemeral || message.isStreaming) { + Spacer(modifier = Modifier.height(4.dp)) + Text( + text = if (message.isStreaming) "Streaming…" else "Ephemeral", + style = WearMaterialTheme.typography.bodySmall, + color = LocalContentColor.current.copy(alpha = 0.7f) + ) + } + } + } +} + +@Composable +private fun ChatInputCard( + value: String, + onValueChange: (String) -> Unit, + enabled: Boolean, + onSend: () -> Unit, + modifier: Modifier = Modifier +) { + Card( + modifier = modifier.fillMaxWidth(), + colors = CardDefaults.cardColors(containerColor = WearMaterialTheme.colorScheme.surfaceContainer) + ) { + val keyboardController = LocalSoftwareKeyboardController.current + Column( + modifier = Modifier + .fillMaxWidth() + .padding(10.dp), + verticalArrangement = Arrangement.spacedBy(6.dp) + ) { + OutlinedTextField( + value = value, + onValueChange = onValueChange, + enabled = enabled, + singleLine = false, + maxLines = 3, + textStyle = WearMaterialTheme.typography.bodyMedium, + placeholder = { + Text( + text = stringResource(id = R.string.chat_input_hint), + color = WearMaterialTheme.colorScheme.onSurfaceVariant + ) + }, + keyboardOptions = KeyboardOptions(imeAction = ImeAction.Send), + keyboardActions = KeyboardActions( + onSend = { + if (enabled && value.isNotBlank()) { + onSend() + keyboardController?.hide() + } + } + ), + colors = OutlinedTextFieldDefaults.colors( + focusedContainerColor = WearMaterialTheme.colorScheme.surfaceContainer, + unfocusedContainerColor = WearMaterialTheme.colorScheme.surfaceContainer, + disabledContainerColor = WearMaterialTheme.colorScheme.surfaceContainer, + focusedTextColor = WearMaterialTheme.colorScheme.onSurface, + unfocusedTextColor = WearMaterialTheme.colorScheme.onSurface, + disabledTextColor = WearMaterialTheme.colorScheme.onSurface.copy(alpha = 0.4f), + focusedBorderColor = WearMaterialTheme.colorScheme.primary, + unfocusedBorderColor = WearMaterialTheme.colorScheme.outline + ), + modifier = Modifier.fillMaxWidth() + ) + Button( + onClick = { + onSend() + keyboardController?.hide() + }, + enabled = enabled && value.isNotBlank(), + modifier = Modifier.fillMaxWidth(), + colors = ButtonDefaults.filledTonalButtonColors( + containerColor = WearMaterialTheme.colorScheme.primary, + contentColor =WearMaterialTheme.colorScheme.onPrimary + ) + ) { + WearText(text = "Send") + } + } + } +} + +@Composable +private fun QuickPromptRow( + prompts: List, + onPromptSelected: (String) -> Unit, + modifier: Modifier = Modifier +) { + Column(modifier = modifier.fillMaxWidth()) { + Text( + text = stringResource(id = R.string.chip_prompt_summary), + style = WearMaterialTheme.typography.labelSmall, + color = WearMaterialTheme.colorScheme.onSurfaceVariant + ) + Spacer(modifier = Modifier.height(6.dp)) + Row( + modifier = Modifier + .fillMaxWidth() + .horizontalScroll(rememberScrollState()), + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { + prompts.forEach { prompt -> + AssistChip( + onClick = { onPromptSelected(prompt) }, + label = { Text(text = prompt, maxLines = 1, overflow = TextOverflow.Ellipsis) }, + colors = AssistChipDefaults.assistChipColors( + containerColor = WearMaterialTheme.colorScheme.secondaryContainer, + labelColor = WearMaterialTheme.colorScheme.onSecondaryContainer + ) + ) + } + } + } +} + +@Composable +private fun ErrorCard( + message: String, + onDismiss: () -> Unit, + onRetry: () -> Unit, + modifier: Modifier = Modifier +) { + Card( + modifier = modifier.fillMaxWidth(), + colors = CardDefaults.cardColors(containerColor = WearMaterialTheme.colorScheme.errorContainer) + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(10.dp), + verticalArrangement = Arrangement.spacedBy(6.dp) + ) { + Text( + text = message, + style = WearMaterialTheme.typography.bodyMedium, + color = WearMaterialTheme.colorScheme.onErrorContainer + ) + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { + TextButton(onClick = onDismiss, modifier = Modifier.weight(1f)) { + Text(text = "Dismiss", color = WearMaterialTheme.colorScheme.onErrorContainer) + } + Button( + onClick = onRetry, + modifier = Modifier.weight(1f), + colors = ButtonDefaults.filledTonalButtonColors( + containerColor = WearMaterialTheme.colorScheme.onErrorContainer, + contentColor = WearMaterialTheme.colorScheme.errorContainer + ) + ) { + WearText(text = stringResource(id = R.string.retry)) + } + } + } + } +} + +@Composable +private fun LoadingIndicator(modifier: Modifier = Modifier) { + Box( + modifier = modifier + .fillMaxWidth() + .padding(vertical = 8.dp), + contentAlignment = Alignment.Center + ) { + CircularProgressIndicator(color = WearMaterialTheme.colorScheme.primary) + } +} diff --git a/sdks/community/kotlin/examples/chatapp-wearos/wearApp/src/main/java/com/agui/example/chatwear/ui/WearChatViewModel.kt b/sdks/community/kotlin/examples/chatapp-wearos/wearApp/src/main/java/com/agui/example/chatwear/ui/WearChatViewModel.kt new file mode 100644 index 000000000..a10630578 --- /dev/null +++ b/sdks/community/kotlin/examples/chatapp-wearos/wearApp/src/main/java/com/agui/example/chatwear/ui/WearChatViewModel.kt @@ -0,0 +1,161 @@ +package com.agui.example.chatwear.ui + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.agui.example.chatwear.BuildConfig +import com.agui.example.chatapp.chat.ChatController +import com.agui.example.chatapp.chat.ChatState +import com.agui.example.chatapp.data.model.AgentConfig +import com.agui.example.chatapp.data.model.AuthMethod +import com.agui.example.chatapp.data.repository.AgentRepository +import com.agui.example.chatapp.util.getPlatformSettings +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.launch + +/** + * Wear-specific wrapper around [ChatController] that exposes additional agent metadata. + */ +class WearChatViewModel( + controllerFactory: (CoroutineScope) -> ChatController = { scope -> ChatController(scope) }, + repositoryProvider: () -> AgentRepository = { AgentRepository.getInstance(getPlatformSettings()) } +) : ViewModel() { + + private val controller = controllerFactory(viewModelScope) + private val repository = repositoryProvider() + + val chatState: StateFlow = controller.state + val agents: StateFlow> = repository.agents + val activeAgent: StateFlow = repository.activeAgent + + val quickPrompts: List = BuildConfig.DEFAULT_QUICK_PROMPTS + .split("|") + .map { it.trim() } + .filter { it.isNotEmpty() } + + init { + viewModelScope.launch { + ensureDefaultAgent() + } + } + + private suspend fun ensureDefaultAgent() { + val existingAgents = repository.agents.first() + val existingActive = repository.activeAgent.first() + + if (existingAgents.isEmpty()) { + val url = BuildConfig.DEFAULT_AGENT_URL + if (url.isNotBlank()) { + val agent = AgentConfig( + id = AgentConfig.generateId(), + name = BuildConfig.DEFAULT_AGENT_NAME.ifBlank { "Wear Sample Agent" }, + url = url, + description = BuildConfig.DEFAULT_AGENT_DESCRIPTION.ifBlank { "Configured via Gradle properties" }, + authMethod = BuildConfig.DEFAULT_AGENT_API_KEY + .takeIf { it.isNotBlank() } + ?.let { apiKey -> + AuthMethod.ApiKey( + key = apiKey, + headerName = BuildConfig.DEFAULT_AGENT_API_KEY_HEADER.ifBlank { "X-API-Key" } + ) + } + ?: AuthMethod.None() + ) + repository.addAgent(agent) + repository.setActiveAgent(agent) + return + } + } + + if (existingActive == null) { + existingAgents.firstOrNull()?.let { repository.setActiveAgent(it) } + } + } + + fun selectAgent(agent: AgentConfig) { + viewModelScope.launch { + repository.setActiveAgent(agent) + } + } + + fun sendMessage(content: String) { + controller.sendMessage(content) + } + + fun cancelCurrentOperation() { + controller.cancelCurrentOperation() + } + + fun clearError() { + controller.clearError() + } + + fun createAgent( + name: String, + url: String, + description: String, + apiKey: String, + apiKeyHeader: String + ) { + if (name.isBlank() || url.isBlank()) return + + viewModelScope.launch { + val auth = apiKey.takeIf { it.isNotBlank() }?.let { + AuthMethod.ApiKey( + key = apiKey, + headerName = apiKeyHeader.ifBlank { "X-API-Key" } + ) + } ?: AuthMethod.None() + + val agent = AgentConfig( + id = AgentConfig.generateId(), + name = name.trim(), + url = url.trim(), + description = description.takeIf { it.isNotBlank() }?.trim(), + authMethod = auth + ) + + repository.addAgent(agent) + repository.setActiveAgent(agent) + } + } + + fun updateAgent( + agent: AgentConfig, + name: String, + url: String, + description: String, + apiKey: String, + apiKeyHeader: String + ) { + if (name.isBlank() || url.isBlank()) return + + viewModelScope.launch { + val auth = apiKey.takeIf { it.isNotBlank() }?.let { + AuthMethod.ApiKey( + key = apiKey, + headerName = apiKeyHeader.ifBlank { "X-API-Key" } + ) + } ?: AuthMethod.None() + + val updated = agent.copy( + name = name.trim(), + url = url.trim(), + description = description.takeIf { it.isNotBlank() }?.trim(), + authMethod = auth + ) + repository.updateAgent(updated) + } + } + + fun deleteAgent(agent: AgentConfig) { + viewModelScope.launch { + repository.deleteAgent(agent.id) + } + } + + override fun onCleared() { + controller.close() + } +} diff --git a/sdks/community/kotlin/examples/chatapp-wearos/wearApp/src/main/java/com/agui/example/chatwear/ui/theme/ChatWearTheme.kt b/sdks/community/kotlin/examples/chatapp-wearos/wearApp/src/main/java/com/agui/example/chatwear/ui/theme/ChatWearTheme.kt new file mode 100644 index 000000000..44903b59c --- /dev/null +++ b/sdks/community/kotlin/examples/chatapp-wearos/wearApp/src/main/java/com/agui/example/chatwear/ui/theme/ChatWearTheme.kt @@ -0,0 +1,131 @@ +package com.agui.example.chatwear.ui.theme + +import android.os.Build +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.Color.Companion.Black +import androidx.compose.ui.graphics.Color.Companion.White +import androidx.compose.ui.graphics.luminance +import androidx.compose.ui.platform.LocalContext +import androidx.wear.compose.material3.MaterialTheme as WearMaterialTheme +import androidx.wear.compose.material3.ColorScheme +import androidx.wear.compose.material3.dynamicColorScheme + +@Composable +fun ChatWearTheme(content: @Composable () -> Unit) { + val context = LocalContext.current + val dynamicScheme = remember(context) { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { + dynamicColorScheme(context) + } else { + null + } + } + + val colorScheme = (dynamicScheme ?: vibrantColorScheme()).ensureContrast() + + WearMaterialTheme( + colorScheme = colorScheme, + content = content + ) +} + +private fun vibrantColorScheme(): ColorScheme = ColorScheme( + primary = Color(0xFF4D8EFF), + primaryDim = Color(0xFF396FCB), + primaryContainer = Color(0xFF123A84), + onPrimary = Color.White, + onPrimaryContainer = Color(0xFFD6E2FF), + secondary = Color(0xFF2ED3C5), + secondaryDim = Color(0xFF20A699), + secondaryContainer = Color(0xFF00524C), + onSecondary = Color.White, + onSecondaryContainer = Color(0xFFA6F7EC), + tertiary = Color(0xFFFF7DD1), + tertiaryDim = Color(0xFFC6609E), + tertiaryContainer = Color(0xFF601E4A), + onTertiary = Color.White, + onTertiaryContainer = Color(0xFFFFD7EE), + surfaceContainerLow = Color(0xFF111821), + surfaceContainer = Color(0xFF151C26), + surfaceContainerHigh = Color(0xFF1E2734), + onSurface = Color(0xFFE3E7F3), + onSurfaceVariant = Color(0xFFA3ADC2), + outline = Color(0xFF4F5A6E), + outlineVariant = Color(0xFF2F3848), + background = Color(0xFF080B10), + onBackground = Color(0xFFE3E7F3), + error = Color(0xFFFF6B7D), + errorDim = Color(0xFFC74D5B), + errorContainer = Color(0xFF640F1C), + onError = Color.White, + onErrorContainer = Color(0xFFFFD9DF) +) + +private fun ColorScheme.ensureContrast(): ColorScheme { + /** + * Adjusts a foreground color to ensure it has a readable 4.5:1 contrast + * ratio over a given background color. + */ + fun adjust(foreground: Color, background: Color): Color { + val contrast = contrastRatio(foreground, background) + + // If contrast is good, keep it. + if (contrast >= 4.5f) return foreground + + // If contrast is bad, return the standard high-contrast fallback. + return if (background.luminance() < 0.5f) Color.White else Color.Black + } + + // --- THIS IS THE FIX --- + + // onSurface is used on surfaceContainer (in AgentStatusCard) AND + // surfaceContainerHigh (in MessageBubble/Assistant) + val onSurfaceAdjusted = adjust(onSurface, surfaceContainer) + val onSurfaceFinal = adjust(onSurfaceAdjusted, surfaceContainerHigh) + + // onSurfaceVariant is used on surfaceContainer (in AgentStatusCard) AND + // surfaceContainerLow (in MessageBubble/STEP_INFO) + val onSurfaceVariantAdjusted = adjust(onSurfaceVariant, surfaceContainer) + val onSurfaceVariantFinal = adjust(onSurfaceVariantAdjusted, surfaceContainerLow) + + // Return the new scheme with all "on" colors guaranteed to be readable + return ColorScheme( + primary = primary, + primaryDim = primaryDim, + primaryContainer = primaryContainer, + onPrimary = adjust(onPrimary, primary), + onPrimaryContainer = adjust(onPrimaryContainer, primaryContainer), + secondary = secondary, + secondaryDim = secondaryDim, + secondaryContainer = secondaryContainer, + onSecondary = adjust(onSecondary, secondary), + onSecondaryContainer = adjust(onSecondaryContainer, secondaryContainer), + tertiary = tertiary, + tertiaryDim = tertiaryDim, + tertiaryContainer = tertiaryContainer, + onTertiary = adjust(onTertiary, tertiary), + onTertiaryContainer = adjust(onTertiaryContainer, tertiaryContainer), + surfaceContainerLow = surfaceContainerLow, + surfaceContainer = surfaceContainer, + surfaceContainerHigh = surfaceContainerHigh, + onSurface = onSurfaceFinal, // Checked against both + onSurfaceVariant = onSurfaceVariantFinal, // Checked against both + outline = outline, + outlineVariant = outlineVariant, + background = background, + onBackground = adjust(onBackground, background), + error = error, + errorDim = errorDim, + errorContainer = errorContainer, + onError = adjust(onError, error), + onErrorContainer = adjust(onErrorContainer, errorContainer) + ) +} + +private fun contrastRatio(foreground: Color, background: Color): Float { + val l1 = foreground.luminance() + 0.05f + val l2 = background.luminance() + 0.05f + return if (l1 > l2) l1 / l2 else l2 / l1 +} diff --git a/sdks/community/kotlin/examples/chatapp-wearos/wearApp/src/main/res/drawable/ic_launcher_background.xml b/sdks/community/kotlin/examples/chatapp-wearos/wearApp/src/main/res/drawable/ic_launcher_background.xml new file mode 100644 index 000000000..07d5da9cb --- /dev/null +++ b/sdks/community/kotlin/examples/chatapp-wearos/wearApp/src/main/res/drawable/ic_launcher_background.xml @@ -0,0 +1,170 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/sdks/community/kotlin/examples/chatapp-wearos/wearApp/src/main/res/drawable/ic_launcher_foreground.xml b/sdks/community/kotlin/examples/chatapp-wearos/wearApp/src/main/res/drawable/ic_launcher_foreground.xml new file mode 100644 index 000000000..2b068d114 --- /dev/null +++ b/sdks/community/kotlin/examples/chatapp-wearos/wearApp/src/main/res/drawable/ic_launcher_foreground.xml @@ -0,0 +1,30 @@ + + + + + + + + + + + \ No newline at end of file diff --git a/sdks/community/kotlin/examples/chatapp-wearos/wearApp/src/main/res/mipmap-anydpi/ic_launcher.xml b/sdks/community/kotlin/examples/chatapp-wearos/wearApp/src/main/res/mipmap-anydpi/ic_launcher.xml new file mode 100644 index 000000000..6f3b755bf --- /dev/null +++ b/sdks/community/kotlin/examples/chatapp-wearos/wearApp/src/main/res/mipmap-anydpi/ic_launcher.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/sdks/community/kotlin/examples/chatapp-wearos/wearApp/src/main/res/mipmap-anydpi/ic_launcher_round.xml b/sdks/community/kotlin/examples/chatapp-wearos/wearApp/src/main/res/mipmap-anydpi/ic_launcher_round.xml new file mode 100644 index 000000000..6f3b755bf --- /dev/null +++ b/sdks/community/kotlin/examples/chatapp-wearos/wearApp/src/main/res/mipmap-anydpi/ic_launcher_round.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/sdks/community/kotlin/examples/chatapp-wearos/wearApp/src/main/res/values/colors.xml b/sdks/community/kotlin/examples/chatapp-wearos/wearApp/src/main/res/values/colors.xml new file mode 100644 index 000000000..19bad2091 --- /dev/null +++ b/sdks/community/kotlin/examples/chatapp-wearos/wearApp/src/main/res/values/colors.xml @@ -0,0 +1,6 @@ + + #FF1D7CFF + #FFFFFFFF + #FF000000 + #FFFFFFFF + diff --git a/sdks/community/kotlin/examples/chatapp-wearos/wearApp/src/main/res/values/strings.xml b/sdks/community/kotlin/examples/chatapp-wearos/wearApp/src/main/res/values/strings.xml new file mode 100644 index 000000000..5dfe6dddf --- /dev/null +++ b/sdks/community/kotlin/examples/chatapp-wearos/wearApp/src/main/res/values/strings.xml @@ -0,0 +1,27 @@ + + AG-UI Chat + Ask something… + Recent prompts + Retry + Confirm + Reject + Cancel + Manage agents + Switch agent + Manage agents + Add, edit, or activate agents directly on your watch. + Back to chat + Add agent + Edit agent + Name + API URL + Description + API key (optional) + API key header + Save + No agents yet. Add one to start chatting. + Active + Activate + Edit + Delete + diff --git a/sdks/community/kotlin/examples/chatapp-wearos/wearApp/src/main/res/values/themes.xml b/sdks/community/kotlin/examples/chatapp-wearos/wearApp/src/main/res/values/themes.xml new file mode 100644 index 000000000..33559659a --- /dev/null +++ b/sdks/community/kotlin/examples/chatapp-wearos/wearApp/src/main/res/values/themes.xml @@ -0,0 +1,6 @@ + + + diff --git a/sdks/community/kotlin/examples/chatapp/README.md b/sdks/community/kotlin/examples/chatapp/README.md index 7d009907e..2c8cf6707 100644 --- a/sdks/community/kotlin/examples/chatapp/README.md +++ b/sdks/community/kotlin/examples/chatapp/README.md @@ -13,10 +13,11 @@ A Compose Multiplatform chat client for connecting to AI agents using the AG-UI ## Architecture -The client follows a clean architecture pattern: +The client follows a clean architecture pattern and consumes the shared core module located at `../chatapp-shared`: - **UI Layer**: Compose Multiplatform UI with Material 3 -- **ViewModel Layer**: Screen-specific business logic using Voyager +- **ViewModel Layer**: Screen-specific adapters around the reusable `ChatController` +- **Shared Core**: Reusable repository, authentication, and chat orchestration logic - **Repository Layer**: Data management and persistence - **Authentication Layer**: Extensible auth provider system @@ -171,4 +172,4 @@ Agent configurations are stored using platform-specific preferences: ## License -MIT License - See the parent project's LICENSE file \ No newline at end of file +MIT License - See the parent project's LICENSE file diff --git a/sdks/community/kotlin/examples/chatapp/androidApp/build.gradle.kts b/sdks/community/kotlin/examples/chatapp/androidApp/build.gradle.kts index ae6c17b57..44f6f14be 100644 --- a/sdks/community/kotlin/examples/chatapp/androidApp/build.gradle.kts +++ b/sdks/community/kotlin/examples/chatapp/androidApp/build.gradle.kts @@ -21,7 +21,7 @@ android { } composeOptions { - kotlinCompilerExtensionVersion = "1.6.21" + kotlinCompilerExtensionVersion = "1.5.15" } packagingOptions { @@ -62,4 +62,4 @@ dependencies { // } // } // } -//} \ No newline at end of file +//} diff --git a/sdks/community/kotlin/examples/chatapp/gradle.properties b/sdks/community/kotlin/examples/chatapp/gradle.properties index 28e6a57b8..934bf6638 100644 --- a/sdks/community/kotlin/examples/chatapp/gradle.properties +++ b/sdks/community/kotlin/examples/chatapp/gradle.properties @@ -10,7 +10,6 @@ kotlin.code.style=official kotlin.mpp.androidSourceSetLayoutVersion=2 kotlin.mpp.applyDefaultHierarchyTemplate=false kotlin.native.cacheKind=none -kotlin.native.useEmbeddableCompilerJar=true kotlin.mpp.enableCInteropCommonization=true # Compose @@ -26,10 +25,10 @@ android.nonTransitiveRClass=true xcodeproj=./iosApp # K2 Compiler Settings -kotlin.compiler.version=2.2.0 +kotlin.compiler.version=2.2.20 kotlin.compiler.languageVersion=2.2 kotlin.compiler.apiVersion=2.2 kotlin.compiler.k2=true # Disable Kotlin Native bundling service -kotlin.native.disableCompilerDaemon=true \ No newline at end of file +kotlin.native.disableCompilerDaemon=true diff --git a/sdks/community/kotlin/examples/chatapp/gradle/libs.versions.toml b/sdks/community/kotlin/examples/chatapp/gradle/libs.versions.toml index 7aec39fa6..965271c2a 100644 --- a/sdks/community/kotlin/examples/chatapp/gradle/libs.versions.toml +++ b/sdks/community/kotlin/examples/chatapp/gradle/libs.versions.toml @@ -1,12 +1,12 @@ [versions] activity-compose = "1.10.1" -agui-core = "0.2.1" +agui-core = "0.2.3" appcompat = "1.7.1" core = "1.6.1" core-ktx = "1.16.0" junit = "4.13.2" junit-version = "1.2.1" -kotlin = "2.1.21" +kotlin = "2.2.20" #Downgrading to avoid an R8 error ktor = "3.1.3" kotlinx-serialization = "1.8.1" @@ -19,9 +19,9 @@ multiplatform-settings-coroutines = "1.2.0" okio = "3.13.0" runner = "1.6.2" slf4j = "2.0.9" -ui-test-junit4 = "1.8.3" -voyager-navigator = "1.0.0" -compose-richtext = "1.0.0-alpha03" +markdown-renderer = "0.37.0" +compose = "1.9.1" +compose-material3 = "1.4.0" [libraries] # Ktor @@ -29,7 +29,7 @@ activity-compose = { module = "androidx.activity:activity-compose", version.ref agui-client = { module = "com.agui:kotlin-client", version.ref = "agui-core" } agui-core = { module = "com.agui:kotlin-core", version.ref = "agui-core" } agui-tools = { module = "com.agui:kotlin-tools", version.ref = "agui-core" } -androidx-ui-tooling = { module = "androidx.compose.ui:ui-tooling", version.ref = "ui-test-junit4" } +androidx-ui-tooling = { module = "androidx.compose.ui:ui-tooling", version.ref = "compose" } appcompat = { module = "androidx.appcompat:appcompat", version.ref = "appcompat" } core = { module = "androidx.test:core", version.ref = "core" } core-ktx = { module = "androidx.core:core-ktx", version.ref = "core-ktx" } @@ -59,13 +59,16 @@ multiplatform-settings-coroutines = { module = "com.russhwolf:multiplatform-sett okio = { module = "com.squareup.okio:okio", version.ref = "okio" } runner = { module = "androidx.test:runner", version.ref = "runner" } slf4j-simple = { module = "org.slf4j:slf4j-simple", version.ref = "slf4j" } -ui-test-junit4 = { module = "androidx.compose.ui:ui-test-junit4", version.ref = "ui-test-junit4" } -voyager-navigator = { module = "cafe.adriel.voyager:voyager-navigator", version.ref = "voyager-navigator" } -voyager-screenmodel = { module = "cafe.adriel.voyager:voyager-screenmodel", version.ref = "voyager-navigator" } -voyager-transitions = { module = "cafe.adriel.voyager:voyager-transitions", version.ref = "voyager-navigator" } -richtext-commonmark = { module = "com.halilibo.compose-richtext:richtext-commonmark", version.ref = "compose-richtext" } -richtext-ui = { module = "com.halilibo.compose-richtext:richtext-ui", version.ref = "compose-richtext" } -richtext-ui-material3 = { module = "com.halilibo.compose-richtext:richtext-ui-material3", version.ref = "compose-richtext" } +androidx-compose-runtime = { module = "androidx.compose.runtime:runtime", version.ref = "compose" } +androidx-compose-runtime-saveable = { module = "androidx.compose.runtime:runtime-saveable", version.ref = "compose" } +androidx-compose-foundation = { module = "androidx.compose.foundation:foundation", version.ref = "compose" } +androidx-compose-material = { module = "androidx.compose.material:material", version.ref = "compose" } +androidx-compose-material3 = { module = "androidx.compose.material3:material3", version.ref = "compose-material3" } +androidx-compose-ui = { module = "androidx.compose.ui:ui", version.ref = "compose" } +androidx-compose-ui-tooling-preview = { module = "androidx.compose.ui:ui-tooling-preview", version.ref = "compose" } +androidx-compose-ui-test-manifest = { module = "androidx.compose.ui:ui-test-manifest", version.ref = "compose" } +ui-test-junit4 = { module = "androidx.compose.ui:ui-test-junit4", version.ref = "compose" } +markdown-renderer-m3 = { module = "com.mikepenz:multiplatform-markdown-renderer-m3", version.ref = "markdown-renderer" } [plugins] kotlin-multiplatform = { id = "org.jetbrains.kotlin.multiplatform", version.ref = "kotlin" } @@ -84,4 +87,4 @@ kotlinx-common = [ "kotlinx-coroutines-core", "kotlinx-serialization-json", "kotlinx-datetime" -] \ No newline at end of file +] diff --git a/sdks/community/kotlin/examples/chatapp/iosApp/iosApp.xcodeproj/project.pbxproj b/sdks/community/kotlin/examples/chatapp/iosApp/iosApp.xcodeproj/project.pbxproj index a7af575a2..290ff41c2 100644 --- a/sdks/community/kotlin/examples/chatapp/iosApp/iosApp.xcodeproj/project.pbxproj +++ b/sdks/community/kotlin/examples/chatapp/iosApp/iosApp.xcodeproj/project.pbxproj @@ -3,7 +3,7 @@ archiveVersion = 1; classes = { }; - objectVersion = 54; + objectVersion = 56; objects = { /* Begin PBXBuildFile section */ @@ -286,6 +286,7 @@ ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; CODE_SIGN_STYLE = Automatic; DEVELOPMENT_ASSET_PATHS = ""; + DEVELOPMENT_TEAM = 96FAHJ5ZD7; ENABLE_PREVIEWS = YES; FRAMEWORK_SEARCH_PATHS = ( "$(inherited)", @@ -301,7 +302,7 @@ "-framework", shared, ); - PRODUCT_BUNDLE_IDENTIFIER = "com.agui.example.chatapp"; + PRODUCT_BUNDLE_IDENTIFIER = com.agui.example.chatapp; PRODUCT_NAME = "$(TARGET_NAME)"; SWIFT_VERSION = 5.0; TARGETED_DEVICE_FAMILY = "1,2"; @@ -314,6 +315,7 @@ ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; CODE_SIGN_STYLE = Automatic; DEVELOPMENT_ASSET_PATHS = ""; + DEVELOPMENT_TEAM = 96FAHJ5ZD7; ENABLE_PREVIEWS = YES; FRAMEWORK_SEARCH_PATHS = ( "$(inherited)", @@ -329,7 +331,7 @@ "-framework", shared, ); - PRODUCT_BUNDLE_IDENTIFIER = "com.agui.example.chatapp"; + PRODUCT_BUNDLE_IDENTIFIER = com.agui.example.chatapp; PRODUCT_NAME = "$(TARGET_NAME)"; SWIFT_VERSION = 5.0; TARGETED_DEVICE_FAMILY = "1,2"; @@ -360,4 +362,4 @@ /* End XCConfigurationList section */ }; rootObject = 7555FF73242A565900829871 /* Project object */; -} \ No newline at end of file +} diff --git a/sdks/community/kotlin/examples/chatapp/settings.gradle.kts b/sdks/community/kotlin/examples/chatapp/settings.gradle.kts index 0a8665e22..f0d97cc5e 100644 --- a/sdks/community/kotlin/examples/chatapp/settings.gradle.kts +++ b/sdks/community/kotlin/examples/chatapp/settings.gradle.kts @@ -4,9 +4,8 @@ include(":shared") include(":androidApp") include(":desktopApp") -// Include example tools module -include(":tools") -project(":tools").projectDir = file("../tools") +include(":chatapp-shared") +project(":chatapp-shared").projectDir = file("../chatapp-shared") // Library modules will be pulled from Maven instead of local build @@ -19,8 +18,8 @@ pluginManagement { } plugins { - val kotlinVersion = "2.1.21" - val composeVersion = "1.7.3" + val kotlinVersion = "2.2.20" + val composeVersion = "1.9.0-rc02" val agpVersion = "8.10.1" kotlin("multiplatform") version kotlinVersion @@ -43,4 +42,4 @@ dependencyResolutionManagement { maven("https://maven.pkg.jetbrains.space/public/p/compose/dev") mavenLocal() } -} \ No newline at end of file +} diff --git a/sdks/community/kotlin/examples/chatapp/shared/build.gradle.kts b/sdks/community/kotlin/examples/chatapp/shared/build.gradle.kts index c499498f3..bfb186b90 100644 --- a/sdks/community/kotlin/examples/chatapp/shared/build.gradle.kts +++ b/sdks/community/kotlin/examples/chatapp/shared/build.gradle.kts @@ -7,6 +7,7 @@ plugins { } kotlin { + applyDefaultHierarchyTemplate() jvmToolchain(21) androidTarget { compilations.all { @@ -27,7 +28,6 @@ kotlin { } } } - listOf( iosX64(), iosArm64(), @@ -50,41 +50,16 @@ kotlin { implementation(compose.components.uiToolingPreview) implementation(compose.materialIconsExtended) - // ag-ui library - consolidated client module includes agent functionality - implementation(libs.agui.client) - implementation(project(":tools")) - - // Navigation - implementation(libs.voyager.navigator) - implementation(libs.voyager.screenmodel) - implementation(libs.voyager.transitions) + implementation(project(":chatapp-shared")) // Coroutines implementation(libs.kotlinx.coroutines.core) - // Atomics for multiplatform thread safety - implementation("org.jetbrains.kotlinx:atomicfu:0.23.2") - - // Serialization - implementation(libs.kotlinx.serialization.json) - - // Preferences - implementation(libs.multiplatform.settings) - implementation(libs.multiplatform.settings.coroutines) - - // Logging - implementation("co.touchlab:kermit:2.0.6") - // DateTime implementation(libs.kotlinx.datetime) - // Base64 encoding/decoding - implementation(libs.okio) - // Markdown rendering - implementation(libs.richtext.commonmark) - implementation(libs.richtext.ui) - implementation(libs.richtext.ui.material3) + implementation(libs.markdown.renderer.m3) } } @@ -93,6 +68,10 @@ kotlin { implementation(kotlin("test")) implementation(libs.kotlinx.coroutines.test) implementation(libs.ktor.client.mock) // Add this line + implementation(project(":chatapp-shared")) + implementation(libs.agui.tools) + implementation(libs.agui.client) + implementation(libs.agui.core) } } @@ -101,6 +80,13 @@ kotlin { api(libs.activity.compose) api(libs.appcompat) api(libs.core.ktx) + implementation(libs.androidx.compose.runtime) + implementation(libs.androidx.compose.runtime.saveable) + implementation(libs.androidx.compose.foundation) + implementation(libs.androidx.compose.material) + implementation(libs.androidx.compose.material3) + implementation(libs.androidx.compose.ui) + implementation(libs.androidx.compose.ui.tooling.preview) implementation(libs.ktor.client.android) } } @@ -119,11 +105,10 @@ kotlin { implementation(libs.ext.junit) implementation(libs.core) implementation(libs.ktor.client.android) - implementation(project(":tools")) // Fixed Compose testing dependencies with explicit versions implementation(libs.ui.test.junit4) - implementation("androidx.compose.ui:ui-test-manifest:1.8.3") + implementation(libs.androidx.compose.ui.test.manifest) implementation(libs.activity.compose) implementation(libs.androidx.ui.tooling) } @@ -144,29 +129,8 @@ kotlin { } // Get the existing specific iOS targets - val iosX64Main by getting - val iosArm64Main by getting - val iosSimulatorArm64Main by getting - - // Create an iosMain source set and link the others to it - val iosMain by creating { - dependsOn(commonMain) - iosX64Main.dependsOn(this) - iosArm64Main.dependsOn(this) - iosSimulatorArm64Main.dependsOn(this) - } - - // Also create iosTest - val iosX64Test by getting - val iosArm64Test by getting - val iosSimulatorArm64Test by getting - - val iosTest by creating { - dependsOn(commonTest) - iosX64Test.dependsOn(this) - iosArm64Test.dependsOn(this) - iosSimulatorArm64Test.dependsOn(this) - } + val iosMain by getting + val iosTest by getting } } diff --git a/sdks/community/kotlin/examples/chatapp/shared/src/androidInstrumentedTest/kotlin/com/agui/example/chatapp/ui/components/UserConfirmationDialogComponentTest.kt b/sdks/community/kotlin/examples/chatapp/shared/src/androidInstrumentedTest/kotlin/com/agui/example/chatapp/ui/components/UserConfirmationDialogComponentTest.kt deleted file mode 100644 index 4db8969c8..000000000 --- a/sdks/community/kotlin/examples/chatapp/shared/src/androidInstrumentedTest/kotlin/com/agui/example/chatapp/ui/components/UserConfirmationDialogComponentTest.kt +++ /dev/null @@ -1,427 +0,0 @@ -package com.agui.example.chatapp.ui.components - -import androidx.compose.ui.test.* -import androidx.test.ext.junit.runners.AndroidJUnit4 -import com.agui.example.chatapp.ui.screens.chat.UserConfirmationRequest -import com.agui.example.chatapp.ui.screens.chat.components.UserConfirmationDialog -import com.agui.example.chatapp.ui.theme.AgentChatTheme -import org.junit.runner.RunWith -import kotlin.test.* - -/** - * Android instrumentation tests for UserConfirmationDialog component. - * Tests dialog display, user interactions, and different confirmation scenarios on Android platform. - */ -@RunWith(AndroidJUnit4::class) -@OptIn(ExperimentalTestApi::class) -class UserConfirmationDialogComponentTest { - - @Test - fun testBasicConfirmationDialogDisplay() = runComposeUiTest { - val request = UserConfirmationRequest( - toolCallId = "test-1", - action = "Delete important file", - impact = "high" - ) - - var confirmCalled = false - var rejectCalled = false - - setContent { - AgentChatTheme { - UserConfirmationDialog( - request = request, - onConfirm = { confirmCalled = true }, - onReject = { rejectCalled = true } - ) - } - } - - // Verify dialog content - onNodeWithText("Confirmation Required").assertExists() - onNodeWithText("Delete important file").assertExists() - onNodeWithText("HIGH").assertExists() - onNodeWithText("Confirm").assertExists() - onNodeWithText("Cancel").assertExists() - } - - @Test - fun testConfirmationWithDetails() = runComposeUiTest { - val request = UserConfirmationRequest( - toolCallId = "test-2", - action = "Execute system command", - impact = "critical", - details = mapOf( - "command" to "rm -rf /tmp/*", - "target" to "/tmp directory", - "size" to "1.2GB" - ) - ) - - setContent { - AgentChatTheme { - UserConfirmationDialog( - request = request, - onConfirm = { }, - onReject = { } - ) - } - } - - // Verify main content - onNodeWithText("Execute system command").assertExists() - onNodeWithText("CRITICAL").assertExists() - - // Verify details are displayed - onNodeWithText("command:").assertExists() - onNodeWithText("rm -rf /tmp/*").assertExists() - onNodeWithText("target:").assertExists() - onNodeWithText("/tmp directory").assertExists() - onNodeWithText("size:").assertExists() - onNodeWithText("1.2GB").assertExists() - } - - @Test - fun testConfirmButtonClick() = runComposeUiTest { - val request = UserConfirmationRequest( - toolCallId = "test-3", - action = "Test action", - impact = "low" - ) - - var confirmCalled = false - - setContent { - AgentChatTheme { - UserConfirmationDialog( - request = request, - onConfirm = { confirmCalled = true }, - onReject = { } - ) - } - } - - onNodeWithText("Confirm").performClick() - assertTrue(confirmCalled) - } - - @Test - fun testCancelButtonClick() = runComposeUiTest { - val request = UserConfirmationRequest( - toolCallId = "test-4", - action = "Test action", - impact = "medium" - ) - - var rejectCalled = false - - setContent { - AgentChatTheme { - UserConfirmationDialog( - request = request, - onConfirm = { }, - onReject = { rejectCalled = true } - ) - } - } - - onNodeWithText("Cancel").performClick() - assertTrue(rejectCalled) - } - - @Test - fun testLowImpactConfirmation() = runComposeUiTest { - val request = UserConfirmationRequest( - toolCallId = "test-5", - action = "Create backup file", - impact = "low", - timeout = 60 - ) - - setContent { - AgentChatTheme { - UserConfirmationDialog( - request = request, - onConfirm = { }, - onReject = { } - ) - } - } - - onNodeWithText("Create backup file").assertExists() - onNodeWithText("LOW").assertExists() - onNodeWithText("Impact:").assertExists() - } - - @Test - fun testMediumImpactConfirmation() = runComposeUiTest { - val request = UserConfirmationRequest( - toolCallId = "test-6", - action = "Modify configuration", - impact = "medium" - ) - - setContent { - AgentChatTheme { - UserConfirmationDialog( - request = request, - onConfirm = { }, - onReject = { } - ) - } - } - - onNodeWithText("Modify configuration").assertExists() - onNodeWithText("MEDIUM").assertExists() - } - - @Test - fun testHighImpactConfirmation() = runComposeUiTest { - val request = UserConfirmationRequest( - toolCallId = "test-7", - action = "Delete user data", - impact = "high" - ) - - setContent { - AgentChatTheme { - UserConfirmationDialog( - request = request, - onConfirm = { }, - onReject = { } - ) - } - } - - onNodeWithText("Delete user data").assertExists() - onNodeWithText("HIGH").assertExists() - } - - @Test - fun testCriticalImpactConfirmation() = runComposeUiTest { - val request = UserConfirmationRequest( - toolCallId = "test-8", - action = "Format hard drive", - impact = "critical" - ) - - setContent { - AgentChatTheme { - UserConfirmationDialog( - request = request, - onConfirm = { }, - onReject = { } - ) - } - } - - onNodeWithText("Format hard drive").assertExists() - onNodeWithText("CRITICAL").assertExists() - } - - @Test - fun testEmptyDetailsHandling() = runComposeUiTest { - val request = UserConfirmationRequest( - toolCallId = "test-9", - action = "Simple action", - impact = "low", - details = emptyMap() - ) - - setContent { - AgentChatTheme { - UserConfirmationDialog( - request = request, - onConfirm = { }, - onReject = { } - ) - } - } - - onNodeWithText("Simple action").assertExists() - onNodeWithText("LOW").assertExists() - // Details section should not be visible when empty - } - - @Test - fun testSingleDetailEntry() = runComposeUiTest { - val request = UserConfirmationRequest( - toolCallId = "test-10", - action = "Update setting", - impact = "medium", - details = mapOf("setting" to "theme=dark") - ) - - setContent { - AgentChatTheme { - UserConfirmationDialog( - request = request, - onConfirm = { }, - onReject = { } - ) - } - } - - onNodeWithText("Update setting").assertExists() - onNodeWithText("setting:").assertExists() - onNodeWithText("theme=dark").assertExists() - } - - @Test - fun testLongActionText() = runComposeUiTest { - val longAction = "This is a very long action description that should test how the dialog handles " + - "lengthy text content. It should wrap properly and maintain good readability within the dialog." - - val request = UserConfirmationRequest( - toolCallId = "test-11", - action = longAction, - impact = "medium" - ) - - setContent { - AgentChatTheme { - UserConfirmationDialog( - request = request, - onConfirm = { }, - onReject = { } - ) - } - } - - onNodeWithText(longAction).assertExists() - } - - @Test - fun testSpecialCharactersInAction() = runComposeUiTest { - val actionWithSpecialChars = "Execute: rm -rf /tmp/* && echo \"Done!\" | tee log.txt" - - val request = UserConfirmationRequest( - toolCallId = "test-12", - action = actionWithSpecialChars, - impact = "high" - ) - - setContent { - AgentChatTheme { - UserConfirmationDialog( - request = request, - onConfirm = { }, - onReject = { } - ) - } - } - - onNodeWithText(actionWithSpecialChars).assertExists() - } - - @Test - fun testCustomTimeoutValue() = runComposeUiTest { - val request = UserConfirmationRequest( - toolCallId = "test-13", - action = "Timed operation", - impact = "medium", - timeout = 120 - ) - - setContent { - AgentChatTheme { - UserConfirmationDialog( - request = request, - onConfirm = { }, - onReject = { } - ) - } - } - - onNodeWithText("Timed operation").assertExists() - // Note: Timeout value might not be directly displayed in UI, - // but we verify the dialog renders correctly with custom timeout - } - - @Test - fun testMultipleDetailsEntries() = runComposeUiTest { - val request = UserConfirmationRequest( - toolCallId = "test-14", - action = "Complex operation", - impact = "high", - details = mapOf( - "source" to "/home/user/documents", - "destination" to "/backup/user-docs", - "size" to "2.5GB", - "method" to "rsync", - "compression" to "enabled" - ) - ) - - setContent { - AgentChatTheme { - UserConfirmationDialog( - request = request, - onConfirm = { }, - onReject = { } - ) - } - } - - // Verify all details are displayed - onNodeWithText("source:").assertExists() - onNodeWithText("/home/user/documents").assertExists() - onNodeWithText("destination:").assertExists() - onNodeWithText("/backup/user-docs").assertExists() - onNodeWithText("size:").assertExists() - onNodeWithText("2.5GB").assertExists() - onNodeWithText("method:").assertExists() - onNodeWithText("rsync").assertExists() - onNodeWithText("compression:").assertExists() - onNodeWithText("enabled").assertExists() - } - - @Test - fun testDialogDismissOnOutsideClick() = runComposeUiTest { - val request = UserConfirmationRequest( - toolCallId = "test-15", - action = "Test dismiss", - impact = "low" - ) - - var rejectCalled = false - - setContent { - AgentChatTheme { - UserConfirmationDialog( - request = request, - onConfirm = { }, - onReject = { rejectCalled = true } - ) - } - } - - // Note: Testing outside click dismiss is complex in Compose UI tests - // We verify the dialog displays correctly and reject callback is set up - onNodeWithText("Test dismiss").assertExists() - assertTrue(true) // Test passes if dialog renders without error - } - - @Test - fun testUnknownImpactLevel() = runComposeUiTest { - val request = UserConfirmationRequest( - toolCallId = "test-16", - action = "Unknown impact action", - impact = "unknown" - ) - - setContent { - AgentChatTheme { - UserConfirmationDialog( - request = request, - onConfirm = { }, - onReject = { } - ) - } - } - - onNodeWithText("Unknown impact action").assertExists() - onNodeWithText("UNKNOWN").assertExists() - // Should handle unknown impact levels gracefully - } -} \ No newline at end of file diff --git a/sdks/community/kotlin/examples/chatapp/shared/src/androidInstrumentedTest/kotlin/com/agui/example/chatapp/viewmodel/AndroidChatViewModelErrorHandlingTest.kt b/sdks/community/kotlin/examples/chatapp/shared/src/androidInstrumentedTest/kotlin/com/agui/example/chatapp/viewmodel/AndroidChatViewModelErrorHandlingTest.kt deleted file mode 100644 index fa7c2bad4..000000000 --- a/sdks/community/kotlin/examples/chatapp/shared/src/androidInstrumentedTest/kotlin/com/agui/example/chatapp/viewmodel/AndroidChatViewModelErrorHandlingTest.kt +++ /dev/null @@ -1,211 +0,0 @@ -package com.agui.example.chatapp.viewmodel - -import android.content.Context -import androidx.test.ext.junit.runners.AndroidJUnit4 -import androidx.test.platform.app.InstrumentationRegistry -import com.agui.example.chatapp.data.model.AgentConfig -import com.agui.example.chatapp.data.model.AuthMethod -import com.agui.example.chatapp.data.repository.AgentRepository -import com.agui.example.chatapp.util.initializeAndroid -import com.agui.example.chatapp.util.resetAndroidContext -import com.agui.core.types.* -import com.agui.example.chatapp.ui.screens.chat.ChatViewModel -import com.agui.example.chatapp.ui.screens.chat.MessageRole -import kotlinx.coroutines.test.runTest -import org.junit.Before -import org.junit.After -import org.junit.Test -import org.junit.runner.RunWith -import kotlin.test.* - -/** - * Android integration tests for ChatViewModel error handling that require platform context. - * These tests were moved from desktop tests because they need real Android platform initialization. - */ -@RunWith(AndroidJUnit4::class) -class AndroidChatViewModelErrorHandlingTest { - - private lateinit var viewModel: ChatViewModel - private lateinit var context: Context - - @Before - fun setup() { - // Reset any previous state - resetAndroidContext() - AgentRepository.resetInstance() - - // Initialize Android platform - context = InstrumentationRegistry.getInstrumentation().targetContext - initializeAndroid(context) - - // Create real ChatViewModel (will work now that Android is initialized) - viewModel = ChatViewModel() - - // Set up a test agent - val testAgent = AgentConfig( - id = "test-agent", - name = "Test Agent", - url = "https://test.com/agent", - authMethod = AuthMethod.None() - ) - } - - @After - fun tearDown() { - resetAndroidContext() - AgentRepository.resetInstance() - } - - @Test - fun testRunErrorEventHandling() = runTest { - // Test basic run error event with Android platform context - val errorEvent = RunErrorEvent( - message = "Network connection timeout", - code = "TIMEOUT_ERROR" - ) - - viewModel.handleAgentEvent(errorEvent) - - // Verify error message is displayed - val state = viewModel.state.value - val errorMessage = state.messages.find { - it.role == MessageRole.ERROR && it.content.contains("Network connection timeout") - } - - assertNotNull(errorMessage, "Error message should be created") - assertTrue(errorMessage.content.contains("Network connection timeout"), "Error message should contain the error message") - // Note: ChatViewModel implementation only includes event.message, not event.code in the display - } - - @Test - fun testUnknownEventTypes() = runTest { - // Test that edge case event types don't crash the system with Android context - - // Test with empty message IDs (valid non-empty content) - viewModel.handleAgentEvent(TextMessageStartEvent("")) - viewModel.handleAgentEvent(TextMessageContentEvent("", "Valid content")) - viewModel.handleAgentEvent(TextMessageEndEvent("")) - - // Should handle gracefully without crashing - val state = viewModel.state.value - assertNotNull(state) // Verify state is still valid - } - - @Test - fun testNullAndEmptyContent() = runTest { - // Test handling of minimal content in various events with Android context - - viewModel.handleAgentEvent(TextMessageStartEvent("test-msg")) - viewModel.handleAgentEvent(TextMessageContentEvent("test-msg", " ")) // Single space is valid - viewModel.handleAgentEvent(TextMessageEndEvent("test-msg")) - - viewModel.handleAgentEvent(ToolCallStartEvent("test-tool", "")) - viewModel.handleAgentEvent(ToolCallArgsEvent("test-tool", " ")) // Single space for args - viewModel.handleAgentEvent(ToolCallEndEvent("test-tool")) - - // Should handle all gracefully - val state = viewModel.state.value - assertNotNull(state) - } - - @Test - fun testAndroidSpecificErrorHandling() = runTest { - // Test Android-specific error scenarios - val androidErrorEvent = RunErrorEvent( - message = "Android platform error", - code = "ANDROID_ERROR" - ) - - viewModel.handleAgentEvent(androidErrorEvent) - - // Verify error is handled correctly on Android - val state = viewModel.state.value - val errorMessage = state.messages.find { - it.role == MessageRole.ERROR && it.content.contains("Android platform error") - } - - assertNotNull(errorMessage) - } - - @Test - fun testErrorRecoveryOnAndroid() = runTest { - // Test system recovery after errors on Android platform - - // Cause an error - viewModel.handleAgentEvent(RunErrorEvent("Connection lost", "NETWORK_ERROR")) - - // System should continue working after error - viewModel.handleAgentEvent(TextMessageStartEvent("recovery-msg")) - viewModel.handleAgentEvent(TextMessageContentEvent("recovery-msg", "System recovered on Android")) - viewModel.handleAgentEvent(TextMessageEndEvent("recovery-msg")) - - // Verify both error and recovery message exist - val state = viewModel.state.value - val errorMessage = state.messages.find { it.role == MessageRole.ERROR } - val recoveryMessage = state.messages.find { it.id == "recovery-msg" } - - assertNotNull(errorMessage) - assertNotNull(recoveryMessage) - assertEquals("System recovered on Android", recoveryMessage.content) - } - - @Test - fun testStateConsistencyAfterErrorsOnAndroid() = runTest { - // Test that state remains consistent after various errors on Android platform - - val initialMessageCount = viewModel.state.value.messages.size - - // Create various error conditions - viewModel.handleAgentEvent(RunErrorEvent("Android Error 1", "E1")) - viewModel.handleAgentEvent(ToolCallStartEvent("bad-tool", "nonexistent_tool")) - viewModel.handleAgentEvent(ToolCallArgsEvent("bad-tool", "invalid json")) - viewModel.handleAgentEvent(TextMessageContentEvent("missing-msg", "orphaned content")) - - // Verify state is still consistent - val state = viewModel.state.value - assertNotNull(state) - assertNotNull(state.messages) - assertTrue(state.messages.size >= initialMessageCount) - - // Verify we can still process normal events - viewModel.handleAgentEvent(TextMessageStartEvent("normal-msg")) - viewModel.handleAgentEvent(TextMessageContentEvent("normal-msg", "Normal content on Android")) - viewModel.handleAgentEvent(TextMessageEndEvent("normal-msg")) - - val finalState = viewModel.state.value - val normalMessage = finalState.messages.find { it.id == "normal-msg" } - assertNotNull(normalMessage) - assertEquals("Normal content on Android", normalMessage.content) - } - - @Test - fun testConcurrentErrorHandlingOnAndroid() = runTest { - // Test handling multiple errors concurrently on Android - - // Send multiple error events rapidly - repeat(5) { i -> - viewModel.handleAgentEvent(RunErrorEvent("Android concurrent error $i", "ERROR_$i")) - } - - // All errors should be handled - val state = viewModel.state.value - val errorMessages = state.messages.filter { it.role == MessageRole.ERROR } - assertEquals(5, errorMessages.size) - } - - @Test - fun testLongContentHandlingOnAndroid() = runTest { - // Test handling of very long content on Android platform - val longContent = "Android: " + "x".repeat(10000) - - viewModel.handleAgentEvent(TextMessageStartEvent("long-msg")) - viewModel.handleAgentEvent(TextMessageContentEvent("long-msg", longContent)) - viewModel.handleAgentEvent(TextMessageEndEvent("long-msg")) - - // Verify content is handled properly on Android - val state = viewModel.state.value - val message = state.messages.find { it.id == "long-msg" } - assertNotNull(message) - assertEquals(longContent, message.content) - } -} \ No newline at end of file diff --git a/sdks/community/kotlin/examples/chatapp/shared/src/androidInstrumentedTest/kotlin/com/agui/example/chatapp/viewmodel/AndroidChatViewModelEventHandlingTest.kt b/sdks/community/kotlin/examples/chatapp/shared/src/androidInstrumentedTest/kotlin/com/agui/example/chatapp/viewmodel/AndroidChatViewModelEventHandlingTest.kt deleted file mode 100644 index 11d72c1f3..000000000 --- a/sdks/community/kotlin/examples/chatapp/shared/src/androidInstrumentedTest/kotlin/com/agui/example/chatapp/viewmodel/AndroidChatViewModelEventHandlingTest.kt +++ /dev/null @@ -1,186 +0,0 @@ -package com.agui.example.chatapp.viewmodel - -import android.content.Context -import androidx.test.ext.junit.runners.AndroidJUnit4 -import androidx.test.platform.app.InstrumentationRegistry -import com.agui.example.chatapp.data.model.AgentConfig -import com.agui.example.chatapp.data.model.AuthMethod -import com.agui.example.chatapp.data.repository.AgentRepository -import com.agui.example.chatapp.util.initializeAndroid -import com.agui.example.chatapp.util.resetAndroidContext -import com.agui.core.types.* -import com.agui.example.chatapp.ui.screens.chat.ChatViewModel -import com.agui.example.chatapp.ui.screens.chat.EphemeralType -import com.agui.example.chatapp.ui.screens.chat.MessageRole -import kotlinx.coroutines.test.runTest -import org.junit.Before -import org.junit.After -import org.junit.Test -import org.junit.runner.RunWith -import kotlin.test.* - -/** - * Android integration tests for ChatViewModel event handling. - * Tests the real ChatViewModel with Android platform implementation. - * Runs on actual Android device/emulator where Android context is available. - */ -@RunWith(AndroidJUnit4::class) -class AndroidChatViewModelEventHandlingTest { - - private lateinit var viewModel: ChatViewModel - private lateinit var context: Context - - @Before - fun setup() { - // Reset any previous state - resetAndroidContext() - AgentRepository.resetInstance() - - // Initialize Android platform - context = InstrumentationRegistry.getInstrumentation().targetContext - initializeAndroid(context) - - // Create real ChatViewModel (will work now that Android is initialized) - viewModel = ChatViewModel() - - // Set up a test agent - val testAgent = AgentConfig( - id = "test-agent", - name = "Test Agent", - url = "https://test.com/agent", - authMethod = AuthMethod.None() - ) - } - - @After - fun tearDown() { - resetAndroidContext() - AgentRepository.resetInstance() - } - - @Test - fun testTextMessageStartEventOnAndroid() = runTest { - // Test that text message start events work on Android platform - val event = TextMessageStartEvent( - messageId = "msg-123" - ) - - viewModel.handleAgentEvent(event) - - // Verify that a new streaming message was added - val state = viewModel.state.value - val message = state.messages.find { it.id == "msg-123" } - - assertNotNull(message) - assertEquals(MessageRole.ASSISTANT, message.role) - assertEquals("", message.content) // Should start empty - assertTrue(message.isStreaming) - } - - @Test - fun testToolCallEventsOnAndroid() = runTest { - // Test tool call events work on Android platform - val toolStartEvent = ToolCallStartEvent( - toolCallId = "tool-123", - toolCallName = "test_tool" - ) - - viewModel.handleAgentEvent(toolStartEvent) - - // Verify ephemeral message is created - val state = viewModel.state.value - val ephemeralMessage = state.messages.find { - it.role == MessageRole.TOOL_CALL && it.content.contains("test_tool") - } - - assertNotNull(ephemeralMessage) - assertEquals(EphemeralType.TOOL_CALL, ephemeralMessage.ephemeralType) - } - - @Test - fun testUserConfirmationFlowOnAndroid() = runTest { - // Note: This test validates the internal event handling mechanism - // In real usage, confirmation would be triggered by the actual confirmation tool - - // For now, we'll test that the confirmation tool events are handled correctly - // but acknowledge that pendingConfirmation is set by the ConfirmationHandler, not the events directly - viewModel.handleAgentEvent(ToolCallStartEvent("confirm-123", "user_confirmation")) - - val confirmationArgs = """ - { - "action": "Delete file", - "impact": "high", - "details": {"file": "important.txt"}, - "timeout_seconds": 30 - } - """.trimIndent() - - viewModel.handleAgentEvent(ToolCallArgsEvent("confirm-123", confirmationArgs)) - viewModel.handleAgentEvent(ToolCallEndEvent("confirm-123")) - - // For internal event handling tests, we verify the events are processed without error - // The actual pendingConfirmation is set by the ConfirmationHandler during real tool execution - val state = viewModel.state.value - // The confirmation dialog state would be set by the actual tool execution, not direct events - // For now, we just verify the events were handled without throwing errors - assertNotNull(state) // Basic state validation - } - - @Test - fun testErrorHandlingOnAndroid() = runTest { - // Test error handling works on Android platform - val errorEvent = RunErrorEvent( - message = "Connection failed", - code = "NETWORK_ERROR" - ) - - viewModel.handleAgentEvent(errorEvent) - - // Verify error message is added - val state = viewModel.state.value - val errorMessage = state.messages.find { - it.role == MessageRole.ERROR && it.content.contains("Connection failed") - } - - assertNotNull(errorMessage) - } - - @Test - fun testAndroidPlatformSpecificBehavior() = runTest { - // Test any Android-specific behavior (if any) - // For now, just verify the ViewModel works correctly on Android - - // Send multiple events - viewModel.handleAgentEvent(TextMessageStartEvent("msg-1")) - viewModel.handleAgentEvent(TextMessageContentEvent("msg-1", "Hello from Android!")) - viewModel.handleAgentEvent(TextMessageEndEvent("msg-1")) - - // Verify Android platform handles them correctly - val state = viewModel.state.value - val message = state.messages.find { it.id == "msg-1" } - - assertNotNull(message) - assertEquals("Hello from Android!", message.content) - assertFalse(message.isStreaming) - } - - @Test - fun testStateConsistencyOnAndroid() = runTest { - // Verify state remains consistent through Android platform operations - - // Initial state - val initialMessageCount = viewModel.state.value.messages.size - - // Add some events - viewModel.handleAgentEvent(StepStartedEvent("Processing on Android")) - viewModel.handleAgentEvent(ToolCallStartEvent("tool-456", "android_tool")) - - // Verify state is consistent - val state = viewModel.state.value - assertEquals(initialMessageCount + 2, state.messages.size) - - // Verify ephemeral messages are properly managed - val ephemeralMessages = state.messages.filter { it.ephemeralType != null } - assertEquals(2, ephemeralMessages.size) - } -} \ No newline at end of file diff --git a/sdks/community/kotlin/examples/chatapp/shared/src/androidInstrumentedTest/kotlin/com/agui/example/chatapp/viewmodel/AndroidChatViewModelStateManagementTest.kt b/sdks/community/kotlin/examples/chatapp/shared/src/androidInstrumentedTest/kotlin/com/agui/example/chatapp/viewmodel/AndroidChatViewModelStateManagementTest.kt deleted file mode 100644 index c02c83418..000000000 --- a/sdks/community/kotlin/examples/chatapp/shared/src/androidInstrumentedTest/kotlin/com/agui/example/chatapp/viewmodel/AndroidChatViewModelStateManagementTest.kt +++ /dev/null @@ -1,412 +0,0 @@ -package com.agui.example.chatapp.viewmodel - -import android.content.Context -import androidx.test.ext.junit.runners.AndroidJUnit4 -import androidx.test.platform.app.InstrumentationRegistry -import com.agui.example.chatapp.data.model.AgentConfig -import com.agui.example.chatapp.data.model.AuthMethod -import com.agui.example.chatapp.data.repository.AgentRepository -import com.agui.example.chatapp.util.initializeAndroid -import com.agui.example.chatapp.util.resetAndroidContext -import com.agui.example.chatapp.util.getPlatformSettings -import com.agui.core.types.* -import com.agui.example.chatapp.ui.screens.chat.ChatViewModel -import com.agui.example.chatapp.ui.screens.chat.MessageRole -import kotlinx.coroutines.test.runTest -import kotlinx.coroutines.delay -import kotlinx.coroutines.runBlocking -import org.junit.Before -import org.junit.After -import org.junit.Test -import org.junit.runner.RunWith -import kotlin.test.* - -/** - * Android integration tests for ChatViewModel state management functionality. - * These tests focus on state changes, agent switching, and UI state consistency - * that require proper Android platform context. - */ -@RunWith(AndroidJUnit4::class) -class AndroidChatViewModelStateManagementTest { - - private lateinit var viewModel: ChatViewModel - private lateinit var context: Context - private lateinit var agentRepository: AgentRepository - private lateinit var settings: com.russhwolf.settings.Settings - - @Before - fun setup() { - // Reset any previous state - resetAndroidContext() - AgentRepository.resetInstance() - - // Initialize Android platform - context = InstrumentationRegistry.getInstrumentation().targetContext - initializeAndroid(context) - - // Create real ChatViewModel and repository with Android settings - settings = getPlatformSettings() - agentRepository = AgentRepository.getInstance(settings) - viewModel = ChatViewModel() - - // Give ViewModel time to set up its flow observations - runBlocking { delay(100) } - } - - @After - fun tearDown() { - resetAndroidContext() - AgentRepository.resetInstance() - } - - @Test - fun testConnectionStateManagement() = runTest { - val testAgent = AgentConfig( - id = "test-agent", - name = "Test Agent", - url = "https://test.com/agent", - authMethod = AuthMethod.None() - ) - - // Initial state - assertFalse(viewModel.state.value.isConnected) - assertNull(viewModel.state.value.activeAgent) - - // Verify repository and ViewModel use same instance - val vmRepository = AgentRepository.getInstance(settings) - assertTrue(agentRepository === vmRepository, "Repository instances should be the same") - - // Set active agent - agentRepository.setActiveAgent(testAgent) - - // Wait for flow updates with multiple checks - var attempts = 0 - while (attempts < 20 && viewModel.state.value.activeAgent?.id != testAgent.id) { - delay(50) - attempts++ - } - - // State should reflect active agent - val connectedState = viewModel.state.value - assertNotNull(connectedState.activeAgent, "Active agent should be set") - assertEquals(testAgent.id, connectedState.activeAgent?.id) - assertEquals(testAgent.name, connectedState.activeAgent?.name) - - // Disconnect - agentRepository.setActiveAgent(null) - - // Wait for disconnect with multiple checks - attempts = 0 - while (attempts < 20 && (viewModel.state.value.activeAgent != null || viewModel.state.value.isConnected)) { - delay(50) - attempts++ - } - - // State should be cleared - val disconnectedState = viewModel.state.value - assertNull(disconnectedState.activeAgent, "Active agent should be null after disconnect") - assertFalse(disconnectedState.isConnected, "Should be disconnected after agent is cleared") - } - - @Test - fun testMultipleAgentSwitching() = runTest { - // Test switching between multiple agents - val agent1 = AgentConfig( - id = "agent-1", - name = "First Agent", - url = "https://first.com/agent", - authMethod = AuthMethod.None() - ) - - val agent2 = AgentConfig( - id = "agent-2", - name = "Second Agent", - url = "https://second.com/agent", - authMethod = AuthMethod.ApiKey("key-123") - ) - - // Connect to first agent - agentRepository.setActiveAgent(agent1) - - // Wait for first agent to be set - var attempts = 0 - while (attempts < 20 && viewModel.state.value.activeAgent?.id != agent1.id) { - delay(50) - attempts++ - } - - val state1 = viewModel.state.value - assertNotNull(state1.activeAgent, "First agent should be set") - assertEquals(agent1.id, state1.activeAgent?.id) - assertEquals(agent1.name, state1.activeAgent?.name) - - // Switch to second agent - agentRepository.setActiveAgent(agent2) - - // Wait for second agent to be set - attempts = 0 - while (attempts < 20 && viewModel.state.value.activeAgent?.id != agent2.id) { - delay(50) - attempts++ - } - - val state2 = viewModel.state.value - assertNotNull(state2.activeAgent, "Second agent should be set") - assertEquals(agent2.id, state2.activeAgent?.id) - assertEquals(agent2.name, state2.activeAgent?.name) - - // Verify messages were cleared on switch (except for system connection message) - assertTrue(state2.messages.size <= 1, "Should have at most 1 system message after agent switch") - if (state2.messages.isNotEmpty()) { - assertEquals(MessageRole.SYSTEM, state2.messages.first().role, "Only message should be system connection message") - } - assertNull(state2.pendingConfirmation) - } - - @Test - fun testAgentClientToolRegistration() = runTest { - // Test that AgentClient is created with proper tool registry - val testAgent = AgentConfig( - id = "test-agent", - name = "Test Agent", - url = "https://test.com/agent", - authMethod = AuthMethod.None() - ) - - agentRepository.setActiveAgent(testAgent) - delay(500) // Give more time for async agent connection - - // We can't directly test the AgentClient instance, but we can verify - // that confirmation tools are handled properly - viewModel.handleAgentEvent(ToolCallStartEvent("confirm-123", "user_confirmation")) - - val argsJson = """{"action": "Test action", "impact": "low"}""" - viewModel.handleAgentEvent(ToolCallArgsEvent("confirm-123", argsJson)) - viewModel.handleAgentEvent(ToolCallEndEvent("confirm-123")) - - // Note: With the new AgentClient API, tool confirmation is handled - // directly by the confirmation handler, not through parsing events - val state = viewModel.state.value - // The confirmation dialog may not appear since tool handling changed - // This test validates that the event processing doesn't crash - } - - @Test - fun testEmptyMessageIgnored() = runTest { - val testAgent = AgentConfig( - id = "test-agent", - name = "Test Agent", - url = "https://test.com/agent", - authMethod = AuthMethod.None() - ) - agentRepository.setActiveAgent(testAgent) - - // Wait for agent connection to complete and system message to be added - var attempts = 0 - while (attempts < 40 && viewModel.state.value.messages.isEmpty() && viewModel.state.value.error == null) { - delay(100) // Increased delay - attempts++ - println("Test attempt $attempts: messages=${viewModel.state.value.messages.size}, error=${viewModel.state.value.error}, connected=${viewModel.state.value.isConnected}") - } - - // Check if there's an error instead of a successful connection - val currentState = viewModel.state.value - println("Final state: messages=${currentState.messages.size}, error=${currentState.error}, connected=${currentState.isConnected}, activeAgent=${currentState.activeAgent?.name}") - - if (currentState.error != null) { - fail("Agent connection failed with error: ${currentState.error}") - } - - val initialMessageCount = currentState.messages.size - assertEquals(1, initialMessageCount, "Should have system message after connection. Final state: messages=${currentState.messages.size}, error=${currentState.error}, connected=${currentState.isConnected}") - - // Try to send empty messages - viewModel.sendMessage("") - viewModel.sendMessage(" ") - viewModel.sendMessage("\t\n") - - // Verify no messages were added - val finalMessageCount = viewModel.state.value.messages.size - assertEquals(initialMessageCount, finalMessageCount) - } - - @Test - fun testThreadIdGeneration() = runTest { - // Test that each connection gets a unique thread ID - val testAgent = AgentConfig( - id = "test-agent", - name = "Test Agent", - url = "https://test.com/agent", - authMethod = AuthMethod.None() - ) - - // Connect and disconnect multiple times - for (i in 1..3) { - agentRepository.setActiveAgent(testAgent) - delay(50) - - // Add a user message to trigger state changes - viewModel.handleAgentEvent(TextMessageStartEvent("msg-$i")) - viewModel.handleAgentEvent(TextMessageContentEvent("msg-$i", "Test message $i")) - viewModel.handleAgentEvent(TextMessageEndEvent("msg-$i")) - delay(50) - - agentRepository.setActiveAgent(null) - delay(50) - } - - // Each connection should have created a unique thread - // (We can't directly verify thread IDs, but we verify no errors occurred) - assertTrue(true) // Test passes if no exceptions were thrown - } - - @Test - fun testMessageSendingWithoutConnection() = runTest { - // Try to send message without connection - val initialMessageCount = viewModel.state.value.messages.size - - viewModel.sendMessage("This should be ignored") - - // Verify no message was added - val finalMessageCount = viewModel.state.value.messages.size - assertEquals(initialMessageCount, finalMessageCount) - } - - @Test - fun testAgentConfigurationPersistence() = runTest { - // Test that agent configuration is properly maintained during state changes - val testAgent = AgentConfig( - id = "config-test", - name = "Configuration Test Agent", - url = "https://config.test.com/agent", - authMethod = AuthMethod.BearerToken("test-token-123") - ) - - // Set agent and verify configuration - agentRepository.setActiveAgent(testAgent) - delay(500) // Give more time for async agent connection - - val state1 = viewModel.state.value - assertEquals(testAgent.id, state1.activeAgent?.id) - assertEquals(testAgent.name, state1.activeAgent?.name) - assertEquals(testAgent.url, state1.activeAgent?.url) - assertTrue(state1.activeAgent?.authMethod is AuthMethod.BearerToken) - - // Trigger some state changes - viewModel.handleAgentEvent(TextMessageStartEvent("test-1")) - viewModel.handleAgentEvent(TextMessageContentEvent("test-1", "Hello")) - viewModel.handleAgentEvent(TextMessageEndEvent("test-1")) - - // Verify agent config is still intact - val state2 = viewModel.state.value - assertEquals(testAgent.id, state2.activeAgent?.id) - assertEquals(testAgent.name, state2.activeAgent?.name) - assertEquals(testAgent.url, state2.activeAgent?.url) - } - - @Test - fun testStateConsistencyDuringEventProcessing() = runTest { - // Test that state remains consistent during rapid event processing - val testAgent = AgentConfig( - id = "consistency-test", - name = "Consistency Test Agent", - url = "https://consistency.test.com/agent", - authMethod = AuthMethod.None() - ) - - agentRepository.setActiveAgent(testAgent) - - // Wait for agent to be set before proceeding - var attempts = 0 - while (attempts < 20 && viewModel.state.value.activeAgent?.id != testAgent.id) { - delay(50) - attempts++ - } - - // Verify agent is set before proceeding - val preState = viewModel.state.value - assertNotNull(preState.activeAgent, "Agent should be set before processing events") - assertEquals(testAgent.id, preState.activeAgent?.id) - - // Process multiple events with small delays to ensure proper handling - repeat(10) { i -> - viewModel.handleAgentEvent(TextMessageStartEvent("rapid-$i")) - viewModel.handleAgentEvent(TextMessageContentEvent("rapid-$i", "Message $i")) - viewModel.handleAgentEvent(TextMessageEndEvent("rapid-$i")) - - // Small delay to ensure events are processed - delay(10) - - if (i % 3 == 0) { - viewModel.handleAgentEvent(ToolCallStartEvent("tool-$i", "test_tool")) - viewModel.handleAgentEvent(ToolCallEndEvent("tool-$i")) - delay(10) - } - } - - // Allow extra time for all events to process - delay(200) - - // Verify state is consistent - val finalState = viewModel.state.value - assertNotNull(finalState) - assertNotNull(finalState.activeAgent, "Agent should still be set after event processing") - assertEquals(testAgent.id, finalState.activeAgent!!.id) - assertEquals(testAgent.name, finalState.activeAgent!!.name) - - // Debug output - val allMessages = finalState.messages - println("Total messages: ${allMessages.size}") - allMessages.forEach { msg -> - println("Message: role=${msg.role}, id=${msg.id}, content=${msg.content.take(20)}...") - } - - // Should have 10 text messages - val textMessages = finalState.messages.filter { it.role == MessageRole.ASSISTANT } - val toolMessages = finalState.messages.filter { it.role == MessageRole.TOOL_CALL } - - println("Text messages: ${textMessages.size}, Tool messages: ${toolMessages.size}") - - // Accept at least 3 messages since that's what we're getting consistently - assertTrue(textMessages.size >= 3, "Should have at least 3 text messages, got ${textMessages.size}") - // Note: Reduced expectation to match actual behavior - } - - @Test - fun testPendingConfirmationStateManagement() = runTest { - // Test that confirmation tool events are handled properly on Android - // This test verifies that user_confirmation tool events don't create visible messages - - // Create a fresh ChatViewModel to avoid state from other tests - val freshViewModel = ChatViewModel() - - // Handle confirmation tool events directly without agent connection - freshViewModel.handleAgentEvent(ToolCallStartEvent("confirm-1", "user_confirmation")) - freshViewModel.handleAgentEvent(ToolCallArgsEvent("confirm-1", """{"action": "First action", "impact": "low"}""")) - freshViewModel.handleAgentEvent(ToolCallEndEvent("confirm-1")) - - // Verify no messages were created (user_confirmation tools should not create ephemeral messages) - var state = freshViewModel.state.value - assertNotNull(state) - assertEquals(0, state.messages.size, "No messages should be created from user_confirmation tool events") - - // Handle another confirmation tool sequence - freshViewModel.handleAgentEvent(ToolCallStartEvent("confirm-2", "user_confirmation")) - freshViewModel.handleAgentEvent(ToolCallArgsEvent("confirm-2", """{"action": "Second action", "impact": "high"}""")) - freshViewModel.handleAgentEvent(ToolCallEndEvent("confirm-2")) - - // Verify state remains consistent - state = freshViewModel.state.value - assertNotNull(state) - assertEquals(0, state.messages.size, "Still no messages after second confirmation tool sequence") - - // Verify non-confirmation tools DO create ephemeral messages - freshViewModel.handleAgentEvent(ToolCallStartEvent("tool-1", "some_other_tool")) - delay(50) // Give time for ephemeral message to be set - - state = freshViewModel.state.value - assertEquals(1, state.messages.size, "Non-confirmation tools should create ephemeral messages") - - freshViewModel.handleAgentEvent(ToolCallEndEvent("tool-1")) - } -} \ No newline at end of file diff --git a/sdks/community/kotlin/examples/chatapp/shared/src/androidInstrumentedTest/kotlin/com/agui/example/chatapp/viewmodel/AndroidChatViewModelToolConfirmationTest.kt b/sdks/community/kotlin/examples/chatapp/shared/src/androidInstrumentedTest/kotlin/com/agui/example/chatapp/viewmodel/AndroidChatViewModelToolConfirmationTest.kt deleted file mode 100644 index 7d34764ff..000000000 --- a/sdks/community/kotlin/examples/chatapp/shared/src/androidInstrumentedTest/kotlin/com/agui/example/chatapp/viewmodel/AndroidChatViewModelToolConfirmationTest.kt +++ /dev/null @@ -1,213 +0,0 @@ -package com.agui.example.chatapp.viewmodel - -import android.content.Context -import androidx.test.ext.junit.runners.AndroidJUnit4 -import androidx.test.platform.app.InstrumentationRegistry -import com.agui.example.chatapp.data.model.AgentConfig -import com.agui.example.chatapp.data.model.AuthMethod -import com.agui.example.chatapp.data.repository.AgentRepository -import com.agui.example.chatapp.util.initializeAndroid -import com.agui.example.chatapp.util.resetAndroidContext -import com.agui.core.types.* -import com.agui.example.chatapp.ui.screens.chat.ChatViewModel -import com.agui.example.chatapp.ui.screens.chat.MessageRole -import kotlinx.coroutines.test.runTest -import org.junit.Before -import org.junit.After -import org.junit.Test -import org.junit.runner.RunWith -import kotlin.test.* - -/** - * Android integration tests for ChatViewModel tool confirmation flow. - * Tests the complete tool confirmation workflow on Android platform. - * Runs on actual Android device/emulator where Android context is available. - */ -@RunWith(AndroidJUnit4::class) -class AndroidChatViewModelToolConfirmationTest { - - private lateinit var viewModel: ChatViewModel - private lateinit var context: Context - - @Before - fun setup() { - // Reset any previous state - resetAndroidContext() - AgentRepository.resetInstance() - - // Initialize Android platform - context = InstrumentationRegistry.getInstrumentation().targetContext - initializeAndroid(context) - - // Create real ChatViewModel (will work now that Android is initialized) - viewModel = ChatViewModel() - - // Set up a test agent - val testAgent = AgentConfig( - id = "test-agent", - name = "Test Agent", - url = "https://test.com/agent", - authMethod = AuthMethod.None() - ) - } - - @After - fun tearDown() { - resetAndroidContext() - AgentRepository.resetInstance() - } - - @Test - fun testConfirmationDetectionOnAndroid() = runTest { - // Test that user_confirmation tools are detected on Android - val toolStartEvent = ToolCallStartEvent( - toolCallId = "confirm-123", - toolCallName = "user_confirmation" - ) - - viewModel.handleAgentEvent(toolStartEvent) - - // Verify that no ephemeral message is created for confirmation tools - val state = viewModel.state.value - val toolMessages = state.messages.filter { it.role == MessageRole.TOOL_CALL } - assertTrue(toolMessages.isEmpty(), "Confirmation tools should not show ephemeral messages on Android") - } - - @Test - fun testConfirmationArgsBuildingOnAndroid() = runTest { - // Test that confirmation tool events are processed correctly on Android platform - // Note: In real usage, pendingConfirmation is set by ConfirmationHandler during tool execution - viewModel.handleAgentEvent(ToolCallStartEvent("confirm-123", "user_confirmation")) - - // Send args in chunks (testing Android's JSON handling) - val argsChunk1 = """{"action": "Delete Android file", "impact": """ - val argsChunk2 = """"critical", "details": {"path": "/android/data/file.db", """ - val argsChunk3 = """"size": "1MB"}, "timeout_seconds": 60}""" - - viewModel.handleAgentEvent(ToolCallArgsEvent("confirm-123", argsChunk1)) - viewModel.handleAgentEvent(ToolCallArgsEvent("confirm-123", argsChunk2)) - viewModel.handleAgentEvent(ToolCallArgsEvent("confirm-123", argsChunk3)) - - // End the tool call - viewModel.handleAgentEvent(ToolCallEndEvent("confirm-123")) - - // Verify events were processed without errors on Android - val state = viewModel.state.value - assertNotNull(state) - // Test validates that the event sequence was handled without throwing exceptions - // In real usage, the ConfirmationHandler would create the pendingConfirmation - } - - @Test - fun testConfirmationFlowOnAndroid() = runTest { - // Test that confirmation tool events are processed correctly on Android - // Note: In real usage, pendingConfirmation is set by ConfirmationHandler, not direct events - setupConfirmationDialog() - - // Verify events were processed without errors - val state = viewModel.state.value - assertNotNull(state) - - // In real usage, confirmAction() would be called when a confirmation dialog exists - // For now, we test that the method doesn't crash when called without a dialog - viewModel.confirmAction() // Should handle gracefully - - // Verify state remains consistent - val finalState = viewModel.state.value - assertNotNull(finalState) - } - - @Test - fun testRejectionFlowOnAndroid() = runTest { - // Test that confirmation tool events are processed correctly on Android - setupConfirmationDialog() - - // Verify events were processed without errors - val state = viewModel.state.value - assertNotNull(state) - - // In real usage, rejectAction() would be called when a confirmation dialog exists - // For now, we test that the method doesn't crash when called without a dialog - viewModel.rejectAction() // Should handle gracefully - - // Verify state remains consistent - val finalState = viewModel.state.value - assertNotNull(finalState) - } - - @Test - fun testInvalidJsonHandlingOnAndroid() = runTest { - // Test Android's JSON error handling - viewModel.handleAgentEvent(ToolCallStartEvent("confirm-123", "user_confirmation")) - - // Use malformed JSON to test Android's parsing - val invalidArgs = """{"action": "Test", malformed json on android}""" - - viewModel.handleAgentEvent(ToolCallArgsEvent("confirm-123", invalidArgs)) - viewModel.handleAgentEvent(ToolCallEndEvent("confirm-123")) - - // Verify Android handles JSON errors gracefully - events should be processed without crashing - val state = viewModel.state.value - assertNotNull(state) // State should remain valid even with malformed JSON - } - - @Test - fun testMultipleConfirmationsOnAndroid() = runTest { - // Test handling multiple confirmations on Android - - // First confirmation - viewModel.handleAgentEvent(ToolCallStartEvent("confirm-1", "user_confirmation")) - viewModel.handleAgentEvent(ToolCallArgsEvent("confirm-1", """{"action": "First Android action"}""")) - viewModel.handleAgentEvent(ToolCallEndEvent("confirm-1")) - - val state1 = viewModel.state.value - assertNotNull(state1) // Events processed without error - - // Second confirmation (should replace first on Android) - viewModel.handleAgentEvent(ToolCallStartEvent("confirm-2", "user_confirmation")) - viewModel.handleAgentEvent(ToolCallArgsEvent("confirm-2", """{"action": "Second Android action"}""")) - viewModel.handleAgentEvent(ToolCallEndEvent("confirm-2")) - - val state2 = viewModel.state.value - assertNotNull(state2) // Both event sequences processed without error - // In real usage, the ConfirmationHandler would manage pendingConfirmation state - } - - @Test - fun testAndroidSpecificConfirmationBehavior() = runTest { - // Test that confirmation tool events are handled correctly with other events on Android - setupConfirmationDialog() - - val initialState = viewModel.state.value - assertNotNull(initialState) // Events processed successfully - - // Trigger some other events to test state consistency - viewModel.handleAgentEvent(TextMessageStartEvent("msg-1")) - viewModel.handleAgentEvent(StepStartedEvent("android step")) - - // Verify state remains consistent after processing mixed events - val state = viewModel.state.value - assertNotNull(state) - // Test that the ChatViewModel handles mixed event types without errors - // In real usage, confirmation state would be managed by ConfirmationHandler - } - - /** - * Helper method to set up a basic confirmation dialog for Android testing. - */ - private fun setupConfirmationDialog() { - viewModel.handleAgentEvent(ToolCallStartEvent("confirm-test", "user_confirmation")) - - val confirmationArgs = """ - { - "action": "Test Android action", - "impact": "medium", - "details": {"platform": "android"}, - "timeout_seconds": 30 - } - """.trimIndent() - - viewModel.handleAgentEvent(ToolCallArgsEvent("confirm-test", confirmationArgs)) - viewModel.handleAgentEvent(ToolCallEndEvent("confirm-test")) - } -} \ No newline at end of file diff --git a/sdks/community/kotlin/examples/chatapp/shared/src/commonMain/kotlin/com/agui/example/chatapp/App.kt b/sdks/community/kotlin/examples/chatapp/shared/src/commonMain/kotlin/com/agui/example/chatapp/App.kt index 89332b8e4..ba4d2c8c9 100644 --- a/sdks/community/kotlin/examples/chatapp/shared/src/commonMain/kotlin/com/agui/example/chatapp/App.kt +++ b/sdks/community/kotlin/examples/chatapp/shared/src/commonMain/kotlin/com/agui/example/chatapp/App.kt @@ -1,16 +1,27 @@ package com.agui.example.chatapp import androidx.compose.runtime.Composable -import cafe.adriel.voyager.navigator.Navigator -import cafe.adriel.voyager.transitions.SlideTransition +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.getValue +import androidx.compose.runtime.setValue import com.agui.example.chatapp.ui.screens.chat.ChatScreen +import com.agui.example.chatapp.ui.screens.settings.SettingsScreen import com.agui.example.chatapp.ui.theme.AgentChatTheme @Composable fun App() { AgentChatTheme { - Navigator(ChatScreen()) { navigator -> - SlideTransition(navigator) + var currentScreen by remember { mutableStateOf(Screen.Chat) } + + when (currentScreen) { + Screen.Chat -> ChatScreen(onOpenSettings = { currentScreen = Screen.Settings }) + Screen.Settings -> SettingsScreen(onBack = { currentScreen = Screen.Chat }) } } -} \ No newline at end of file +} + +private sealed interface Screen { + data object Chat : Screen + data object Settings : Screen +} diff --git a/sdks/community/kotlin/examples/chatapp/shared/src/commonMain/kotlin/com/agui/example/chatapp/ui/screens/chat/ChatScreen.kt b/sdks/community/kotlin/examples/chatapp/shared/src/commonMain/kotlin/com/agui/example/chatapp/ui/screens/chat/ChatScreen.kt index 47f6f9b7c..be80f9cd9 100644 --- a/sdks/community/kotlin/examples/chatapp/shared/src/commonMain/kotlin/com/agui/example/chatapp/ui/screens/chat/ChatScreen.kt +++ b/sdks/community/kotlin/examples/chatapp/shared/src/commonMain/kotlin/com/agui/example/chatapp/ui/screens/chat/ChatScreen.kt @@ -1,80 +1,82 @@ package com.agui.example.chatapp.ui.screens.chat -import androidx.compose.foundation.layout.* -import androidx.compose.material3.* -import androidx.compose.runtime.* -import androidx.compose.ui.Modifier +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.material3.Button +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp -import cafe.adriel.voyager.core.model.rememberScreenModel -import cafe.adriel.voyager.core.screen.Screen -import cafe.adriel.voyager.navigator.LocalNavigator -import cafe.adriel.voyager.navigator.currentOrThrow import com.agui.example.chatapp.ui.screens.chat.components.ChatHeader import com.agui.example.chatapp.ui.screens.chat.components.ChatInput import com.agui.example.chatapp.ui.screens.chat.components.MessageList -import com.agui.example.chatapp.ui.screens.chat.components.UserConfirmationDialog -import com.agui.example.chatapp.ui.screens.settings.SettingsScreen +import com.agui.example.chatapp.ui.theme.AgentChatTheme import org.jetbrains.compose.resources.stringResource -import agui4kclient.shared.generated.resources.* +import agui4kclient.shared.generated.resources.Res +import agui4kclient.shared.generated.resources.go_to_settings +import agui4kclient.shared.generated.resources.no_agent_selected +import agui4kclient.shared.generated.resources.no_agent_selected_description -class ChatScreen : Screen { - @Composable - override fun Content() { - val navigator = LocalNavigator.currentOrThrow - val viewModel = rememberScreenModel { ChatViewModel() } - val state by viewModel.state.collectAsState() +@Composable +fun ChatScreen( + onOpenSettings: () -> Unit +) { + val viewModel = rememberChatViewModel() + val state by viewModel.state.collectAsState() - Scaffold( - topBar = { - ChatHeader( - agentName = state.activeAgent?.name ?: stringResource(Res.string.no_agent_selected), - isConnected = state.isConnected, - onSettingsClick = { - navigator.push(SettingsScreen()) - } - ) - }, - bottomBar = { - ChatInput( - enabled = state.activeAgent != null && !state.isLoading, - onSendMessage = { message -> - viewModel.sendMessage(message) - } - ) - } - ) { paddingValues -> - Box( - modifier = Modifier - .fillMaxSize() - .padding(paddingValues) - ) { - when { - state.activeAgent == null -> { - NoAgentSelected( - onGoToSettings = { - navigator.push(SettingsScreen()) - } - ) - } - else -> { - MessageList( - messages = state.messages, - isLoading = state.isLoading - ) - } + Scaffold( + topBar = { + ChatHeader( + agentName = state.activeAgent?.name ?: stringResource(Res.string.no_agent_selected), + isConnected = state.isConnected, + onSettingsClick = onOpenSettings + ) + }, + bottomBar = { + ChatInput( + enabled = state.activeAgent != null && !state.isLoading, + onSendMessage = { message -> + viewModel.sendMessage(message) } - } + ) + } + ) { paddingValues -> + val defaultBackground = MaterialTheme.colorScheme.background + val backgroundColor = remember(state.background, defaultBackground) { + state.background.toComposeColor(defaultBackground) } - // Show confirmation dialog if there's a pending confirmation - state.pendingConfirmation?.let { confirmation -> - UserConfirmationDialog( - request = confirmation, - onConfirm = { viewModel.confirmAction() }, - onReject = { viewModel.rejectAction() } - ) + Box( + modifier = Modifier + .fillMaxSize() + .padding(paddingValues) + .background(backgroundColor) + ) { + when { + state.activeAgent == null -> { + NoAgentSelected(onOpenSettings) + } + else -> { + MessageList( + messages = state.messages, + isLoading = state.isLoading + ) + } + } } } } @@ -107,10 +109,22 @@ private fun NoAgentSelected( Spacer(modifier = Modifier.height(32.dp)) - Button( - onClick = onGoToSettings - ) { + Button(onClick = onGoToSettings) { Text(stringResource(Res.string.go_to_settings)) } } -} \ No newline at end of file +} + +private fun com.agui.example.tools.BackgroundStyle.toComposeColor(default: Color): Color { + val hex = colorHex?.removePrefix("#") ?: return default + return when (hex.length) { + 6 -> hex.toLongOrNull(16)?.let { Color((0xFF000000 or it).toInt()) } ?: default + 8 -> { + val rgbPart = hex.substring(0, 6) + val alphaPart = hex.substring(6, 8) + val argb = (alphaPart + rgbPart).toLongOrNull(16) ?: return default + Color(argb.toInt()) + } + else -> default + } +} diff --git a/sdks/community/kotlin/examples/chatapp/shared/src/commonMain/kotlin/com/agui/example/chatapp/ui/screens/chat/ChatViewModel.kt b/sdks/community/kotlin/examples/chatapp/shared/src/commonMain/kotlin/com/agui/example/chatapp/ui/screens/chat/ChatViewModel.kt index 8a20cdaaf..47efa9fc3 100644 --- a/sdks/community/kotlin/examples/chatapp/shared/src/commonMain/kotlin/com/agui/example/chatapp/ui/screens/chat/ChatViewModel.kt +++ b/sdks/community/kotlin/examples/chatapp/shared/src/commonMain/kotlin/com/agui/example/chatapp/ui/screens/chat/ChatViewModel.kt @@ -1,490 +1,45 @@ package com.agui.example.chatapp.ui.screens.chat -import cafe.adriel.voyager.core.model.ScreenModel -import cafe.adriel.voyager.core.model.screenModelScope -import com.agui.client.AgUiAgent -import com.agui.tools.DefaultToolRegistry -import com.agui.example.tools.ConfirmationToolExecutor -import com.agui.example.tools.ConfirmationHandler -import com.agui.example.tools.ConfirmationRequest -import com.agui.example.chatapp.data.auth.AuthManager -import com.agui.example.chatapp.data.model.AgentConfig -import com.agui.example.chatapp.data.repository.AgentRepository -import com.agui.core.types.* -import com.agui.example.chatapp.util.getPlatformSettings -import com.agui.example.chatapp.util.Strings -import com.agui.example.chatapp.util.UserIdManager -import kotlinx.coroutines.Job -import kotlinx.coroutines.delay -import kotlinx.coroutines.flow.* -import kotlinx.coroutines.launch -import kotlinx.coroutines.CancellableContinuation -import kotlinx.coroutines.suspendCancellableCoroutine -import kotlin.coroutines.resume -import kotlinx.datetime.Clock -import kotlinx.serialization.json.* -import co.touchlab.kermit.Logger +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.remember +import com.agui.example.chatapp.chat.ChatController +import com.agui.example.chatapp.chat.ChatState +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.MainScope +import kotlinx.coroutines.cancel +import kotlinx.coroutines.flow.StateFlow -private val logger = Logger.withTag("ChatViewModel") +/** + * Compose-facing wrapper around [ChatController]. + */ +class ChatViewModel( + scopeFactory: () -> CoroutineScope = { MainScope() }, + controllerFactory: (CoroutineScope) -> ChatController = { scope -> ChatController(scope) } +) { -data class ChatState( - val activeAgent: AgentConfig? = null, - val messages: List = emptyList(), - val ephemeralMessage: DisplayMessage? = null, - val isLoading: Boolean = false, - val isConnected: Boolean = false, - val error: String? = null, - val pendingConfirmation: UserConfirmationRequest? = null -) + private val scope = scopeFactory() + private val controller = controllerFactory(scope) -data class DisplayMessage( - val id: String, - val role: MessageRole, - val content: String, - val timestamp: Long = Clock.System.now().toEpochMilliseconds(), - val isStreaming: Boolean = false, - val ephemeralGroupId: String? = null, - val ephemeralType: EphemeralType? = null -) + val state: StateFlow = controller.state -data class UserConfirmationRequest( - val toolCallId: String, - val action: String, - val impact: String, - val details: Map = emptyMap(), - val timeout: Int = 30 -) + fun sendMessage(content: String) = controller.sendMessage(content) -enum class MessageRole { - USER, ASSISTANT, SYSTEM, ERROR, TOOL_CALL, STEP_INFO -} - -enum class EphemeralType { - TOOL_CALL, STEP -} - -class ChatViewModel : ScreenModel { - private val settings = getPlatformSettings() - private val agentRepository = AgentRepository.getInstance(settings) - private val authManager = AuthManager() - private val userIdManager = UserIdManager.getInstance(settings) - - // Track ephemeral messages by type - private val ephemeralMessageIds = mutableMapOf() - private val toolCallBuffer = mutableMapOf() - private val pendingToolCalls = mutableMapOf() // toolCallId -> toolName - - private val _state = MutableStateFlow(ChatState()) - val state: StateFlow = _state.asStateFlow() - - private var currentClient: AgUiAgent? = null - private var currentJob: Job? = null - private var currentThreadId: String? = null - private val streamingMessages = mutableMapOf() - private var pendingConfirmationContinuation: CancellableContinuation? = null - - init { - screenModelScope.launch { - // Observe active agent changes - agentRepository.activeAgent.collect { agent -> - _state.update { it.copy(activeAgent = agent) } - if (agent != null) { - connectToAgent(agent) - } else { - disconnectFromAgent() - } - } - } - } - - private fun setEphemeralMessage(content: String, type: EphemeralType, icon: String = "") { - _state.update { state -> - // Remove the old ephemeral message of this type if it exists - val oldId = ephemeralMessageIds[type] - val filtered = if (oldId != null) { - state.messages.filter { it.id != oldId } - } else { - state.messages - } - - // Create new message with icon - val newMessage = DisplayMessage( - id = generateMessageId(), - role = when (type) { - EphemeralType.TOOL_CALL -> MessageRole.TOOL_CALL - EphemeralType.STEP -> MessageRole.STEP_INFO - }, - content = "$icon $content".trim(), - ephemeralGroupId = type.name, - ephemeralType = type - ) - - // Track the new ID - ephemeralMessageIds[type] = newMessage.id - - state.copy(messages = filtered + newMessage) - } - } - - private fun clearEphemeralMessage(type: EphemeralType) { - val messageId = ephemeralMessageIds[type] - if (messageId != null) { - _state.update { state -> - state.copy( - messages = state.messages.filter { it.id != messageId } - ) - } - ephemeralMessageIds.remove(type) - } - } - - private suspend fun connectToAgent(agentConfig: AgentConfig) { - disconnectFromAgent() - - try { - // Apply authentication - val headers = agentConfig.customHeaders.toMutableMap() - authManager.applyAuth(agentConfig.authMethod, headers) - - // Create confirmation tool with a handler that integrates with our UI - val confirmationHandler = object : ConfirmationHandler { - override suspend fun requestConfirmation(request: ConfirmationRequest): Boolean { - // Show the confirmation dialog and wait for user response - return suspendCancellableCoroutine { continuation -> - _state.update { - it.copy( - pendingConfirmation = UserConfirmationRequest( - toolCallId = request.toolCallId, - action = request.message, - impact = request.importance, - details = mapOf("details" to (request.details ?: "")), - timeout = 30 - ) - ) - } - - // Store the continuation so we can resume it when user responds - pendingConfirmationContinuation = continuation - } - } - } - val confirmationTool = ConfirmationToolExecutor(confirmationHandler) - - // Create new agent client with the new SDK API - val clientToolRegistry = DefaultToolRegistry().apply { - registerTool(confirmationTool) - } - - currentClient = AgUiAgent(url = agentConfig.url) { - // Add all headers (including auth headers set by AuthManager) - headers.putAll(headers) - - // Set tool registry - toolRegistry = clientToolRegistry - - // Set persistent user ID - userId = userIdManager.getUserId() - - // Set system prompt if provided - systemPrompt = agentConfig.systemPrompt - } - - // Generate new thread ID for this session - currentThreadId = "thread_${Clock.System.now().toEpochMilliseconds()}" - - _state.update { it.copy(isConnected = true, error = null) } - - // Add system message - addDisplayMessage( - DisplayMessage( - id = generateMessageId(), - role = MessageRole.SYSTEM, - content = "${Strings.CONNECTED_TO_PREFIX}${agentConfig.name}" - ) - ) - } catch (e: Exception) { - logger.e(e) { "Failed to connect to agent" } - _state.update { - it.copy( - isConnected = false, - error = "${Strings.FAILED_TO_CONNECT_PREFIX}${e.message}" - ) - } - } - } - - private fun disconnectFromAgent() { - currentJob?.cancel() - currentJob = null - currentClient = null - currentThreadId = null - streamingMessages.clear() - toolCallBuffer.clear() - pendingToolCalls.clear() - ephemeralMessageIds.clear() - - // Cancel any pending confirmations - pendingConfirmationContinuation?.cancel() - pendingConfirmationContinuation = null - - _state.update { - it.copy( - isConnected = false, - messages = emptyList(), - pendingConfirmation = null - ) - } - } - - fun sendMessage(content: String) { - if (content.isBlank() || currentClient == null) return - - // Add user message to display - addDisplayMessage( - DisplayMessage( - id = generateMessageId(), - role = MessageRole.USER, - content = content.trim() - ) - ) - - // Start conversation with stateful client - startConversation(content.trim()) - } - - private fun startConversation(content: String) { - currentJob?.cancel() - - currentJob = screenModelScope.launch { - _state.update { it.copy(isLoading = true) } - - try { - currentClient?.sendMessage( - message = content, - threadId = currentThreadId ?: "default" - )?.collect { event -> - handleAgentEvent(event) - } - } catch (e: Exception) { - logger.e(e) { "Error running agent" } - addDisplayMessage( - DisplayMessage( - id = generateMessageId(), - role = MessageRole.ERROR, - content = "${Strings.ERROR_PREFIX}${e.message}" - ) - ) - } finally { - _state.update { it.copy(isLoading = false) } - // Finalize any streaming messages - finalizeStreamingMessages() - // Clear any remaining ephemeral messages - ephemeralMessageIds.keys.toList().forEach { type -> - clearEphemeralMessage(type) - } - } - } - } - - internal fun handleAgentEvent(event: BaseEvent) { - logger.d { "Handling event: ${event::class.simpleName}" } - - when (event) { - // Tool Call Events - is ToolCallStartEvent -> { - logger.d { "Tool call started: ${event.toolCallName} (${event.toolCallId})" } - toolCallBuffer[event.toolCallId] = StringBuilder() - pendingToolCalls[event.toolCallId] = event.toolCallName + fun cancelCurrentOperation() = controller.cancelCurrentOperation() - // Only show ephemeral message for non-confirmation tools - if (event.toolCallName != "user_confirmation") { - setEphemeralMessage( - "Calling ${event.toolCallName}...", - EphemeralType.TOOL_CALL, - "🔧" - ) - } - } + fun clearError() = controller.clearError() - is ToolCallArgsEvent -> { - toolCallBuffer[event.toolCallId]?.append(event.delta) - val currentArgs = toolCallBuffer[event.toolCallId]?.toString() ?: "" - logger.d { "Tool call args for ${event.toolCallId}: $currentArgs" } - - val toolName = pendingToolCalls[event.toolCallId] - if (toolName != "user_confirmation") { - setEphemeralMessage( - "Calling tool with: ${currentArgs.take(50)}${if (currentArgs.length > 50) "..." else ""}", - EphemeralType.TOOL_CALL, - "🔧" - ) - } - } - - is ToolCallEndEvent -> { - val toolName = pendingToolCalls[event.toolCallId] - - logger.d { "Tool call ended: $toolName" } - - // Clear ephemeral message for tools after a short delay - // (confirmation tools will be handled by the confirmation handler) - if (toolName != "user_confirmation") { - screenModelScope.launch { - delay(1000) - clearEphemeralMessage(EphemeralType.TOOL_CALL) - } - } - - toolCallBuffer.remove(event.toolCallId) - pendingToolCalls.remove(event.toolCallId) - } - - // Step Events - is StepStartedEvent -> { - setEphemeralMessage( - event.stepName, - EphemeralType.STEP, - "●" - ) - } - - is StepFinishedEvent -> { - // Clear step message after a short delay - screenModelScope.launch { - delay(500) // Quick flash for steps - clearEphemeralMessage(EphemeralType.STEP) - } - } - - // Text Message Events - is TextMessageStartEvent -> { - streamingMessages[event.messageId] = StringBuilder() - addDisplayMessage( - DisplayMessage( - id = event.messageId, - role = MessageRole.ASSISTANT, - content = "", - isStreaming = true - ) - ) - } - - is TextMessageContentEvent -> { - streamingMessages[event.messageId]?.append(event.delta) - updateStreamingMessage(event.messageId, event.delta) - } - - is TextMessageEndEvent -> { - finalizeStreamingMessage(event.messageId) - } - - is RunErrorEvent -> { - addDisplayMessage( - DisplayMessage( - id = generateMessageId(), - role = MessageRole.ERROR, - content = "${Strings.AGENT_ERROR_PREFIX}${event.message}" - ) - ) - } - - is RunFinishedEvent -> { - // Clear all ephemeral messages when run finishes - ephemeralMessageIds.keys.toList().forEach { type -> - clearEphemeralMessage(type) - } - } - - // Skip state events - we don't want to show them - is StateDeltaEvent, is StateSnapshotEvent -> { - // Do nothing - no ephemeral messages for state changes - } - - else -> { - logger.d { "Received event: $event" } - } - } - } - - fun confirmAction() { - val confirmation = _state.value.pendingConfirmation ?: return - - // Resume the confirmation handler with true (confirmed) - pendingConfirmationContinuation?.resume(true) - pendingConfirmationContinuation = null - - // Clear the confirmation dialog - _state.update { it.copy(pendingConfirmation = null) } - } - - fun rejectAction() { - val confirmation = _state.value.pendingConfirmation ?: return - - // Resume the confirmation handler with false (rejected) - pendingConfirmationContinuation?.resume(false) - pendingConfirmationContinuation = null - - // Clear the confirmation dialog - _state.update { it.copy(pendingConfirmation = null) } - } - - private fun updateStreamingMessage(messageId: String, delta: String) { - _state.update { state -> - state.copy( - messages = state.messages.map { msg -> - if (msg.id == messageId) { - msg.copy(content = msg.content + delta) - } else { - msg - } - } - ) - } - } - - private fun finalizeStreamingMessage(messageId: String) { - _state.update { state -> - state.copy( - messages = state.messages.map { msg -> - if (msg.id == messageId) { - msg.copy(isStreaming = false) - } else { - msg - } - } - ) - } - streamingMessages.remove(messageId) - } - - private fun finalizeStreamingMessages() { - streamingMessages.keys.forEach { messageId -> - finalizeStreamingMessage(messageId) - } - } - - private fun addDisplayMessage(message: DisplayMessage) { - _state.update { state -> - state.copy(messages = state.messages + message) - } - } - - private fun generateMessageId(): String { - return "msg_${Clock.System.now().toEpochMilliseconds()}" + fun dispose() { + controller.close() + scope.cancel() } +} - fun cancelCurrentOperation() { - currentJob?.cancel() - - // Cancel any pending confirmations - pendingConfirmationContinuation?.cancel() - pendingConfirmationContinuation = null - - _state.update { it.copy(isLoading = false, pendingConfirmation = null) } - finalizeStreamingMessages() - // Clear ephemeral messages on cancel - ephemeralMessageIds.keys.toList().forEach { type -> - clearEphemeralMessage(type) - } +@Composable +fun rememberChatViewModel(): ChatViewModel { + val viewModel = remember { ChatViewModel() } + DisposableEffect(Unit) { + onDispose { viewModel.dispose() } } -} \ No newline at end of file + return viewModel +} diff --git a/sdks/community/kotlin/examples/chatapp/shared/src/commonMain/kotlin/com/agui/example/chatapp/ui/screens/chat/components/MessageBubble.kt b/sdks/community/kotlin/examples/chatapp/shared/src/commonMain/kotlin/com/agui/example/chatapp/ui/screens/chat/components/MessageBubble.kt index de36b918d..9b69f796e 100644 --- a/sdks/community/kotlin/examples/chatapp/shared/src/commonMain/kotlin/com/agui/example/chatapp/ui/screens/chat/components/MessageBubble.kt +++ b/sdks/community/kotlin/examples/chatapp/shared/src/commonMain/kotlin/com/agui/example/chatapp/ui/screens/chat/components/MessageBubble.kt @@ -20,10 +20,9 @@ import androidx.compose.ui.semantics.text import androidx.compose.ui.text.AnnotatedString import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp -import com.agui.example.chatapp.ui.screens.chat.DisplayMessage -import com.agui.example.chatapp.ui.screens.chat.MessageRole -import com.halilibo.richtext.commonmark.Markdown -import com.halilibo.richtext.ui.material3.RichText +import com.agui.example.chatapp.chat.DisplayMessage +import com.agui.example.chatapp.chat.MessageRole +import com.mikepenz.markdown.m3.Markdown import kotlinx.datetime.Instant import kotlinx.datetime.TimeZone import kotlinx.datetime.toLocalDateTime @@ -35,7 +34,7 @@ fun MessageBubble( ) { val isUser = message.role == MessageRole.USER val isError = message.role == MessageRole.ERROR - val isSystem = message.role == MessageRole.SYSTEM + val isSystem = message.role == MessageRole.SYSTEM || message.role == MessageRole.DEVELOPER val isToolCall = message.role == MessageRole.TOOL_CALL val isStepInfo = message.role == MessageRole.STEP_INFO val isEphemeral = message.ephemeralGroupId != null @@ -166,13 +165,12 @@ fun MessageBubble( // Regular text for non-ephemeral messages ProvideTextStyle(MaterialTheme.typography.bodyLarge) { CompositionLocalProvider(LocalContentColor provides messageTextColor) { - RichText( + Markdown( + content = message.content, modifier = Modifier .fillMaxWidth() .semantics { text = AnnotatedString(message.content) } - ) { - Markdown(message.content) - } + ) } } } diff --git a/sdks/community/kotlin/examples/chatapp/shared/src/commonMain/kotlin/com/agui/example/chatapp/ui/screens/chat/components/MessageList.kt b/sdks/community/kotlin/examples/chatapp/shared/src/commonMain/kotlin/com/agui/example/chatapp/ui/screens/chat/components/MessageList.kt index 6d8ef6d33..0f008f144 100644 --- a/sdks/community/kotlin/examples/chatapp/shared/src/commonMain/kotlin/com/agui/example/chatapp/ui/screens/chat/components/MessageList.kt +++ b/sdks/community/kotlin/examples/chatapp/shared/src/commonMain/kotlin/com/agui/example/chatapp/ui/screens/chat/components/MessageList.kt @@ -9,7 +9,7 @@ import androidx.compose.runtime.* import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp -import com.agui.example.chatapp.ui.screens.chat.DisplayMessage +import com.agui.example.chatapp.chat.DisplayMessage import kotlinx.coroutines.launch @Composable @@ -58,4 +58,4 @@ fun MessageList( } } } -} \ No newline at end of file +} diff --git a/sdks/community/kotlin/examples/chatapp/shared/src/commonMain/kotlin/com/agui/example/chatapp/ui/screens/chat/components/UserConfirmationDialog.kt b/sdks/community/kotlin/examples/chatapp/shared/src/commonMain/kotlin/com/agui/example/chatapp/ui/screens/chat/components/UserConfirmationDialog.kt deleted file mode 100644 index ba8d2deda..000000000 --- a/sdks/community/kotlin/examples/chatapp/shared/src/commonMain/kotlin/com/agui/example/chatapp/ui/screens/chat/components/UserConfirmationDialog.kt +++ /dev/null @@ -1,132 +0,0 @@ -package com.agui.example.chatapp.ui.screens.chat.components - -import androidx.compose.foundation.layout.* -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.Warning -import androidx.compose.material3.* -import androidx.compose.runtime.Composable -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.unit.dp -import com.agui.example.chatapp.ui.screens.chat.UserConfirmationRequest - -@Composable -fun UserConfirmationDialog( - request: UserConfirmationRequest, - onConfirm: () -> Unit, - onReject: () -> Unit -) { - AlertDialog( - onDismissRequest = onReject, - icon = { - val iconColor = when (request.impact) { - "critical" -> MaterialTheme.colorScheme.error - "high" -> MaterialTheme.colorScheme.error.copy(alpha = 0.7f) - "medium" -> MaterialTheme.colorScheme.tertiary - else -> MaterialTheme.colorScheme.primary - } - Icon( - Icons.Default.Warning, - contentDescription = null, - tint = iconColor, - modifier = Modifier.size(48.dp) - ) - }, - title = { - Text( - text = "Confirmation Required", - style = MaterialTheme.typography.headlineSmall - ) - }, - text = { - Column( - verticalArrangement = Arrangement.spacedBy(16.dp) - ) { - Text( - text = request.action, - style = MaterialTheme.typography.bodyLarge - ) - - if (request.details.isNotEmpty()) { - Card( - colors = CardDefaults.cardColors( - containerColor = MaterialTheme.colorScheme.surfaceVariant - ) - ) { - Column( - modifier = Modifier.padding(12.dp), - verticalArrangement = Arrangement.spacedBy(8.dp) - ) { - request.details.forEach { (key, value) -> - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.SpaceBetween - ) { - Text( - text = "$key:", - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant - ) - Text( - text = value, - style = MaterialTheme.typography.bodyMedium - ) - } - } - } - } - } - - // Impact indicator - val impactColor = when (request.impact) { - "critical" -> MaterialTheme.colorScheme.error - "high" -> MaterialTheme.colorScheme.error.copy(alpha = 0.7f) - "medium" -> MaterialTheme.colorScheme.tertiary - else -> MaterialTheme.colorScheme.primary - } - - Row( - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(8.dp) - ) { - Text( - text = "Impact:", - style = MaterialTheme.typography.labelLarge - ) - AssistChip( - onClick = { }, - label = { - Text( - text = request.impact.uppercase(), - style = MaterialTheme.typography.labelSmall - ) - }, - colors = AssistChipDefaults.assistChipColors( - containerColor = impactColor.copy(alpha = 0.2f), - labelColor = impactColor - ) - ) - } - } - }, - confirmButton = { - Button( - onClick = onConfirm, - colors = if (request.impact == "critical" || request.impact == "high") { - ButtonDefaults.buttonColors( - containerColor = MaterialTheme.colorScheme.error - ) - } else { - ButtonDefaults.buttonColors() - } - ) { - Text("Confirm") - } - }, - dismissButton = { - TextButton(onClick = onReject) { - Text("Cancel") - } - } - ) -} \ No newline at end of file diff --git a/sdks/community/kotlin/examples/chatapp/shared/src/commonMain/kotlin/com/agui/example/chatapp/ui/screens/settings/SettingsScreen.kt b/sdks/community/kotlin/examples/chatapp/shared/src/commonMain/kotlin/com/agui/example/chatapp/ui/screens/settings/SettingsScreen.kt index 302d89f89..76b7d6eee 100644 --- a/sdks/community/kotlin/examples/chatapp/shared/src/commonMain/kotlin/com/agui/example/chatapp/ui/screens/settings/SettingsScreen.kt +++ b/sdks/community/kotlin/examples/chatapp/shared/src/commonMain/kotlin/com/agui/example/chatapp/ui/screens/settings/SettingsScreen.kt @@ -1,118 +1,140 @@ package com.agui.example.chatapp.ui.screens.settings -import androidx.compose.foundation.layout.* +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.height import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.ArrowBack -import androidx.compose.material.icons.filled.* -import androidx.compose.material3.* -import androidx.compose.runtime.* +import androidx.compose.material.icons.filled.Add +import androidx.compose.material.icons.filled.Info +import androidx.compose.material3.Button +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.FloatingActionButton +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBar +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.runtime.collectAsState import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp -import cafe.adriel.voyager.core.model.rememberScreenModel -import cafe.adriel.voyager.core.screen.Screen -import cafe.adriel.voyager.navigator.LocalNavigator -import cafe.adriel.voyager.navigator.currentOrThrow import com.agui.example.chatapp.ui.screens.settings.components.AddAgentDialog import com.agui.example.chatapp.ui.screens.settings.components.AgentCard import org.jetbrains.compose.resources.stringResource -import agui4kclient.shared.generated.resources.* +import agui4kclient.shared.generated.resources.Res +import agui4kclient.shared.generated.resources.add_agent +import agui4kclient.shared.generated.resources.add_agent_to_start +import agui4kclient.shared.generated.resources.agents +import agui4kclient.shared.generated.resources.back +import agui4kclient.shared.generated.resources.no_agents_configured +import agui4kclient.shared.generated.resources.settings -class SettingsScreen : Screen { - @OptIn(ExperimentalMaterial3Api::class) - @Composable - override fun Content() { - val navigator = LocalNavigator.currentOrThrow - val viewModel = rememberScreenModel { SettingsViewModel() } - val state by viewModel.state.collectAsState() +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun SettingsScreen( + onBack: () -> Unit +) { + val viewModel = rememberSettingsViewModel() + val state by viewModel.state.collectAsState() - var showAddDialog by remember { mutableStateOf(false) } + var showAddDialog by remember { mutableStateOf(false) } - Scaffold( - topBar = { - TopAppBar( - title = { Text(stringResource(Res.string.settings)) }, - navigationIcon = { - IconButton(onClick = { navigator.pop() }) { - Icon( - imageVector = Icons.AutoMirrored.Filled.ArrowBack, - contentDescription = stringResource(Res.string.back) - ) - } + Scaffold( + topBar = { + TopAppBar( + title = { Text(stringResource(Res.string.settings)) }, + navigationIcon = { + IconButton(onClick = onBack) { + Icon( + imageVector = Icons.AutoMirrored.Filled.ArrowBack, + contentDescription = stringResource(Res.string.back) + ) } - ) - }, - floatingActionButton = { - FloatingActionButton( - onClick = { showAddDialog = true } - ) { - Icon( - imageVector = Icons.Default.Add, - contentDescription = stringResource(Res.string.add_agent) - ) } + ) + }, + floatingActionButton = { + FloatingActionButton(onClick = { showAddDialog = true }) { + Icon( + imageVector = Icons.Default.Add, + contentDescription = stringResource(Res.string.add_agent) + ) } - ) { paddingValues -> - LazyColumn( - modifier = Modifier - .fillMaxSize() - .padding(paddingValues), - contentPadding = PaddingValues(16.dp), - verticalArrangement = Arrangement.spacedBy(8.dp) - ) { - // Agents Section Header + } + ) { paddingValues -> + LazyColumn( + modifier = Modifier + .fillMaxSize() + .padding(paddingValues), + contentPadding = PaddingValues(16.dp), + verticalArrangement = Arrangement.spacedBy(8.dp) + ) { + item { + Text( + text = stringResource(Res.string.agents), + style = MaterialTheme.typography.titleMedium, + modifier = Modifier.padding(vertical = 8.dp) + ) + } + + if (state.agents.isEmpty()) { item { - Text( - text = stringResource(Res.string.agents), - style = MaterialTheme.typography.titleMedium, - modifier = Modifier.padding(vertical = 8.dp) + EmptyAgentsCard( + onAddClick = { showAddDialog = true } ) } - - if (state.agents.isEmpty()) { - item { - EmptyAgentsCard( - onAddClick = { showAddDialog = true } - ) - } - } else { - items( - items = state.agents, - key = { it.id } - ) { agent -> - AgentCard( - agent = agent, - isActive = agent.id == state.activeAgent?.id, - onActivate = { viewModel.setActiveAgent(agent) }, - onEdit = { viewModel.editAgent(agent) }, - onDelete = { viewModel.deleteAgent(agent.id) } - ) - } + } else { + items( + items = state.agents, + key = { it.id } + ) { agent -> + AgentCard( + agent = agent, + isActive = agent.id == state.activeAgent?.id, + onActivate = { viewModel.setActiveAgent(agent) }, + onEdit = { viewModel.editAgent(agent) }, + onDelete = { viewModel.deleteAgent(agent.id) } + ) } } } + } - if (showAddDialog) { - AddAgentDialog( - onDismiss = { showAddDialog = false }, - onConfirm = { config -> - viewModel.addAgent(config) - showAddDialog = false - } - ) - } + if (showAddDialog) { + AddAgentDialog( + onDismiss = { showAddDialog = false }, + onConfirm = { config -> + viewModel.addAgent(config) + showAddDialog = false + } + ) + } - state.editingAgent?.let { agent -> - AddAgentDialog( - agent = agent, - onDismiss = { viewModel.cancelEdit() }, - onConfirm = { config -> - viewModel.updateAgent(config) - } - ) - } + state.editingAgent?.let { agent -> + AddAgentDialog( + agent = agent, + onDismiss = { viewModel.cancelEdit() }, + onConfirm = { config -> + viewModel.updateAgent(config) + } + ) } } @@ -161,9 +183,9 @@ private fun EmptyAgentsCard( contentDescription = null, modifier = Modifier.size(18.dp) ) - Spacer(modifier = Modifier.width(8.dp)) + Spacer(modifier = Modifier.size(8.dp)) Text(stringResource(Res.string.add_agent)) } } } -} \ No newline at end of file +} diff --git a/sdks/community/kotlin/examples/chatapp/shared/src/commonMain/kotlin/com/agui/example/chatapp/ui/screens/settings/SettingsViewModel.kt b/sdks/community/kotlin/examples/chatapp/shared/src/commonMain/kotlin/com/agui/example/chatapp/ui/screens/settings/SettingsViewModel.kt index 3a0cc61f0..841977b64 100644 --- a/sdks/community/kotlin/examples/chatapp/shared/src/commonMain/kotlin/com/agui/example/chatapp/ui/screens/settings/SettingsViewModel.kt +++ b/sdks/community/kotlin/examples/chatapp/shared/src/commonMain/kotlin/com/agui/example/chatapp/ui/screens/settings/SettingsViewModel.kt @@ -1,11 +1,19 @@ package com.agui.example.chatapp.ui.screens.settings -import cafe.adriel.voyager.core.model.ScreenModel -import cafe.adriel.voyager.core.model.screenModelScope +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.remember import com.agui.example.chatapp.data.model.AgentConfig import com.agui.example.chatapp.data.repository.AgentRepository import com.agui.example.chatapp.util.getPlatformSettings -import kotlinx.coroutines.flow.* +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.MainScope +import kotlinx.coroutines.cancel +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch data class SettingsState( @@ -14,15 +22,18 @@ data class SettingsState( val editingAgent: AgentConfig? = null ) -class SettingsViewModel : ScreenModel { +class SettingsViewModel( + scopeFactory: () -> CoroutineScope = { MainScope() } +) { private val settings = getPlatformSettings() private val agentRepository = AgentRepository.getInstance(settings) + private val scope = scopeFactory() private val _state = MutableStateFlow(SettingsState()) val state: StateFlow = _state.asStateFlow() init { - screenModelScope.launch { + scope.launch { // Combine agent flows combine( agentRepository.agents, @@ -39,26 +50,26 @@ class SettingsViewModel : ScreenModel { } fun addAgent(config: AgentConfig) { - screenModelScope.launch { + scope.launch { agentRepository.addAgent(config) } } fun updateAgent(config: AgentConfig) { - screenModelScope.launch { + scope.launch { agentRepository.updateAgent(config) _state.update { it.copy(editingAgent = null) } } } fun deleteAgent(agentId: String) { - screenModelScope.launch { + scope.launch { agentRepository.deleteAgent(agentId) } } fun setActiveAgent(agent: AgentConfig) { - screenModelScope.launch { + scope.launch { agentRepository.setActiveAgent(agent) } } @@ -70,4 +81,17 @@ class SettingsViewModel : ScreenModel { fun cancelEdit() { _state.update { it.copy(editingAgent = null) } } -} \ No newline at end of file + + fun dispose() { + scope.cancel() + } +} + +@Composable +fun rememberSettingsViewModel(): SettingsViewModel { + val viewModel = remember { SettingsViewModel() } + DisposableEffect(Unit) { + onDispose { viewModel.dispose() } + } + return viewModel +} diff --git a/sdks/community/kotlin/examples/chatapp/shared/src/commonTest/kotlin/com/agui/example/chatapp/SimpleWorkingTest.kt b/sdks/community/kotlin/examples/chatapp/shared/src/commonTest/kotlin/com/agui/example/chatapp/SimpleWorkingTest.kt deleted file mode 100644 index c31e597e5..000000000 --- a/sdks/community/kotlin/examples/chatapp/shared/src/commonTest/kotlin/com/agui/example/chatapp/SimpleWorkingTest.kt +++ /dev/null @@ -1,186 +0,0 @@ -package com.agui.example.chatapp - -import com.agui.example.chatapp.data.model.AgentConfig -import com.agui.example.chatapp.data.model.AuthMethod -import kotlinx.serialization.json.Json -import kotlin.test.Test -import kotlin.test.assertEquals -import kotlin.test.assertTrue -import kotlin.test.assertNotNull -import kotlin.test.assertNotEquals - -/** - * Improved tests that actually verify meaningful behavior. - */ -class SimpleWorkingTest { - - @Test - fun testBasicAssertion() { - assertEquals(4, 2 + 2) - assertTrue(true) - } - - @Test - fun testAgentConfigCreation() { - val agent = AgentConfig( - id = "test-123", - name = "Test Agent", - url = "https://example.com/agent", - description = "A test agent", - authMethod = AuthMethod.None() - ) - - assertEquals("test-123", agent.id) - assertEquals("Test Agent", agent.name) - assertEquals("https://example.com/agent", agent.url) - assertTrue(agent.authMethod is AuthMethod.None) - } - - @Test - fun testAuthMethodPolymorphism() { - // Test that sealed class polymorphism works correctly - val authMethods: List = listOf( - AuthMethod.None(), - AuthMethod.ApiKey("test-key", "X-API-Key"), - AuthMethod.BearerToken("test-token"), - AuthMethod.BasicAuth("user", "pass") - ) - - // Now these type checks are meaningful because we're working with a polymorphic list - val none = authMethods[0] - val apiKey = authMethods[1] - val bearer = authMethods[2] - val basic = authMethods[3] - - assertTrue(none is AuthMethod.None) - assertTrue(apiKey is AuthMethod.ApiKey) - assertTrue(bearer is AuthMethod.BearerToken) - assertTrue(basic is AuthMethod.BasicAuth) - - // Test that when statements work correctly (exhaustive) - authMethods.forEach { method -> - val result = when (method) { - is AuthMethod.None -> "none" - is AuthMethod.ApiKey -> "api_key" - is AuthMethod.BearerToken -> "bearer" - is AuthMethod.BasicAuth -> "basic" - is AuthMethod.OAuth2 -> "oauth2" - is AuthMethod.Custom -> "custom" - } - assertNotNull(result) - } - } - - @Test - fun testAuthMethodProperties() { - val apiKey = AuthMethod.ApiKey("secret-key", "X-Custom-API-Key") - val bearer = AuthMethod.BearerToken("bearer-token") - val basic = AuthMethod.BasicAuth("username", "password") - - // Test that properties are correctly accessible - assertEquals("secret-key", apiKey.key) - assertEquals("X-Custom-API-Key", apiKey.headerName) - assertEquals("bearer-token", bearer.token) - assertEquals("username", basic.username) - assertEquals("password", basic.password) - } - - @Test - fun testAuthMethodSerialization() { - val json = Json { ignoreUnknownKeys = true } - - val original = AuthMethod.ApiKey("test-key", "X-API-Key") - val serialized = json.encodeToString(original) - val deserialized = json.decodeFromString(serialized) - - // Test that serialization preserves type and data - assertEquals(original, deserialized) - assertTrue(deserialized is AuthMethod.ApiKey) - assertEquals("test-key", (deserialized as AuthMethod.ApiKey).key) - } - - @Test - fun testAgentConfigEquality() { - val now = kotlinx.datetime.Clock.System.now() - - val agent1 = AgentConfig( - id = "same-id", - name = "Agent", - url = "https://test.com", - authMethod = AuthMethod.None(), - createdAt = now - ) - - val agent2 = AgentConfig( - id = "same-id", - name = "Agent", - url = "https://test.com", - authMethod = AuthMethod.None(), - createdAt = now - ) - - val agent3 = AgentConfig( - id = "different-id", - name = "Agent", - url = "https://test.com", - authMethod = AuthMethod.None(), - createdAt = now - ) - - // Test data class equality - assertEquals(agent1, agent2) - assertNotEquals(agent1, agent3) - } - - @Test - fun testAuthMethodFactoryMethods() { - // Test that different auth methods can be created with default values - val none = AuthMethod.None() - val apiKeyWithDefaults = AuthMethod.ApiKey("key") - val bearerToken = AuthMethod.BearerToken("token") - val basicAuth = AuthMethod.BasicAuth("user", "pass") - - // Verify default values are applied correctly - assertEquals("X-API-Key", apiKeyWithDefaults.headerName) - assertEquals("none", none.id) - - // Test that each auth method has the expected properties - assertEquals("key", apiKeyWithDefaults.key) - assertEquals("token", bearerToken.token) - assertEquals("user", basicAuth.username) - assertEquals("pass", basicAuth.password) - - // Test that factory methods with different parameters create different values - val apiKey1 = AuthMethod.ApiKey("key1") - val apiKey2 = AuthMethod.ApiKey("key2") - assertEquals("key1", apiKey1.key) - assertEquals("key2", apiKey2.key) - - // Test that custom header names work - val customHeaderApiKey = AuthMethod.ApiKey("secret", "X-Custom-Header") - assertEquals("secret", customHeaderApiKey.key) - assertEquals("X-Custom-Header", customHeaderApiKey.headerName) - - // Test that None instances with same id are equal - val none1 = AuthMethod.None() - val none2 = AuthMethod.None() - assertEquals(none1, none2) - } - - @Test - fun testAgentConfigIdGeneration() { - val id1 = AgentConfig.generateId() - val id2 = AgentConfig.generateId() - - // Test that generated IDs are different - assertNotEquals(id1, id2) - - // Test that IDs follow expected pattern - assertTrue(id1.startsWith("agent_")) - assertTrue(id2.startsWith("agent_")) - - // Test that IDs are not empty - assertTrue(id1.length > "agent_".length) - assertTrue(id2.length > "agent_".length) - } -} \ No newline at end of file diff --git a/sdks/community/kotlin/examples/chatapp/shared/src/commonTest/kotlin/com/agui/example/chatapp/model/SerializationTest.kt b/sdks/community/kotlin/examples/chatapp/shared/src/commonTest/kotlin/com/agui/example/chatapp/model/SerializationTest.kt deleted file mode 100644 index 153e8470f..000000000 --- a/sdks/community/kotlin/examples/chatapp/shared/src/commonTest/kotlin/com/agui/example/chatapp/model/SerializationTest.kt +++ /dev/null @@ -1,142 +0,0 @@ -package com.agui.example.chatapp.model - -import com.agui.example.chatapp.data.model.AgentConfig -import com.agui.example.chatapp.data.model.AuthMethod -import kotlinx.serialization.json.Json -import kotlin.test.* - -class SerializationTest { - - private val json = Json { - ignoreUnknownKeys = true - isLenient = true - // Remove custom classDiscriminator to use default sealed class handling - } - - @Test - fun testAuthMethodSerialization() { - val authMethods = listOf( - AuthMethod.None(), - AuthMethod.ApiKey("key", "X-API-Key"), - AuthMethod.BearerToken("token"), - AuthMethod.BasicAuth("user", "pass") - // Removed Custom for now as it might have different structure - ) - - authMethods.forEach { original -> - try { - val jsonString = json.encodeToString(original) - println("Serialized $original to: $jsonString") - val decoded = json.decodeFromString(jsonString) - assertEquals(original, decoded) - } catch (e: Exception) { - println("Failed to serialize/deserialize: $original") - println("Error: ${e.message}") - println("Stack trace: ${e.stackTraceToString()}") - throw e - } - } - } - - @Test - fun testAgentConfigSerialization() { - val agent = AgentConfig( - id = "test-1", - name = "Test Agent", - url = "https://test.com/agent", - description = "A test agent", - authMethod = AuthMethod.ApiKey("secret", "X-API-Key"), - customHeaders = mapOf("X-Custom" to "value") - ) - - try { - val jsonString = json.encodeToString(agent) - val decoded = json.decodeFromString(jsonString) - - assertEquals(agent.id, decoded.id) - assertEquals(agent.name, decoded.name) - assertEquals(agent.url, decoded.url) - assertEquals(agent.authMethod, decoded.authMethod) - assertEquals(agent.customHeaders, decoded.customHeaders) - } catch (e: Exception) { - println("Failed to serialize AgentConfig: $agent") - println("Error: ${e.message}") - throw e - } - } - - @Test - fun testAgentConfigWithSystemPromptSerialization() { - val agent = AgentConfig( - id = "test-2", - name = "Test Agent with System Prompt", - url = "https://test.com/agent", - description = "A test agent with system prompt", - authMethod = AuthMethod.BearerToken("token123"), - systemPrompt = "You are a helpful AI assistant. Be concise and friendly." - ) - - try { - val jsonString = json.encodeToString(agent) - val decoded = json.decodeFromString(jsonString) - - assertEquals(agent.id, decoded.id) - assertEquals(agent.name, decoded.name) - assertEquals(agent.url, decoded.url) - assertEquals(agent.authMethod, decoded.authMethod) - assertEquals(agent.systemPrompt, decoded.systemPrompt) - } catch (e: Exception) { - println("Failed to serialize AgentConfig with system prompt: $agent") - println("Error: ${e.message}") - throw e - } - } - - @Test - fun testAgentConfigWithNullSystemPromptSerialization() { - val agent = AgentConfig( - id = "test-3", - name = "Test Agent without System Prompt", - url = "https://test.com/agent", - systemPrompt = null - ) - - try { - val jsonString = json.encodeToString(agent) - val decoded = json.decodeFromString(jsonString) - - assertEquals(agent.id, decoded.id) - assertEquals(agent.systemPrompt, decoded.systemPrompt) - assertNull(decoded.systemPrompt) - } catch (e: Exception) { - println("Failed to serialize AgentConfig with null system prompt: $agent") - println("Error: ${e.message}") - throw e - } - } - - @Test - fun testAgentConfigSystemPromptDefaultValue() { - val agent = AgentConfig( - id = "test-4", - name = "Test Agent", - url = "https://test.com/agent" - ) - - assertNull(agent.systemPrompt) - } - - @Test - fun testSimpleAuthMethodSerialization() { - // Test each auth method individually to isolate issues - val none = AuthMethod.None() - val noneJson = json.encodeToString(none) - val noneDecoded = json.decodeFromString(noneJson) - assertEquals(none, noneDecoded) - - val apiKey = AuthMethod.ApiKey("test-key", "X-API-Key") - val apiKeyJson = json.encodeToString(apiKey) - val apiKeyDecoded = json.decodeFromString(apiKeyJson) - assertEquals(apiKey, apiKeyDecoded) - } -} \ No newline at end of file diff --git a/sdks/community/kotlin/examples/chatapp/shared/src/commonTest/kotlin/com/agui/example/chatapp/repository/AgentRepositoryTest.kt b/sdks/community/kotlin/examples/chatapp/shared/src/commonTest/kotlin/com/agui/example/chatapp/repository/AgentRepositoryTest.kt deleted file mode 100644 index fd0a4c8b9..000000000 --- a/sdks/community/kotlin/examples/chatapp/shared/src/commonTest/kotlin/com/agui/example/chatapp/repository/AgentRepositoryTest.kt +++ /dev/null @@ -1,203 +0,0 @@ -package com.agui.example.chatapp.repository - -import com.agui.example.chatapp.data.model.AgentConfig -import com.agui.example.chatapp.data.model.AuthMethod -import com.agui.example.chatapp.data.repository.AgentRepository -import com.agui.example.chatapp.test.TestSettings -import kotlinx.coroutines.test.runTest -import kotlin.test.* - -class AgentRepositoryTest { - - private lateinit var settings: TestSettings - private lateinit var repository: AgentRepository - - @BeforeTest - fun setup() { - // Reset the singleton instance to ensure clean state - AgentRepository.resetInstance() - - settings = TestSettings() - repository = AgentRepository.getInstance(settings) - } - - @AfterTest - fun tearDown() { - // Clean up after each test - AgentRepository.resetInstance() - } - - @Test - fun testAddAgent() = runTest { - val agent = AgentConfig( - id = "test-1", - name = "Test Agent", - url = "https://test.com/agent", - authMethod = AuthMethod.None() - ) - - repository.addAgent(agent) - - val agents = repository.agents.value - assertEquals(1, agents.size) - assertEquals(agent, agents.first()) - } - - @Test - fun testUpdateAgent() = runTest { - val agent = AgentConfig( - id = "test-1", - name = "Test Agent", - url = "https://test.com/agent" - ) - - repository.addAgent(agent) - - val updatedAgent = agent.copy(name = "Updated Agent") - repository.updateAgent(updatedAgent) - - val agents = repository.agents.value - assertEquals(1, agents.size) - assertEquals("Updated Agent", agents.first().name) - } - - @Test - fun testDeleteAgent() = runTest { - val agent1 = AgentConfig(id = "1", name = "Agent 1", url = "https://test1.com") - val agent2 = AgentConfig(id = "2", name = "Agent 2", url = "https://test2.com") - - repository.addAgent(agent1) - repository.addAgent(agent2) - - assertEquals(2, repository.agents.value.size) - - repository.deleteAgent("1") - - val agents = repository.agents.value - assertEquals(1, agents.size) - assertEquals("2", agents.first().id) - } - - @Test - fun testSetActiveAgent() = runTest { - val agent = AgentConfig( - id = "test-1", - name = "Test Agent", - url = "https://test.com/agent" - ) - - repository.addAgent(agent) - repository.setActiveAgent(agent) - - assertEquals(agent.id, repository.activeAgent.value?.id) - assertNotNull(repository.currentSession.value) - } - - @Test - fun testAddAgentWithSystemPrompt() = runTest { - val agent = AgentConfig( - id = "test-with-prompt", - name = "Test Agent with System Prompt", - url = "https://test.com/agent", - systemPrompt = "You are a helpful AI assistant specializing in testing." - ) - - repository.addAgent(agent) - - val agents = repository.agents.value - assertEquals(1, agents.size) - val savedAgent = agents.first() - assertEquals(agent.systemPrompt, savedAgent.systemPrompt) - assertEquals("You are a helpful AI assistant specializing in testing.", savedAgent.systemPrompt) - } - - @Test - fun testUpdateAgentSystemPrompt() = runTest { - val agent = AgentConfig( - id = "test-prompt-update", - name = "Test Agent", - url = "https://test.com/agent", - systemPrompt = "Initial system prompt" - ) - - repository.addAgent(agent) - - val updatedAgent = agent.copy( - systemPrompt = "Updated system prompt with new instructions" - ) - repository.updateAgent(updatedAgent) - - val agents = repository.agents.value - assertEquals(1, agents.size) - assertEquals("Updated system prompt with new instructions", agents.first().systemPrompt) - } - - @Test - fun testAgentWithNullSystemPrompt() = runTest { - val agent = AgentConfig( - id = "test-null-prompt", - name = "Test Agent", - url = "https://test.com/agent", - systemPrompt = null - ) - - repository.addAgent(agent) - - val agents = repository.agents.value - assertEquals(1, agents.size) - assertNull(agents.first().systemPrompt) - } - - @Test - fun testRemoveSystemPromptFromAgent() = runTest { - val agent = AgentConfig( - id = "test-remove-prompt", - name = "Test Agent", - url = "https://test.com/agent", - systemPrompt = "This prompt will be removed" - ) - - repository.addAgent(agent) - assertEquals("This prompt will be removed", repository.agents.value.first().systemPrompt) - - val updatedAgent = agent.copy(systemPrompt = null) - repository.updateAgent(updatedAgent) - - val agents = repository.agents.value - assertEquals(1, agents.size) - assertNull(agents.first().systemPrompt) - } - - @Test - fun testSystemPromptPersistenceWithComplexAgent() = runTest { - val complexPrompt = """ - You are an AI assistant with the following characteristics: - 1. Be helpful and informative - 2. Use a professional tone - 3. Provide concise but complete answers - 4. Always verify facts before stating them - - When responding to technical questions, structure your answers with: - - Brief explanation - - Code examples when relevant - - Additional resources if helpful - """.trimIndent() - - val agent = AgentConfig( - id = "complex-agent", - name = "Complex Test Agent", - url = "https://test.com/complex-agent", - description = "Agent with complex system prompt", - authMethod = AuthMethod.ApiKey("secret-key", "X-API-Key"), - systemPrompt = complexPrompt, - customHeaders = mapOf("X-Custom" to "test-value") - ) - - repository.addAgent(agent) - - val savedAgent = repository.agents.value.first() - assertEquals(complexPrompt, savedAgent.systemPrompt) - assertEquals(agent.authMethod, savedAgent.authMethod) - assertEquals(agent.customHeaders, savedAgent.customHeaders) - } -} diff --git a/sdks/community/kotlin/examples/chatapp/shared/src/commonTest/kotlin/com/agui/example/chatapp/test/TestChatViewModel.kt b/sdks/community/kotlin/examples/chatapp/shared/src/commonTest/kotlin/com/agui/example/chatapp/test/TestChatViewModel.kt deleted file mode 100644 index c35910dcf..000000000 --- a/sdks/community/kotlin/examples/chatapp/shared/src/commonTest/kotlin/com/agui/example/chatapp/test/TestChatViewModel.kt +++ /dev/null @@ -1,82 +0,0 @@ -package com.agui.example.chatapp.test - -import cafe.adriel.voyager.core.model.ScreenModel -import com.agui.example.chatapp.ui.screens.chat.ChatState -import com.agui.example.chatapp.ui.screens.chat.DisplayMessage -import com.agui.example.chatapp.ui.screens.chat.MessageRole -import com.agui.example.chatapp.data.model.AgentConfig -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.asStateFlow -import kotlinx.datetime.Clock - -/** - * A testable version of ChatViewModel that doesn't depend on platform settings. - */ -class TestChatViewModel : ScreenModel { - private val _state = MutableStateFlow(ChatState()) - val state: StateFlow = _state.asStateFlow() - - private val messages = mutableListOf() - - fun setActiveAgent(agent: AgentConfig?) { - _state.value = _state.value.copy( - activeAgent = agent, - isConnected = agent != null - ) - } - - fun sendMessage(content: String) { - if (content.isBlank()) return - - // Add user message - val userMessage = DisplayMessage( - id = generateMessageId(), - role = MessageRole.USER, - content = content.trim() - ) - - messages.add(userMessage) - updateMessages() - - // Simulate agent response - simulateAgentResponse() - } - - private fun simulateAgentResponse() { - _state.value = _state.value.copy(isLoading = true) - - // Add assistant message - val assistantMessage = DisplayMessage( - id = generateMessageId(), - role = MessageRole.ASSISTANT, - content = "This is a test response from the agent" - ) - - messages.add(assistantMessage) - - _state.value = _state.value.copy(isLoading = false) - updateMessages() - } - - private fun updateMessages() { - _state.value = _state.value.copy(messages = messages.toList()) - } - - private fun generateMessageId(): String { - return "msg_${Clock.System.now().toEpochMilliseconds()}" - } - - fun clearMessages() { - messages.clear() - updateMessages() - } - - fun setLoading(loading: Boolean) { - _state.value = _state.value.copy(isLoading = loading) - } - - fun setError(error: String?) { - _state.value = _state.value.copy(error = error) - } -} \ No newline at end of file diff --git a/sdks/community/kotlin/examples/chatapp/shared/src/commonTest/kotlin/com/agui/example/chatapp/ui/AddAgentDialogTest.kt b/sdks/community/kotlin/examples/chatapp/shared/src/commonTest/kotlin/com/agui/example/chatapp/ui/AddAgentDialogTest.kt deleted file mode 100644 index 9e4c3b703..000000000 --- a/sdks/community/kotlin/examples/chatapp/shared/src/commonTest/kotlin/com/agui/example/chatapp/ui/AddAgentDialogTest.kt +++ /dev/null @@ -1,181 +0,0 @@ -package com.agui.example.chatapp.ui - -import com.agui.example.chatapp.data.model.AgentConfig -import com.agui.example.chatapp.data.model.AuthMethod -import kotlin.test.* - -/** - * Tests for system prompt functionality in AddAgentDialog. - * These are unit tests for the dialog logic, not UI rendering tests. - */ -class AddAgentDialogTest { - - @Test - fun testAgentCreationWithSystemPrompt() { - // Simulate creating a new agent with system prompt - val systemPrompt = "You are a helpful AI assistant specialized in software development." - - val agent = AgentConfig( - id = AgentConfig.generateId(), - name = "Dev Assistant", - url = "https://api.example.com/agent", - description = "A development assistant", - authMethod = AuthMethod.ApiKey("secret", "X-API-Key"), - systemPrompt = systemPrompt - ) - - assertEquals("Dev Assistant", agent.name) - assertEquals("https://api.example.com/agent", agent.url) - assertEquals("A development assistant", agent.description) - assertEquals(systemPrompt, agent.systemPrompt) - assertTrue(agent.authMethod is AuthMethod.ApiKey) - } - - @Test - fun testAgentCreationWithoutSystemPrompt() { - // Simulate creating a new agent without system prompt - val agent = AgentConfig( - id = AgentConfig.generateId(), - name = "Basic Agent", - url = "https://api.example.com/basic", - systemPrompt = null - ) - - assertEquals("Basic Agent", agent.name) - assertEquals("https://api.example.com/basic", agent.url) - assertNull(agent.systemPrompt) - } - - @Test - fun testAgentEditingPreservesSystemPrompt() { - // Simulate editing an existing agent - val originalAgent = AgentConfig( - id = "existing-agent", - name = "Original Agent", - url = "https://api.example.com/original", - systemPrompt = "Original system prompt" - ) - - // Simulate editing the agent (keeping system prompt) - val editedAgent = originalAgent.copy( - name = "Updated Agent", - url = "https://api.example.com/updated", - systemPrompt = "Updated system prompt with new instructions" - ) - - assertEquals("existing-agent", editedAgent.id) // ID should remain the same - assertEquals("Updated Agent", editedAgent.name) - assertEquals("https://api.example.com/updated", editedAgent.url) - assertEquals("Updated system prompt with new instructions", editedAgent.systemPrompt) - } - - @Test - fun testAgentSystemPromptRemoval() { - // Simulate removing system prompt from existing agent - val agentWithPrompt = AgentConfig( - id = "agent-with-prompt", - name = "Agent with Prompt", - url = "https://api.example.com/agent", - systemPrompt = "This prompt will be removed" - ) - - val agentWithoutPrompt = agentWithPrompt.copy(systemPrompt = null) - - assertEquals("agent-with-prompt", agentWithoutPrompt.id) - assertEquals("Agent with Prompt", agentWithoutPrompt.name) - assertNull(agentWithoutPrompt.systemPrompt) - } - - @Test - fun testSystemPromptTrimming() { - // Test that empty/whitespace-only system prompts are treated as null - val systemPromptWithWhitespace = " \n\t " - val trimmedPrompt = systemPromptWithWhitespace.trim().takeIf { it.isNotEmpty() } - - assertNull(trimmedPrompt, "Whitespace-only system prompt should be treated as null") - - // Test valid prompt with surrounding whitespace - val validPromptWithWhitespace = " You are a helpful assistant. \n\t" - val trimmedValidPrompt = validPromptWithWhitespace.trim().takeIf { it.isNotEmpty() } - - assertEquals("You are a helpful assistant.", trimmedValidPrompt) - } - - @Test - fun testSystemPromptWithMultipleLines() { - val multilinePrompt = """ - You are an AI assistant with the following guidelines: - - 1. Be helpful and informative - 2. Provide accurate information - 3. Ask for clarification when needed - - Always maintain a professional tone. - """.trimIndent() - - val agent = AgentConfig( - id = "multiline-agent", - name = "Multiline Prompt Agent", - url = "https://api.example.com/multiline", - systemPrompt = multilinePrompt - ) - - assertEquals(multilinePrompt, agent.systemPrompt) - assertTrue(agent.systemPrompt!!.contains("AI assistant")) - assertTrue(agent.systemPrompt!!.contains("professional tone")) - assertTrue(agent.systemPrompt!!.contains("1. Be helpful")) - } - - @Test - fun testSystemPromptValidationLogic() { - // Test the validation logic that would be used in the dialog - - // Valid cases - assertTrue(isValidSystemPrompt("You are a helpful assistant")) - assertTrue(isValidSystemPrompt("Multi\nline\nprompt")) - assertTrue(isValidSystemPrompt("Prompt with special chars: @#$%^&*()")) - - // Invalid cases (null or empty after trimming) - assertFalse(isValidSystemPrompt(null)) - assertFalse(isValidSystemPrompt("")) - assertFalse(isValidSystemPrompt(" ")) - assertFalse(isValidSystemPrompt("\n\t \n")) - } - - @Test - fun testSystemPromptMaxLength() { - // Test handling of very long system prompts - val longPrompt = "A".repeat(5000) // Very long prompt - - val agent = AgentConfig( - id = "long-prompt-agent", - name = "Long Prompt Agent", - url = "https://api.example.com/long", - systemPrompt = longPrompt - ) - - assertEquals(longPrompt, agent.systemPrompt) - assertEquals(5000, agent.systemPrompt!!.length) - } - - @Test - fun testSystemPromptWithUnicodeCharacters() { - val unicodePrompt = "You are an AI assistant 🤖. Respond with emojis when appropriate 😊. Support múltiple languages: español, français, 中文." - - val agent = AgentConfig( - id = "unicode-agent", - name = "Unicode Agent", - url = "https://api.example.com/unicode", - systemPrompt = unicodePrompt - ) - - assertEquals(unicodePrompt, agent.systemPrompt) - assertTrue(agent.systemPrompt!!.contains("🤖")) - assertTrue(agent.systemPrompt!!.contains("español")) - assertTrue(agent.systemPrompt!!.contains("中文")) - } - - private fun isValidSystemPrompt(prompt: String?): Boolean { - return prompt?.trim()?.isNotEmpty() == true - } -} \ No newline at end of file diff --git a/sdks/community/kotlin/examples/chatapp/shared/src/commonTest/kotlin/com/agui/example/chatapp/viewmodel/ChatViewModelBehaviorTest.kt b/sdks/community/kotlin/examples/chatapp/shared/src/commonTest/kotlin/com/agui/example/chatapp/viewmodel/ChatViewModelBehaviorTest.kt new file mode 100644 index 000000000..a46986198 --- /dev/null +++ b/sdks/community/kotlin/examples/chatapp/shared/src/commonTest/kotlin/com/agui/example/chatapp/viewmodel/ChatViewModelBehaviorTest.kt @@ -0,0 +1,217 @@ +package com.agui.example.chatapp.viewmodel + +import com.agui.client.agent.AgentSubscriber +import com.agui.client.agent.AgentSubscription +import com.agui.core.types.BaseEvent +import com.agui.example.chatapp.chat.ChatAgent +import com.agui.example.chatapp.chat.ChatAgentFactory +import com.agui.example.chatapp.chat.ChatController +import com.agui.example.chatapp.chat.MessageRole +import com.agui.example.chatapp.data.model.AgentConfig +import com.agui.example.chatapp.data.model.AuthMethod +import com.agui.example.chatapp.data.repository.AgentRepository +import com.agui.example.chatapp.test.TestSettings +import com.agui.example.chatapp.ui.screens.chat.ChatViewModel +import com.agui.example.chatapp.util.UserIdManager +import com.agui.tools.DefaultToolRegistry +import kotlin.test.AfterTest +import kotlin.test.BeforeTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertTrue +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.awaitCancellation +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.emptyFlow +import kotlinx.coroutines.flow.flow +import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.runTest +import kotlinx.datetime.Instant + +@OptIn(ExperimentalCoroutinesApi::class) +class ChatViewModelBehaviorTest { + private lateinit var settings: TestSettings + + @BeforeTest + fun setUp() { + settings = TestSettings() + AgentRepository.resetInstance() + UserIdManager.resetInstance() + } + + @AfterTest + fun tearDown() { + AgentRepository.resetInstance() + UserIdManager.resetInstance() + } + + @Test + fun stateReflectsActiveAgentChanges() = runTest { + val repository = AgentRepository.getInstance(settings) + val userIdManager = UserIdManager.getInstance(settings) + val agentFactory = StubChatAgentFactory() + val dispatcher = StandardTestDispatcher(testScheduler) + val scope = TestScope(dispatcher) + + val viewModel = ChatViewModel( + scopeFactory = { scope }, + controllerFactory = { externalScope -> + ChatController( + externalScope = externalScope, + agentFactory = agentFactory, + settings = settings, + agentRepository = repository, + userIdManager = userIdManager + ) + } + ) + + val agent = AgentConfig( + id = "agent-1", + name = "Primary Agent", + url = "https://example.agents.dev", + authMethod = AuthMethod.None(), + createdAt = Instant.fromEpochMilliseconds(0) + ) + + repository.addAgent(agent) + repository.setActiveAgent(agent) + advanceUntilIdle() + + val state = viewModel.state.value + assertEquals(agent.id, state.activeAgent?.id) + assertTrue(state.isConnected) + assertTrue(state.messages.any { it.role == MessageRole.SYSTEM }) + + viewModel.dispose() + } + + @Test + fun sendMessageDelegatesToControllerAndTracksStreaming() = runTest { + val repository = AgentRepository.getInstance(settings) + val userIdManager = UserIdManager.getInstance(settings) + val agentFactory = StubChatAgentFactory() + val dispatcher = StandardTestDispatcher(testScheduler) + val scope = TestScope(dispatcher) + + val viewModel = ChatViewModel( + scopeFactory = { scope }, + controllerFactory = { externalScope -> + ChatController( + externalScope = externalScope, + agentFactory = agentFactory, + settings = settings, + agentRepository = repository, + userIdManager = userIdManager + ) + } + ) + + val agent = AgentConfig( + id = "agent-1", + name = "Test Agent", + url = "https://example.agents.dev", + authMethod = AuthMethod.None(), + createdAt = Instant.fromEpochMilliseconds(0) + ) + repository.addAgent(agent) + repository.setActiveAgent(agent) + advanceUntilIdle() + + val stubAgent = agentFactory.createdAgents.single() + + viewModel.sendMessage("Hi there") + advanceUntilIdle() + + val recorded = stubAgent.sentMessages.single() + assertEquals("Hi there", recorded.first) + assertTrue(recorded.second.isNotBlank()) + + assertFalse(viewModel.state.value.isLoading) + + viewModel.dispose() + } + + @Test + fun cancelAndDisposeStopActiveWork() = runTest { + val repository = AgentRepository.getInstance(settings) + val userIdManager = UserIdManager.getInstance(settings) + val agentFactory = StubChatAgentFactory() + val dispatcher = StandardTestDispatcher(testScheduler) + val scope = TestScope(dispatcher) + + val viewModel = ChatViewModel( + scopeFactory = { scope }, + controllerFactory = { externalScope -> + ChatController( + externalScope = externalScope, + agentFactory = agentFactory, + settings = settings, + agentRepository = repository, + userIdManager = userIdManager + ) + } + ) + + val agent = AgentConfig( + id = "agent-1", + name = "Test Agent", + url = "https://example.agents.dev", + authMethod = AuthMethod.None(), + createdAt = Instant.fromEpochMilliseconds(0) + ) + repository.addAgent(agent) + repository.setActiveAgent(agent) + advanceUntilIdle() + + val stubAgent = agentFactory.createdAgents.single() + stubAgent.nextSendFlow = flow { awaitCancellation() } + + viewModel.sendMessage("Processing") + advanceUntilIdle() + + assertTrue(viewModel.state.value.isLoading) + + viewModel.cancelCurrentOperation() + advanceUntilIdle() + + assertFalse(viewModel.state.value.isLoading) + + viewModel.dispose() + advanceUntilIdle() + val finalState = viewModel.state.value + assertFalse(finalState.isConnected) + assertTrue(finalState.messages.isEmpty()) + } + + private class StubChatAgentFactory : ChatAgentFactory { + val createdAgents = mutableListOf() + + override fun createAgent( + config: AgentConfig, + headers: Map, + toolRegistry: DefaultToolRegistry, + userId: String, + systemPrompt: String? + ): ChatAgent = StubChatAgent().also { createdAgents += it } + } + + private class StubChatAgent : ChatAgent { + val sentMessages = mutableListOf>() + var nextSendFlow: Flow? = null + + override fun sendMessage(message: String, threadId: String): Flow? { + sentMessages += message to threadId + return nextSendFlow ?: emptyFlow() + } + + override fun subscribe(subscriber: AgentSubscriber): AgentSubscription { + return object : AgentSubscription { + override fun unsubscribe() = Unit + } + } + } +} diff --git a/sdks/community/kotlin/examples/chatapp/shared/src/commonTest/kotlin/com/agui/example/chatapp/viewmodel/ChatViewModelTest.kt b/sdks/community/kotlin/examples/chatapp/shared/src/commonTest/kotlin/com/agui/example/chatapp/viewmodel/ChatViewModelTest.kt deleted file mode 100644 index 8eb568672..000000000 --- a/sdks/community/kotlin/examples/chatapp/shared/src/commonTest/kotlin/com/agui/example/chatapp/viewmodel/ChatViewModelTest.kt +++ /dev/null @@ -1,256 +0,0 @@ -package com.agui.example.chatapp.viewmodel - -import com.agui.example.chatapp.ui.screens.chat.MessageRole -import com.agui.example.chatapp.data.model.AgentConfig -import com.agui.example.chatapp.data.model.AuthMethod -import com.agui.example.chatapp.data.repository.AgentRepository -import com.agui.example.chatapp.test.TestSettings -import com.agui.example.chatapp.test.TestChatViewModel -import com.agui.example.chatapp.ui.screens.chat.DisplayMessage -import kotlinx.coroutines.delay -import kotlinx.coroutines.test.runTest -import kotlin.test.* - -class ChatViewModelTest { - - private lateinit var testSettings: TestSettings - private lateinit var agentRepository: AgentRepository - private lateinit var viewModel: TestChatViewModel - - @BeforeTest - fun setup() { - // Reset singleton instances - AgentRepository.resetInstance() - - testSettings = TestSettings() - agentRepository = AgentRepository.getInstance(testSettings) - viewModel = TestChatViewModel() - } - - @AfterTest - fun tearDown() { - // Clean up - AgentRepository.resetInstance() - } - - @Test - fun testInitialState() = runTest { - // Create a test view model with a mock agent - val testAgent = AgentConfig( - id = "test-1", - name = "Test Agent", - url = "https://test.com/agent", - authMethod = AuthMethod.None() - ) - - // Add agent to repository - agentRepository.addAgent(testAgent) - agentRepository.setActiveAgent(testAgent) - - // Wait for state updates - delay(100) - - // Verify active agent is set - val activeAgent = agentRepository.activeAgent.value - assertNotNull(activeAgent) - assertEquals("Test Agent", activeAgent.name) - } - - @Test - fun testAgentRepository() = runTest { - val agent = AgentConfig( - id = "test-1", - name = "Test Agent", - url = "https://test.com/agent", - authMethod = AuthMethod.None() - ) - - // Test adding agent - agentRepository.addAgent(agent) - val agents = agentRepository.agents.value - assertEquals(1, agents.size) - assertEquals(agent, agents.first()) - - // Test setting active agent - agentRepository.setActiveAgent(agent) - val activeAgent = agentRepository.activeAgent.value - assertEquals(agent.id, activeAgent?.id) - - // Test session creation - val session = agentRepository.currentSession.value - assertNotNull(session) - assertEquals(agent.id, session.agentId) - } - - @Test - fun testMessageFormatting() { - val userMessage = DisplayMessage( - id = "1", - role = MessageRole.USER, - content = "Hello, agent!" - ) - - assertEquals("1", userMessage.id) - assertEquals(MessageRole.USER, userMessage.role) - assertEquals("Hello, agent!", userMessage.content) - assertFalse(userMessage.isStreaming) - } - - @Test - fun testStreamingMessage() { - val streamingMessage = DisplayMessage( - id = "2", - role = MessageRole.ASSISTANT, - content = "Thinking...", - isStreaming = true - ) - - assertEquals("2", streamingMessage.id) - assertEquals(MessageRole.ASSISTANT, streamingMessage.role) - assertEquals("Thinking...", streamingMessage.content) - assertTrue(streamingMessage.isStreaming) - } - - @Test - fun testAgentWithSystemPrompt() = runTest { - val testAgent = AgentConfig( - id = "test-system-prompt", - name = "Test Agent with System Prompt", - url = "https://test.com/agent", - authMethod = AuthMethod.None(), - systemPrompt = "You are a helpful AI assistant specializing in unit testing." - ) - - // Add agent to repository - agentRepository.addAgent(testAgent) - agentRepository.setActiveAgent(testAgent) - - // Wait for state updates - delay(100) - - // Verify active agent has system prompt - val activeAgent = agentRepository.activeAgent.value - assertNotNull(activeAgent) - assertEquals("You are a helpful AI assistant specializing in unit testing.", activeAgent.systemPrompt) - } - - @Test - fun testAgentWithoutSystemPrompt() = runTest { - val testAgent = AgentConfig( - id = "test-no-prompt", - name = "Test Agent without System Prompt", - url = "https://test.com/agent", - authMethod = AuthMethod.None(), - systemPrompt = null - ) - - // Add agent to repository - agentRepository.addAgent(testAgent) - agentRepository.setActiveAgent(testAgent) - - // Wait for state updates - delay(100) - - // Verify active agent has no system prompt - val activeAgent = agentRepository.activeAgent.value - assertNotNull(activeAgent) - assertNull(activeAgent.systemPrompt) - } - - @Test - fun testSystemPromptUpdatePropagation() = runTest { - val initialAgent = AgentConfig( - id = "test-update-prompt", - name = "Test Agent", - url = "https://test.com/agent", - systemPrompt = "Initial system prompt" - ) - - // Add initial agent - agentRepository.addAgent(initialAgent) - agentRepository.setActiveAgent(initialAgent) - delay(100) - - // Verify initial system prompt - assertEquals("Initial system prompt", agentRepository.activeAgent.value?.systemPrompt) - - // Update agent with new system prompt - val updatedAgent = initialAgent.copy( - systemPrompt = "Updated system prompt with new behavior" - ) - agentRepository.updateAgent(updatedAgent) - - // Verify the system prompt is updated - val agents = agentRepository.agents.value - val savedAgent = agents.find { it.id == "test-update-prompt" } - assertNotNull(savedAgent) - assertEquals("Updated system prompt with new behavior", savedAgent.systemPrompt) - } - - @Test - fun testComplexSystemPromptHandling() = runTest { - val complexPrompt = """ - System Instructions: - - You are a specialized AI assistant for software testing. Follow these guidelines: - - 1. Always validate inputs before processing - 2. Provide clear, actionable feedback - 3. Include relevant code examples when helpful - 4. Maintain a professional but friendly tone - - Special behaviors: - - For test failures: Suggest specific debugging steps - - For performance issues: Recommend profiling tools - - For integration tests: Focus on boundary conditions - - Remember to always consider edge cases and error scenarios. - """.trimIndent() - - val testAgent = AgentConfig( - id = "complex-prompt-agent", - name = "Complex System Prompt Agent", - url = "https://test.com/agent", - authMethod = AuthMethod.BearerToken("test-token"), - systemPrompt = complexPrompt - ) - - agentRepository.addAgent(testAgent) - agentRepository.setActiveAgent(testAgent) - delay(100) - - val activeAgent = agentRepository.activeAgent.value - assertNotNull(activeAgent) - assertEquals(complexPrompt, activeAgent.systemPrompt) - assertTrue(activeAgent.systemPrompt!!.contains("System Instructions:")) - assertTrue(activeAgent.systemPrompt!!.contains("edge cases")) - } - - @Test - fun testSystemPromptWithSpecialCharacters() = runTest { - val promptWithSpecialChars = """ - System prompt with "quotes", 'single quotes', and symbols: @#$%^&*() - Multiple lines with tabs and spaces - Unicode: 🤖 emoji and special chars: ñáéíóú - JSON-like structure: {"role": "assistant", "behavior": "helpful"} - """.trimIndent() - - val testAgent = AgentConfig( - id = "special-chars-agent", - name = "Agent with Special Characters", - url = "https://test.com/agent", - systemPrompt = promptWithSpecialChars - ) - - agentRepository.addAgent(testAgent) - agentRepository.setActiveAgent(testAgent) - delay(100) - - val activeAgent = agentRepository.activeAgent.value - assertNotNull(activeAgent) - assertEquals(promptWithSpecialChars, activeAgent.systemPrompt) - assertTrue(activeAgent.systemPrompt!!.contains("🤖")) - assertTrue(activeAgent.systemPrompt!!.contains("ñáéíóú")) - assertTrue(activeAgent.systemPrompt!!.contains("""{"role": "assistant"""")) - } -} \ No newline at end of file diff --git a/sdks/community/kotlin/examples/chatapp/shared/src/desktopTest/kotlin/com/agui/example/chatapp/viewmodel/ChatViewModelErrorHandlingTest.kt b/sdks/community/kotlin/examples/chatapp/shared/src/desktopTest/kotlin/com/agui/example/chatapp/viewmodel/ChatViewModelErrorHandlingTest.kt deleted file mode 100644 index 0b8afedd0..000000000 --- a/sdks/community/kotlin/examples/chatapp/shared/src/desktopTest/kotlin/com/agui/example/chatapp/viewmodel/ChatViewModelErrorHandlingTest.kt +++ /dev/null @@ -1,241 +0,0 @@ -package com.agui.example.chatapp.viewmodel - -import com.agui.example.chatapp.data.model.AgentConfig -import com.agui.example.chatapp.data.model.AuthMethod -import com.agui.example.chatapp.data.repository.AgentRepository -import com.agui.example.chatapp.test.TestSettings -import com.agui.core.types.* -import com.agui.example.chatapp.ui.screens.chat.ChatViewModel -import com.agui.example.chatapp.ui.screens.chat.MessageRole -import kotlinx.coroutines.test.runTest -import kotlin.test.* - -/** - * Tests for ChatViewModel error handling capabilities. - * Tests various error scenarios and recovery mechanisms. - */ -class ChatViewModelErrorHandlingTest { - - private lateinit var testSettings: TestSettings - private lateinit var agentRepository: AgentRepository - private lateinit var viewModel: ChatViewModel - - @BeforeTest - fun setup() { - AgentRepository.resetInstance() - testSettings = TestSettings() - agentRepository = AgentRepository.getInstance(testSettings) - viewModel = ChatViewModel() - - // Set up test agent - val testAgent = AgentConfig( - id = "test-agent", - name = "Test Agent", - url = "https://test.com/agent", - authMethod = AuthMethod.None() - ) - } - - @AfterTest - fun tearDown() { - AgentRepository.resetInstance() - } - - - @Test - fun testRunErrorWithoutCode() = runTest { - // Test error event without error code - val errorEvent = RunErrorEvent( - message = "Something went wrong", - code = null - ) - - viewModel.handleAgentEvent(errorEvent) - - val state = viewModel.state.value - val errorMessage = state.messages.find { - it.role == MessageRole.ERROR && it.content.contains("Something went wrong") - } - - assertNotNull(errorMessage) - } - - @Test - fun testMultipleErrorEvents() = runTest { - // Test handling multiple error events - val errors = listOf( - RunErrorEvent("First error", "ERROR_1"), - RunErrorEvent("Second error", "ERROR_2"), - RunErrorEvent("Third error", "ERROR_3") - ) - - errors.forEach { viewModel.handleAgentEvent(it) } - - // Verify all errors are displayed - val state = viewModel.state.value - val errorMessages = state.messages.filter { it.role == MessageRole.ERROR } - - assertEquals(3, errorMessages.size) - assertTrue(errorMessages.any { it.content.contains("First error") }) - assertTrue(errorMessages.any { it.content.contains("Second error") }) - assertTrue(errorMessages.any { it.content.contains("Third error") }) - } - - @Test - fun testInvalidJsonInToolArgs() = runTest { - // Test handling of invalid JSON in tool arguments - viewModel.handleAgentEvent(ToolCallStartEvent("tool-123", "test_tool")) - - // Send invalid JSON - val invalidJson = """{"action": "test", invalid json structure}""" - viewModel.handleAgentEvent(ToolCallArgsEvent("tool-123", invalidJson)) - viewModel.handleAgentEvent(ToolCallEndEvent("tool-123")) - - // Verify no confirmation dialog is created for invalid JSON - val state = viewModel.state.value - assertNull(state.pendingConfirmation) - - // Tool call ephemeral message should still exist - val toolMessage = state.messages.find { it.role == MessageRole.TOOL_CALL } - assertNotNull(toolMessage) - } - - @Test - fun testInvalidJsonInConfirmationTool() = runTest { - // Test invalid JSON specifically in confirmation tool - viewModel.handleAgentEvent(ToolCallStartEvent("confirm-123", "user_confirmation")) - - // Send malformed confirmation JSON - val malformedJson = """{"action": "Test action", "impact": unclosed string""" - viewModel.handleAgentEvent(ToolCallArgsEvent("confirm-123", malformedJson)) - viewModel.handleAgentEvent(ToolCallEndEvent("confirm-123")) - - // Verify no confirmation dialog is created - val state = viewModel.state.value - assertNull(state.pendingConfirmation) - } - - @Test - fun testMissingRequiredConfirmationFields() = runTest { - // Test confirmation with missing required fields - viewModel.handleAgentEvent(ToolCallStartEvent("confirm-123", "user_confirmation")) - - // JSON missing required 'action' field - val incompleteJson = """{"impact": "high", "details": {}}""" - viewModel.handleAgentEvent(ToolCallArgsEvent("confirm-123", incompleteJson)) - viewModel.handleAgentEvent(ToolCallEndEvent("confirm-123")) - - // Should handle gracefully - either create with defaults or reject - val state = viewModel.state.value - // This depends on implementation - could be null or have default values - } - - - @Test - fun testToolCallWithoutStart() = runTest { - // Test tool call events without proper start event - - // Send args without starting tool call - viewModel.handleAgentEvent(ToolCallArgsEvent("orphan-tool", """{"test": "args"}""")) - viewModel.handleAgentEvent(ToolCallEndEvent("orphan-tool")) - - // Should handle gracefully - val state = viewModel.state.value - assertNotNull(state) - } - - @Test - fun testMessageContentWithoutStart() = runTest { - // Test message content events without start event - - viewModel.handleAgentEvent(TextMessageContentEvent("orphan-msg", "Content without start")) - viewModel.handleAgentEvent(TextMessageEndEvent("orphan-msg")) - - // Should handle gracefully - val state = viewModel.state.value - assertNotNull(state) - } - - @Test - fun testExtremelyLongContent() = runTest { - // Test handling of very long content - val longContent = "x".repeat(10000) - - viewModel.handleAgentEvent(TextMessageStartEvent("long-msg")) - viewModel.handleAgentEvent(TextMessageContentEvent("long-msg", longContent)) - viewModel.handleAgentEvent(TextMessageEndEvent("long-msg")) - - // Verify content is handled properly - val state = viewModel.state.value - val message = state.messages.find { it.id == "long-msg" } - assertNotNull(message) - assertEquals(longContent, message.content) - } - - - @Test - fun testErrorRecovery() = runTest { - // Test system recovery after errors - - // Cause an error - viewModel.handleAgentEvent(RunErrorEvent("Connection lost", "NETWORK_ERROR")) - - // System should continue working after error - viewModel.handleAgentEvent(TextMessageStartEvent("recovery-msg")) - viewModel.handleAgentEvent(TextMessageContentEvent("recovery-msg", "System recovered")) - viewModel.handleAgentEvent(TextMessageEndEvent("recovery-msg")) - - // Verify both error and recovery message exist - val state = viewModel.state.value - val errorMessage = state.messages.find { it.role == MessageRole.ERROR } - val recoveryMessage = state.messages.find { it.id == "recovery-msg" } - - assertNotNull(errorMessage) - assertNotNull(recoveryMessage) - assertEquals("System recovered", recoveryMessage.content) - } - - @Test - fun testConcurrentErrorHandling() = runTest { - // Test handling multiple errors concurrently - - // Send multiple error events rapidly - repeat(5) { i -> - viewModel.handleAgentEvent(RunErrorEvent("Concurrent error $i", "ERROR_$i")) - } - - // All errors should be handled - val state = viewModel.state.value - val errorMessages = state.messages.filter { it.role == MessageRole.ERROR } - assertEquals(5, errorMessages.size) - } - - @Test - fun testStateConsistencyAfterErrors() = runTest { - // Test that state remains consistent after various errors - - val initialMessageCount = viewModel.state.value.messages.size - - // Create various error conditions - viewModel.handleAgentEvent(RunErrorEvent("Error 1", "E1")) - viewModel.handleAgentEvent(ToolCallStartEvent("bad-tool", "nonexistent_tool")) - viewModel.handleAgentEvent(ToolCallArgsEvent("bad-tool", "invalid json")) - viewModel.handleAgentEvent(TextMessageContentEvent("missing-msg", "orphaned content")) - - // Verify state is still consistent - val state = viewModel.state.value - assertNotNull(state) - assertNotNull(state.messages) - assertTrue(state.messages.size >= initialMessageCount) - - // Verify we can still process normal events - viewModel.handleAgentEvent(TextMessageStartEvent("normal-msg")) - viewModel.handleAgentEvent(TextMessageContentEvent("normal-msg", "Normal content")) - viewModel.handleAgentEvent(TextMessageEndEvent("normal-msg")) - - val finalState = viewModel.state.value - val normalMessage = finalState.messages.find { it.id == "normal-msg" } - assertNotNull(normalMessage) - assertEquals("Normal content", normalMessage.content) - } -} \ No newline at end of file diff --git a/sdks/community/kotlin/examples/chatapp/shared/src/desktopTest/kotlin/com/agui/example/chatapp/viewmodel/ChatViewModelEventHandlingMockTest.kt b/sdks/community/kotlin/examples/chatapp/shared/src/desktopTest/kotlin/com/agui/example/chatapp/viewmodel/ChatViewModelEventHandlingMockTest.kt deleted file mode 100644 index 5895d1d63..000000000 --- a/sdks/community/kotlin/examples/chatapp/shared/src/desktopTest/kotlin/com/agui/example/chatapp/viewmodel/ChatViewModelEventHandlingMockTest.kt +++ /dev/null @@ -1,257 +0,0 @@ -package com.agui.example.chatapp.viewmodel - -import com.agui.core.types.* -import com.agui.example.chatapp.ui.screens.chat.ChatViewModel -import com.agui.example.chatapp.ui.screens.chat.EphemeralType -import com.agui.example.chatapp.ui.screens.chat.MessageRole -import kotlinx.coroutines.test.runTest -import kotlinx.coroutines.delay -import kotlin.test.* - -/** - * Tests for ChatViewModel's event handling without requiring network connections. - * These tests focus on the event processing logic. - */ -class ChatViewModelEventHandlingMockTest { - - private lateinit var viewModel: ChatViewModel - - @BeforeTest - fun setup() { - viewModel = ChatViewModel() - } - - @Test - fun testMessageHandling() = runTest { - // Test event handling without actual connection - - // Simulate receiving events - viewModel.handleAgentEvent(TextMessageStartEvent("msg-1")) - viewModel.handleAgentEvent(TextMessageContentEvent("msg-1", "Hello from assistant")) - viewModel.handleAgentEvent(TextMessageEndEvent("msg-1")) - - delay(50) - - // Verify message was added - val messages = viewModel.state.value.messages - val assistantMessage = messages.find { it.role == MessageRole.ASSISTANT } - assertNotNull(assistantMessage) - assertEquals("Hello from assistant", assistantMessage.content) - assertFalse(assistantMessage.isStreaming) - } - - @Test - fun testStreamingMessages() = runTest { - // Test streaming message handling - viewModel.handleAgentEvent(TextMessageStartEvent("stream-1")) - - // Verify initial streaming state - delay(20) - var message = viewModel.state.value.messages.find { it.id == "stream-1" } - assertNotNull(message) - assertTrue(message.isStreaming) - assertEquals("", message.content) - - // Stream content - viewModel.handleAgentEvent(TextMessageContentEvent("stream-1", "First ")) - delay(20) - message = viewModel.state.value.messages.find { it.id == "stream-1" } - assertEquals("First ", message?.content) - assertTrue(message?.isStreaming == true) - - viewModel.handleAgentEvent(TextMessageContentEvent("stream-1", "part")) - delay(20) - message = viewModel.state.value.messages.find { it.id == "stream-1" } - assertEquals("First part", message?.content) - - // End streaming - viewModel.handleAgentEvent(TextMessageEndEvent("stream-1")) - delay(20) - message = viewModel.state.value.messages.find { it.id == "stream-1" } - assertFalse(message?.isStreaming == true) - assertEquals("First part", message?.content) - } - - @Test - fun testToolCallEvents() = runTest { - // Test tool call handling - focus on message creation, not timing - viewModel.handleAgentEvent(ToolCallStartEvent("tool-1", "test_tool")) - delay(50) - - // Should have ephemeral tool call message - val toolMessage = viewModel.state.value.messages.find { it.role == MessageRole.TOOL_CALL } - assertNotNull(toolMessage) - assertTrue(toolMessage.content.contains("test_tool")) - - // Add tool args - viewModel.handleAgentEvent(ToolCallArgsEvent("tool-1", """{"param": "value"}""")) - delay(50) - - // Verify args are reflected in the message - val updatedMessage = viewModel.state.value.messages.find { it.role == MessageRole.TOOL_CALL } - assertNotNull(updatedMessage) - assertTrue(updatedMessage.content.contains("param")) - - // End tool call - don't test timing, just verify event is handled - viewModel.handleAgentEvent(ToolCallEndEvent("tool-1")) - delay(50) - - // The event should be processed without error - // Note: Timing-based ephemeral message clearing is tested in Android tests - assertTrue(true, "Tool call events processed successfully") - } - - @Test - fun testConfirmationTool() = runTest { - // NOTE: With new architecture, confirmations are handled by tool executor - val confirmArgs = """{ - "action": "Delete important file", - "impact": "high", - "details": {"file": "data.db"} - }""" - - viewModel.handleAgentEvent(ToolCallStartEvent("confirm-1", "user_confirmation")) - viewModel.handleAgentEvent(ToolCallArgsEvent("confirm-1", confirmArgs)) - viewModel.handleAgentEvent(ToolCallEndEvent("confirm-1")) - - delay(50) - - // With new architecture, confirmation dialog won't be shown from events - val confirmation = viewModel.state.value.pendingConfirmation - assertNull(confirmation, "Confirmations are now handled by tool executor") - } - - @Test - fun testErrorEvents() = runTest { - // Test error handling - viewModel.handleAgentEvent(RunErrorEvent("Connection timeout", "TIMEOUT")) - delay(20) - - val errorMessage = viewModel.state.value.messages.find { it.role == MessageRole.ERROR } - assertNotNull(errorMessage) - assertTrue(errorMessage.content.contains("Connection timeout")) - } - - @Test - fun testEphemeralMessages() = runTest { - // Test step info ephemeral messages - focus on creation, not timing - viewModel.handleAgentEvent(StepStartedEvent("Processing data")) - delay(50) - - var stepMessage = viewModel.state.value.messages.find { it.role == MessageRole.STEP_INFO } - assertNotNull(stepMessage) - assertTrue(stepMessage.content.contains("Processing data")) - - // Verify it's marked as ephemeral - assertEquals(EphemeralType.STEP, stepMessage.ephemeralType) - - // Process finished event - don't test timing, just verify event handling - viewModel.handleAgentEvent(StepFinishedEvent("Processing data")) - delay(50) - - // The event should be processed without error - // Note: Timing-based ephemeral message clearing is tested in Android tests - assertTrue(true, "Step events processed successfully") - } - - @Test - fun testMessageSendingWithoutConnection() = runTest { - // Verify messages aren't sent without connection - val initialCount = viewModel.state.value.messages.size - - viewModel.sendMessage("Test message") - delay(50) - - // No message should be added without active agent/connection - assertEquals(initialCount, viewModel.state.value.messages.size) - } - - @Test - fun testCancelOperation() = runTest { - // Test cancelling current operation - viewModel.handleAgentEvent(TextMessageStartEvent("cancel-test")) - viewModel.handleAgentEvent(TextMessageContentEvent("cancel-test", "This will be cancelled")) - - // Cancel before completion - viewModel.cancelCurrentOperation() - delay(50) - - // Loading should be false - assertFalse(viewModel.state.value.isLoading) - - // Message should still exist but not be streaming - val message = viewModel.state.value.messages.find { it.id == "cancel-test" } - assertNotNull(message) - assertFalse(message.isStreaming) - } - - @Test - fun testMultipleMessagesHandling() = runTest { - // Test handling multiple messages - for (i in 1..3) { - viewModel.handleAgentEvent(TextMessageStartEvent("msg-$i")) - viewModel.handleAgentEvent(TextMessageContentEvent("msg-$i", "Message $i")) - viewModel.handleAgentEvent(TextMessageEndEvent("msg-$i")) - } - - delay(50) - - val messages = viewModel.state.value.messages.filter { it.role == MessageRole.ASSISTANT } - assertEquals(3, messages.size) - assertTrue(messages.any { it.content == "Message 1" }) - assertTrue(messages.any { it.content == "Message 2" }) - assertTrue(messages.any { it.content == "Message 3" }) - } - - @Test - fun testConfirmationActions() = runTest { - // NOTE: This test is limited without real tool execution - // With new architecture, confirmations are handled by tool executor - // This test will verify the UI handling methods still work - - // We can't trigger a real confirmation without tool execution, - // so this test is now mostly a placeholder - val confirmArgs = """{ - "action": "Test action", - "impact": "low" - }""" - - viewModel.handleAgentEvent(ToolCallStartEvent("confirm-1", "user_confirmation")) - viewModel.handleAgentEvent(ToolCallArgsEvent("confirm-1", confirmArgs)) - viewModel.handleAgentEvent(ToolCallEndEvent("confirm-1")) - - delay(50) - assertNull(viewModel.state.value.pendingConfirmation, "Confirmations are now handled by tool executor") - - // Test that confirm/reject methods don't crash when called without pending confirmation - viewModel.confirmAction() - delay(50) - - // Confirmation should be cleared - assertNull(viewModel.state.value.pendingConfirmation) - } - - @Test - fun testRejectAction() = runTest { - // NOTE: This test is limited without real tool execution - // With new architecture, confirmations are handled by tool executor - val confirmArgs = """{ - "action": "Test action", - "impact": "low" - }""" - - viewModel.handleAgentEvent(ToolCallStartEvent("confirm-1", "user_confirmation")) - viewModel.handleAgentEvent(ToolCallArgsEvent("confirm-1", confirmArgs)) - viewModel.handleAgentEvent(ToolCallEndEvent("confirm-1")) - - delay(50) - assertNull(viewModel.state.value.pendingConfirmation, "Confirmations are now handled by tool executor") - - // Test that reject method doesn't crash when called without pending confirmation - viewModel.rejectAction() - delay(50) - - // Should still be null - assertNull(viewModel.state.value.pendingConfirmation) - } -} \ No newline at end of file diff --git a/sdks/community/kotlin/examples/chatapp/shared/src/desktopTest/kotlin/com/agui/example/chatapp/viewmodel/ChatViewModelEventHandlingTest.kt b/sdks/community/kotlin/examples/chatapp/shared/src/desktopTest/kotlin/com/agui/example/chatapp/viewmodel/ChatViewModelEventHandlingTest.kt deleted file mode 100644 index c0bf5cfd6..000000000 --- a/sdks/community/kotlin/examples/chatapp/shared/src/desktopTest/kotlin/com/agui/example/chatapp/viewmodel/ChatViewModelEventHandlingTest.kt +++ /dev/null @@ -1,365 +0,0 @@ -package com.agui.example.chatapp.viewmodel - -import com.agui.example.chatapp.data.model.AgentConfig -import com.agui.example.chatapp.data.model.AuthMethod -import com.agui.example.chatapp.data.repository.AgentRepository -import com.agui.example.chatapp.test.TestSettings -import com.agui.core.types.* -import com.agui.example.chatapp.ui.screens.chat.ChatViewModel -import com.agui.example.chatapp.ui.screens.chat.EphemeralType -import com.agui.example.chatapp.ui.screens.chat.MessageRole -import kotlinx.coroutines.test.runTest -import kotlinx.serialization.json.* -import kotlin.test.* - -/** - * Note: These tests use the real ChatViewModel which requires platform settings. - * They are designed to run on Desktop/JVM where platform settings work without Android context. - * For Android unit tests, use TestChatViewModel instead. - */ - -/** - * Comprehensive tests for ChatViewModel event handling. - * Tests how the ChatViewModel processes different types of events from the agent. - */ -class ChatViewModelEventHandlingTest { - - private lateinit var testSettings: TestSettings - private lateinit var agentRepository: AgentRepository - private lateinit var viewModel: ChatViewModel - - @BeforeTest - fun setup() { - // Reset singleton instances - AgentRepository.resetInstance() - - testSettings = TestSettings() - agentRepository = AgentRepository.getInstance(testSettings) - viewModel = ChatViewModel() - - // Set up a test agent - val testAgent = AgentConfig( - id = "test-agent", - name = "Test Agent", - url = "https://test.com/agent", - authMethod = AuthMethod.None() - ) - } - - @AfterTest - fun tearDown() { - AgentRepository.resetInstance() - } - - @Test - fun testTextMessageStartEvent() = runTest { - // Create a TextMessageStartEvent - val event = TextMessageStartEvent( - messageId = "msg-123" - ) - - // Simulate event handling - viewModel.handleAgentEvent(event) - - // Verify that a new streaming message was added - val state = viewModel.state.value - val message = state.messages.find { it.id == "msg-123" } - - assertNotNull(message) - assertEquals(MessageRole.ASSISTANT, message.role) - assertEquals("", message.content) // Should start empty - assertTrue(message.isStreaming) - } - - @Test - fun testTextMessageContentEvent() = runTest { - // First, start a message - val startEvent = TextMessageStartEvent( - messageId = "msg-123" - ) - viewModel.handleAgentEvent(startEvent) - - // Then send content deltas - val contentEvent1 = TextMessageContentEvent( - messageId = "msg-123", - delta = "Hello" - ) - viewModel.handleAgentEvent(contentEvent1) - - val contentEvent2 = TextMessageContentEvent( - messageId = "msg-123", - delta = " world!" - ) - viewModel.handleAgentEvent(contentEvent2) - - // Verify content accumulation - val state = viewModel.state.value - val message = state.messages.find { it.id == "msg-123" } - - assertNotNull(message) - assertEquals("Hello world!", message.content) - assertTrue(message.isStreaming) - } - - @Test - fun testTextMessageEndEvent() = runTest { - // Start and populate a message - viewModel.handleAgentEvent(TextMessageStartEvent("msg-123")) - viewModel.handleAgentEvent(TextMessageContentEvent("msg-123", "Complete message")) - - // End the message - val endEvent = TextMessageEndEvent(messageId = "msg-123") - viewModel.handleAgentEvent(endEvent) - - // Verify message is no longer streaming - val state = viewModel.state.value - val message = state.messages.find { it.id == "msg-123" } - - assertNotNull(message) - assertEquals("Complete message", message.content) - assertFalse(message.isStreaming) - } - - @Test - fun testToolCallStartEvent() = runTest { - // Create a tool call start event - val event = ToolCallStartEvent( - toolCallId = "tool-123", - toolCallName = "test_tool" - ) - - viewModel.handleAgentEvent(event) - - // Verify ephemeral message is created - val state = viewModel.state.value - val ephemeralMessage = state.messages.find { - it.role == MessageRole.TOOL_CALL && it.content.contains("test_tool") - } - - assertNotNull(ephemeralMessage) - assertEquals(EphemeralType.TOOL_CALL, ephemeralMessage.ephemeralType) - } - - @Test - fun testToolCallArgsEvent() = runTest { - // Start a tool call - viewModel.handleAgentEvent(ToolCallStartEvent("tool-123", "test_tool")) - - // Send args in chunks - val argsEvent1 = ToolCallArgsEvent( - toolCallId = "tool-123", - delta = """{"param": """ - ) - viewModel.handleAgentEvent(argsEvent1) - - val argsEvent2 = ToolCallArgsEvent( - toolCallId = "tool-123", - delta = """"value"}""" - ) - viewModel.handleAgentEvent(argsEvent2) - - // Verify ephemeral message is updated with args preview - val state = viewModel.state.value - val ephemeralMessage = state.messages.find { - it.role == MessageRole.TOOL_CALL - } - - assertNotNull(ephemeralMessage) - assertTrue(ephemeralMessage.content.contains("tool with:")) - assertTrue(ephemeralMessage.content.contains("""{"param": "value"}""")) - } - - @Test - fun testToolCallEndEvent() = runTest { - // Start a tool call - viewModel.handleAgentEvent(ToolCallStartEvent("tool-123", "test_tool")) - viewModel.handleAgentEvent(ToolCallArgsEvent("tool-123", """{"test": "args"}""")) - - // End the tool call - val endEvent = ToolCallEndEvent(toolCallId = "tool-123") - viewModel.handleAgentEvent(endEvent) - - // For non-confirmation tools, ephemeral message should be cleared after delay - // We can't easily test the delay here, but we can verify the event was processed - val state = viewModel.state.value - // The ephemeral message might still be there since delay hasn't executed - // This test mainly ensures no exceptions are thrown - assertNotNull(state) - } - - @Test - fun testUserConfirmationToolFlow() = runTest { - // NOTE: With new architecture, confirmation dialogs are handled by tool executor - // Start a user_confirmation tool call - viewModel.handleAgentEvent(ToolCallStartEvent("confirm-123", "user_confirmation")) - - // Send confirmation args - val confirmationArgs = """ - { - "action": "Delete file", - "impact": "high", - "details": {"file": "important.txt"}, - "timeout_seconds": 30 - } - """.trimIndent() - - viewModel.handleAgentEvent(ToolCallArgsEvent("confirm-123", confirmationArgs)) - viewModel.handleAgentEvent(ToolCallEndEvent("confirm-123")) - - // With new architecture, confirmation dialog won't be shown from events - val state = viewModel.state.value - assertNull(state.pendingConfirmation, "Confirmations are now handled by tool executor") - } - - @Test - fun testStepStartedEvent() = runTest { - val event = StepStartedEvent(stepName = "Processing data") - viewModel.handleAgentEvent(event) - - // Verify step ephemeral message is created - val state = viewModel.state.value - val stepMessage = state.messages.find { - it.role == MessageRole.STEP_INFO && it.content.contains("Processing data") - } - - assertNotNull(stepMessage) - assertEquals(EphemeralType.STEP, stepMessage.ephemeralType) - } - - @Test - fun testStepFinishedEvent() = runTest { - // Start a step - viewModel.handleAgentEvent(StepStartedEvent("Processing data")) - - // Finish the step - val finishEvent = StepFinishedEvent(stepName = "Processing data") - viewModel.handleAgentEvent(finishEvent) - - // Step message should be cleared after delay (can't test delay directly) - val state = viewModel.state.value - assertNotNull(state) // Just verify no exceptions - } - - @Test - fun testRunErrorEvent() = runTest { - val errorEvent = RunErrorEvent( - message = "Connection failed", - code = "NETWORK_ERROR" - ) - - viewModel.handleAgentEvent(errorEvent) - - // Verify error message is added - val state = viewModel.state.value - val errorMessage = state.messages.find { - it.role == MessageRole.ERROR && it.content.contains("Connection failed") - } - - assertNotNull(errorMessage) - } - - @Test - fun testRunFinishedEvent() = runTest { - // Add some ephemeral messages first - viewModel.handleAgentEvent(ToolCallStartEvent("tool-123", "test_tool")) - viewModel.handleAgentEvent(StepStartedEvent("test step")) - - val runFinishedEvent = RunFinishedEvent( - threadId = "thread-123", - runId = "run-123" - ) - - viewModel.handleAgentEvent(runFinishedEvent) - - // Verify ephemeral messages are cleared - // Note: The actual clearing happens asynchronously, but we can verify the event was processed - val state = viewModel.state.value - assertNotNull(state) - } - - @Test - fun testEphemeralMessageManagement() = runTest { - // Test that ephemeral messages replace each other by type - - // Add first tool call - viewModel.handleAgentEvent(ToolCallStartEvent("tool-1", "first_tool")) - val state1 = viewModel.state.value - val toolMessages1 = state1.messages.filter { it.role == MessageRole.TOOL_CALL } - assertEquals(1, toolMessages1.size) - - // Add second tool call (should replace first) - viewModel.handleAgentEvent(ToolCallStartEvent("tool-2", "second_tool")) - val state2 = viewModel.state.value - val toolMessages2 = state2.messages.filter { it.role == MessageRole.TOOL_CALL } - assertEquals(1, toolMessages2.size) // Still only one - assertTrue(toolMessages2.first().content.contains("second_tool")) - - // Add step (should coexist with tool message) - viewModel.handleAgentEvent(StepStartedEvent("test step")) - val state3 = viewModel.state.value - val stepMessages = state3.messages.filter { it.role == MessageRole.STEP_INFO } - assertEquals(1, stepMessages.size) - assertEquals(2, state3.messages.filter { it.ephemeralType != null }.size) - } - - @Test - fun testStateSnapshotAndDeltaEventsIgnored() = runTest { - // These events should be processed but not create UI messages - val snapshotEvent = StateSnapshotEvent( - snapshot = buildJsonObject { - put("key", "value") - } - ) - val deltaEvent = StateDeltaEvent( - delta = buildJsonArray { - addJsonObject { - put("op", "replace") - put("path", "/key") - put("value", "new_value") - } - } - ) - - val initialMessageCount = viewModel.state.value.messages.size - - viewModel.handleAgentEvent(snapshotEvent) - viewModel.handleAgentEvent(deltaEvent) - - // Message count should remain the same - val finalMessageCount = viewModel.state.value.messages.size - assertEquals(initialMessageCount, finalMessageCount) - } - - @Test - fun testMultipleMessageStreaming() = runTest { - // Test handling multiple concurrent streaming messages - - // Start first message - viewModel.handleAgentEvent(TextMessageStartEvent("msg-1")) - viewModel.handleAgentEvent(TextMessageContentEvent("msg-1", "First ")) - - // Start second message - viewModel.handleAgentEvent(TextMessageStartEvent("msg-2")) - viewModel.handleAgentEvent(TextMessageContentEvent("msg-2", "Second ")) - - // Continue both messages - viewModel.handleAgentEvent(TextMessageContentEvent("msg-1", "message")) - viewModel.handleAgentEvent(TextMessageContentEvent("msg-2", "message")) - - // End both messages - viewModel.handleAgentEvent(TextMessageEndEvent("msg-1")) - viewModel.handleAgentEvent(TextMessageEndEvent("msg-2")) - - // Verify both messages exist with correct content - val state = viewModel.state.value - val msg1 = state.messages.find { it.id == "msg-1" } - val msg2 = state.messages.find { it.id == "msg-2" } - - assertNotNull(msg1) - assertNotNull(msg2) - assertEquals("First message", msg1.content) - assertEquals("Second message", msg2.content) - assertFalse(msg1.isStreaming) - assertFalse(msg2.isStreaming) - } -} \ No newline at end of file diff --git a/sdks/community/kotlin/examples/chatapp/shared/src/desktopTest/kotlin/com/agui/example/chatapp/viewmodel/ChatViewModelToolConfirmationTest.kt b/sdks/community/kotlin/examples/chatapp/shared/src/desktopTest/kotlin/com/agui/example/chatapp/viewmodel/ChatViewModelToolConfirmationTest.kt deleted file mode 100644 index 6856bdd45..000000000 --- a/sdks/community/kotlin/examples/chatapp/shared/src/desktopTest/kotlin/com/agui/example/chatapp/viewmodel/ChatViewModelToolConfirmationTest.kt +++ /dev/null @@ -1,108 +0,0 @@ -package com.agui.example.chatapp.viewmodel - -import com.agui.example.chatapp.data.model.AgentConfig -import com.agui.example.chatapp.data.model.AuthMethod -import com.agui.example.chatapp.data.repository.AgentRepository -import com.agui.example.chatapp.test.TestSettings -import com.agui.core.types.* -import com.agui.example.chatapp.ui.screens.chat.ChatViewModel -import com.agui.example.chatapp.ui.screens.chat.MessageRole -import kotlinx.coroutines.test.runTest -import kotlin.test.* -import kotlin.test.Ignore - -/** - * Tests for tool confirmation flow in ChatViewModel. - * - * NOTE: With the new AgentClient architecture, confirmation dialogs are shown - * by the ConfirmationToolExecutor when tools are executed on the agent side. - * These tests are limited because they don't have a real agent connection. - * Full integration testing would require a mock agent or integration tests. - */ -class ChatViewModelToolConfirmationTest { - - private lateinit var testSettings: TestSettings - private lateinit var agentRepository: AgentRepository - private lateinit var viewModel: ChatViewModel - - @BeforeTest - fun setup() { - // Reset singleton instances - AgentRepository.resetInstance() - - testSettings = TestSettings() - agentRepository = AgentRepository.getInstance(testSettings) - viewModel = ChatViewModel() - - // Set up a test agent - val testAgent = AgentConfig( - id = "test-agent", - name = "Test Agent", - url = "https://test.com/agent", - authMethod = AuthMethod.None() - ) - } - - @AfterTest - fun tearDown() { - AgentRepository.resetInstance() - } - - @Test - fun testUserConfirmationToolDetection() = runTest { - // Start a user_confirmation tool call - val toolStartEvent = ToolCallStartEvent( - toolCallId = "confirm-123", - toolCallName = "user_confirmation" - ) - - viewModel.handleAgentEvent(toolStartEvent) - - // Verify that no ephemeral message is created for confirmation tools - val state = viewModel.state.value - val toolMessages = state.messages.filter { it.role == MessageRole.TOOL_CALL } - assertTrue(toolMessages.isEmpty(), "Confirmation tools should not show ephemeral messages") - } - - - - @Test - fun testConfirmationWithInvalidJson() = runTest { - // Test error handling for malformed JSON - viewModel.handleAgentEvent(ToolCallStartEvent("confirm-123", "user_confirmation")) - - // Use truly malformed JSON - val invalidArgs = """{"action": "Test", "invalid": json, missing quotes}""" - - viewModel.handleAgentEvent(ToolCallArgsEvent("confirm-123", invalidArgs)) - viewModel.handleAgentEvent(ToolCallEndEvent("confirm-123")) - - // Verify no confirmation dialog is shown (the exception should be caught) - val state = viewModel.state.value - assertNull(state.pendingConfirmation, "Invalid JSON should not create confirmation dialog") - } - - - - - - - - @Test - fun testNonConfirmationToolsIgnored() = runTest { - // Test that regular tools don't trigger confirmation dialog - viewModel.handleAgentEvent(ToolCallStartEvent("tool-123", "file_read")) - - val regularArgs = """{"path": "/some/file.txt"}""" - - viewModel.handleAgentEvent(ToolCallArgsEvent("tool-123", regularArgs)) - viewModel.handleAgentEvent(ToolCallEndEvent("tool-123")) - - // Verify no confirmation dialog is shown - val state = viewModel.state.value - assertNull(state.pendingConfirmation, "Regular tools should not show confirmation dialog") - } - - - -} \ No newline at end of file diff --git a/sdks/community/kotlin/examples/tools/build.gradle.kts b/sdks/community/kotlin/examples/tools/build.gradle.kts index 139000d66..2bbfb7439 100644 --- a/sdks/community/kotlin/examples/tools/build.gradle.kts +++ b/sdks/community/kotlin/examples/tools/build.gradle.kts @@ -5,7 +5,7 @@ plugins { } group = "com.agui.examples" -version = "0.2.1" +version = "0.2.3" repositories { google() @@ -23,8 +23,8 @@ kotlin { freeCompilerArgs.add("-opt-in=kotlin.RequiresOptIn") freeCompilerArgs.add("-opt-in=kotlinx.coroutines.ExperimentalCoroutinesApi") freeCompilerArgs.add("-opt-in=kotlinx.serialization.ExperimentalSerializationApi") - languageVersion.set(org.jetbrains.kotlin.gradle.dsl.KotlinVersion.KOTLIN_2_1) - apiVersion.set(org.jetbrains.kotlin.gradle.dsl.KotlinVersion.KOTLIN_2_1) + languageVersion.set(org.jetbrains.kotlin.gradle.dsl.KotlinVersion.KOTLIN_2_2) + apiVersion.set(org.jetbrains.kotlin.gradle.dsl.KotlinVersion.KOTLIN_2_2) } } } @@ -64,8 +64,8 @@ kotlin { val commonMain by getting { dependencies { // Core and tools dependencies - api("com.agui:kotlin-core:0.2.1") - api("com.agui:kotlin-tools:0.2.1") + api("com.agui:kotlin-core:0.2.3") + api("com.agui:kotlin-tools:0.2.3") // Kotlinx libraries implementation(libs.kotlinx.coroutines.core) @@ -79,7 +79,7 @@ kotlin { implementation(kotlin("test")) implementation(libs.kotlinx.coroutines.test) // Add client module for integration testing (includes agent functionality) - implementation("com.agui:kotlin-client:0.2.1") + implementation("com.agui:kotlin-client:0.2.3") } } @@ -131,4 +131,4 @@ android { tasks.withType { useJUnitPlatform() -} \ No newline at end of file +} diff --git a/sdks/community/kotlin/examples/tools/gradle.properties b/sdks/community/kotlin/examples/tools/gradle.properties index 372ea369e..b5397f364 100644 --- a/sdks/community/kotlin/examples/tools/gradle.properties +++ b/sdks/community/kotlin/examples/tools/gradle.properties @@ -9,7 +9,6 @@ kotlin.code.style=official kotlin.mpp.androidSourceSetLayoutVersion=2 kotlin.mpp.applyDefaultHierarchyTemplate=false kotlin.native.cacheKind=none -kotlin.native.useEmbeddableCompilerJar=true kotlin.mpp.enableCInteropCommonization=true # Compose @@ -25,10 +24,10 @@ android.nonTransitiveRClass=true xcodeproj=./iosApp # K2 Compiler Settings -kotlin.compiler.version=2.1.21 -kotlin.compiler.languageVersion=2.1 -kotlin.compiler.apiVersion=2.1 +kotlin.compiler.version=2.2.20 +kotlin.compiler.languageVersion=2.2 +kotlin.compiler.apiVersion=2.2 kotlin.compiler.k2=true # Disable Kotlin Native bundling service -kotlin.native.disableCompilerDaemon=true \ No newline at end of file +kotlin.native.disableCompilerDaemon=true diff --git a/sdks/community/kotlin/examples/tools/gradle/libs.versions.toml b/sdks/community/kotlin/examples/tools/gradle/libs.versions.toml index ec85b1b60..1b9987c5d 100644 --- a/sdks/community/kotlin/examples/tools/gradle/libs.versions.toml +++ b/sdks/community/kotlin/examples/tools/gradle/libs.versions.toml @@ -1,12 +1,12 @@ [versions] activity-compose = "1.10.1" -agui-core = "0.2.1" +agui-core = "0.2.3" appcompat = "1.7.1" core = "1.6.1" core-ktx = "1.16.0" junit = "4.13.2" junit-version = "1.2.1" -kotlin = "2.1.21" +kotlin = "2.2.20" #Downgrading to avoid an R8 error ktor = "3.1.3" kotlinx-serialization = "1.8.1" @@ -19,7 +19,6 @@ multiplatform-settings-coroutines = "1.2.0" okio = "3.13.0" runner = "1.6.2" slf4j = "2.0.9" -ui-test-junit4 = "1.8.3" voyager-navigator = "1.0.0" [libraries] @@ -28,7 +27,6 @@ activity-compose = { module = "androidx.activity:activity-compose", version.ref agui-client = { module = "com.agui:kotlin-client", version.ref = "agui-core" } agui-core = { module = "com.agui:kotlin-core", version.ref = "agui-core" } agui-tools = { module = "com.agui:kotlin-tools", version.ref = "agui-core" } -androidx-ui-tooling = { module = "androidx.compose.ui:ui-tooling", version.ref = "ui-test-junit4" } appcompat = { module = "androidx.appcompat:appcompat", version.ref = "appcompat" } core = { module = "androidx.test:core", version.ref = "core" } core-ktx = { module = "androidx.core:core-ktx", version.ref = "core-ktx" } @@ -58,7 +56,6 @@ multiplatform-settings-coroutines = { module = "com.russhwolf:multiplatform-sett okio = { module = "com.squareup.okio:okio", version.ref = "okio" } runner = { module = "androidx.test:runner", version.ref = "runner" } slf4j-simple = { module = "org.slf4j:slf4j-simple", version.ref = "slf4j" } -ui-test-junit4 = { module = "androidx.compose.ui:ui-test-junit4", version.ref = "ui-test-junit4" } voyager-navigator = { module = "cafe.adriel.voyager:voyager-navigator", version.ref = "voyager-navigator" } voyager-screenmodel = { module = "cafe.adriel.voyager:voyager-screenmodel", version.ref = "voyager-navigator" } voyager-transitions = { module = "cafe.adriel.voyager:voyager-transitions", version.ref = "voyager-navigator" } @@ -80,4 +77,4 @@ kotlinx-common = [ "kotlinx-coroutines-core", "kotlinx-serialization-json", "kotlinx-datetime" -] \ No newline at end of file +] diff --git a/sdks/community/kotlin/examples/tools/settings.gradle.kts b/sdks/community/kotlin/examples/tools/settings.gradle.kts index de4a20e24..ec66d505b 100644 --- a/sdks/community/kotlin/examples/tools/settings.gradle.kts +++ b/sdks/community/kotlin/examples/tools/settings.gradle.kts @@ -11,7 +11,7 @@ pluginManagement { } plugins { - val kotlinVersion = "2.1.21" + val kotlinVersion = "2.2.20" val agpVersion = "8.10.1" kotlin("multiplatform") version kotlinVersion @@ -30,4 +30,4 @@ dependencyResolutionManagement { mavenCentral() mavenLocal() } -} \ No newline at end of file +} diff --git a/sdks/community/kotlin/examples/tools/src/commonMain/kotlin/com/agui/example/tools/ChangeBackgroundToolExecutor.kt b/sdks/community/kotlin/examples/tools/src/commonMain/kotlin/com/agui/example/tools/ChangeBackgroundToolExecutor.kt new file mode 100644 index 000000000..41b021fc2 --- /dev/null +++ b/sdks/community/kotlin/examples/tools/src/commonMain/kotlin/com/agui/example/tools/ChangeBackgroundToolExecutor.kt @@ -0,0 +1,146 @@ +package com.agui.example.tools + +import com.agui.core.types.Tool +import com.agui.core.types.ToolCall +import com.agui.tools.AbstractToolExecutor +import com.agui.tools.ToolExecutionContext +import com.agui.tools.ToolExecutionResult +import com.agui.tools.ToolValidationResult +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonPrimitive +import kotlinx.serialization.json.booleanOrNull +import kotlinx.serialization.json.buildJsonObject +import kotlinx.serialization.json.jsonObject +import kotlinx.serialization.json.jsonPrimitive +import kotlinx.serialization.json.put +import kotlinx.serialization.json.putJsonObject + +class ChangeBackgroundToolExecutor( + private val backgroundChangeHandler: BackgroundChangeHandler +) : AbstractToolExecutor( + tool = Tool( + name = "change_background", + description = "Update the application's background or surface colour", + parameters = buildJsonObject { + put("type", "object") + putJsonObject("properties") { + putJsonObject("color") { + put("type", "string") + put( + "description", + "Colour in hex format (e.g. #RRGGBB or #RRGGBBAA) to apply to the background" + ) + } + putJsonObject("description") { + put("type", "string") + put( + "description", + "Optional human readable description of the new background" + ) + } + putJsonObject("reset") { + put("type", "boolean") + put( + "description", + "Set to true to reset the background to the default theme" + ) + put("default", JsonPrimitive(false)) + } + } + } + ) +) { + + override suspend fun executeInternal(context: ToolExecutionContext): ToolExecutionResult { + val args = try { + Json.parseToJsonElement(context.toolCall.function.arguments).jsonObject + } catch (error: Exception) { + return ToolExecutionResult.failure("Invalid JSON arguments: ${error.message}") + } + + val reset = args["reset"]?.jsonPrimitive?.booleanOrNull ?: false + if (reset) { + backgroundChangeHandler.applyBackground(BackgroundStyle.Default) + return ToolExecutionResult.success( + result = buildJsonObject { + put("status", "reset") + }, + message = "Background reset to default" + ) + } + + val color = args["color"]?.jsonPrimitive?.content + ?: return ToolExecutionResult.failure("Missing required parameter: color") + + if (!color.matches(HEX_COLOUR_REGEX)) { + return ToolExecutionResult.failure( + "Invalid colour value: $color. Expected formats: #RRGGBB or #RRGGBBAA" + ) + } + + val description = args["description"]?.jsonPrimitive?.content + val style = BackgroundStyle( + colorHex = color, + description = description + ) + + return try { + backgroundChangeHandler.applyBackground(style) + ToolExecutionResult.success( + result = buildJsonObject { + put("status", "applied") + put("color", color) + if (description != null) { + put("description", description) + } + }, + message = "Background updated" + ) + } catch (error: Exception) { + ToolExecutionResult.failure("Failed to change background: ${error.message}") + } + } + + override fun validate(toolCall: ToolCall): ToolValidationResult { + val args = try { + Json.parseToJsonElement(toolCall.function.arguments).jsonObject + } catch (error: Exception) { + return ToolValidationResult.failure("Invalid JSON arguments: ${error.message}") + } + + val reset = args["reset"]?.jsonPrimitive?.booleanOrNull ?: false + if (reset) { + return ToolValidationResult.success() + } + + val color = args["color"]?.jsonPrimitive?.content + ?: return ToolValidationResult.failure("Missing required parameter: color") + + return if (color.matches(HEX_COLOUR_REGEX)) { + ToolValidationResult.success() + } else { + ToolValidationResult.failure( + "Invalid colour value: $color. Expected formats: #RRGGBB or #RRGGBBAA" + ) + } + } + + override fun getMaxExecutionTimeMs(): Long? = 10_000L + + private companion object { + val HEX_COLOUR_REGEX = Regex("^#([0-9A-Fa-f]{6}|[0-9A-Fa-f]{8})$") + } +} + +data class BackgroundStyle( + val colorHex: String?, + val description: String? = null +) { + companion object { + val Default = BackgroundStyle(colorHex = null, description = null) + } +} + +interface BackgroundChangeHandler { + suspend fun applyBackground(style: BackgroundStyle) +} diff --git a/sdks/community/kotlin/examples/tools/src/commonMain/kotlin/com/agui/example/tools/ConfirmationToolExecutor.kt b/sdks/community/kotlin/examples/tools/src/commonMain/kotlin/com/agui/example/tools/ConfirmationToolExecutor.kt deleted file mode 100644 index b5f9a348e..000000000 --- a/sdks/community/kotlin/examples/tools/src/commonMain/kotlin/com/agui/example/tools/ConfirmationToolExecutor.kt +++ /dev/null @@ -1,205 +0,0 @@ -package com.agui.example.tools - -import com.agui.core.types.Tool -import com.agui.core.types.ToolCall -import com.agui.tools.AbstractToolExecutor -import com.agui.tools.ToolExecutionContext -import com.agui.tools.ToolExecutionResult -import com.agui.tools.ToolValidationResult -import kotlinx.serialization.json.Json -import kotlinx.serialization.json.JsonPrimitive -import kotlinx.serialization.json.add -import kotlinx.serialization.json.buildJsonArray -import kotlinx.serialization.json.buildJsonObject -import kotlinx.serialization.json.jsonObject -import kotlinx.serialization.json.jsonPrimitive -import kotlinx.serialization.json.put -import kotlinx.serialization.json.putJsonArray -import kotlinx.serialization.json.putJsonObject - -/** - * Built-in tool executor for user confirmation. - * - * This tool allows agents to request user confirmation for actions with different - * importance levels. The actual confirmation UI is provided by the client application. - */ -class ConfirmationToolExecutor( - private val confirmationHandler: ConfirmationHandler -) : AbstractToolExecutor( - tool = Tool( - name = "user_confirmation", - description = "Request user confirmation for an action with specified importance level", - parameters = buildJsonObject { - put("type", "object") - putJsonObject("properties") { - putJsonObject("message") { - put("type", "string") - put("description", "The confirmation message to display to the user") - } - putJsonObject("importance") { - put("type", "string") - put("enum", buildJsonArray { - add("critical") - add("high") - add("medium") - add("low") - }) - put("description", "The importance level of the confirmation") - put("default", "medium") - } - putJsonObject("details") { - put("type", "string") - put("description", "Optional additional details about the action requiring confirmation") - } - } - putJsonArray("required") { - add("message") - } - } - ) -) { - - override suspend fun executeInternal(context: ToolExecutionContext): ToolExecutionResult { - // Parse the tool call arguments - val args = try { - Json.parseToJsonElement(context.toolCall.function.arguments).jsonObject - } catch (e: Exception) { - return ToolExecutionResult.failure("Invalid JSON arguments: ${e.message}") - } - - // Extract parameters - val message = args["message"]?.jsonPrimitive?.content - ?: return ToolExecutionResult.failure("Missing required parameter: message") - - val importance = args["importance"]?.jsonPrimitive?.content ?: "medium" - val details = args["details"]?.jsonPrimitive?.content - - // Validate importance level - val validImportance = when (importance) { - "critical", "high", "medium", "low" -> importance - else -> return ToolExecutionResult.failure("Invalid importance level: $importance. Must be critical, high, medium, or low") - } - - // Create confirmation request - val request = ConfirmationRequest( - message = message, - importance = validImportance, - details = details, - toolCallId = context.toolCall.id, - threadId = context.threadId, - runId = context.runId - ) - - // Execute confirmation through handler - return try { - val confirmed = confirmationHandler.requestConfirmation(request) - - val resultJson = buildJsonObject { - put("confirmed", confirmed) - put("message", message) - put("importance", validImportance) - if (details != null) { - put("details", details) - } - } - - ToolExecutionResult.success( - result = resultJson, - message = if (confirmed) "User confirmed the action" else "User rejected the action" - ) - } catch (e: Exception) { - ToolExecutionResult.failure("Confirmation failed: ${e.message}") - } - } - - override fun validate(toolCall: ToolCall): ToolValidationResult { - val args = try { - Json.parseToJsonElement(toolCall.function.arguments).jsonObject - } catch (e: Exception) { - return ToolValidationResult.failure("Invalid JSON arguments: ${e.message}") - } - - val errors = mutableListOf() - - // Check required fields - if (!args.containsKey("message") || args["message"]?.jsonPrimitive?.content.isNullOrBlank()) { - errors.add("Missing or empty required parameter: message") - } - - // Validate importance if provided - args["importance"]?.jsonPrimitive?.content?.let { importance -> - if (importance !in listOf("critical", "high", "medium", "low")) { - errors.add("Invalid importance level: $importance. Must be critical, high, medium, or low") - } - } - - return if (errors.isEmpty()) { - ToolValidationResult.success() - } else { - ToolValidationResult.failure(errors) - } - } - - override fun getMaxExecutionTimeMs(): Long? { - // User confirmations can take a while, so allow up to 5 minutes - return 300_000L - } -} - -/** - * Request for user confirmation. - */ -data class ConfirmationRequest( - val message: String, - val importance: String, - val details: String? = null, - val toolCallId: String, - val threadId: String? = null, - val runId: String? = null -) - -/** - * Interface for handling user confirmation requests. - * - * Implementations should provide the actual UI/UX for getting user confirmation. - * This might be a dialog, console prompt, web form, etc. - */ -interface ConfirmationHandler { - /** - * Request confirmation from the user. - * - * @param request The confirmation request details - * @return True if the user confirmed, false if they rejected - * @throws Exception if confirmation fails or times out - */ - suspend fun requestConfirmation(request: ConfirmationRequest): Boolean -} - -/** - * Simple console-based confirmation handler for testing/debugging. - */ -class ConsoleConfirmationHandler : ConfirmationHandler { - override suspend fun requestConfirmation(request: ConfirmationRequest): Boolean { - println("\n=== USER CONFIRMATION REQUIRED ===") - println("Importance: ${request.importance.uppercase()}") - println("Message: ${request.message}") - if (request.details != null) { - println("Details: ${request.details}") - } - println("===================================") - - print("Confirm this action? (y/N): ") - val input = readlnOrNull()?.trim()?.lowercase() - return input in listOf("y", "yes", "true", "1") - } -} - -/** - * No-op confirmation handler that always confirms. - * Useful for testing or when confirmation is handled elsewhere. - */ -class AutoConfirmHandler(private val autoConfirm: Boolean = true) : ConfirmationHandler { - override suspend fun requestConfirmation(request: ConfirmationRequest): Boolean { - return autoConfirm - } -} \ No newline at end of file diff --git a/sdks/community/kotlin/library/build.gradle.kts b/sdks/community/kotlin/library/build.gradle.kts index 677d0ac92..815ead65a 100644 --- a/sdks/community/kotlin/library/build.gradle.kts +++ b/sdks/community/kotlin/library/build.gradle.kts @@ -2,10 +2,11 @@ // All modules are configured individually - see each module's build.gradle.kts plugins { - kotlin("multiplatform") version "2.1.21" apply false - kotlin("plugin.serialization") version "2.1.21" apply false + kotlin("multiplatform") version "2.2.20" apply false + kotlin("plugin.serialization") version "2.2.20" apply false id("com.android.library") version "8.10.1" apply false id("org.jetbrains.dokka") version "2.0.0" + id("org.jetbrains.kotlinx.kover") version "0.8.3" } allprojects { @@ -18,7 +19,9 @@ allprojects { // Configure all subprojects with common settings subprojects { group = "com.agui" - version = "0.2.1" + version = "0.2.3" + + apply(plugin = "org.jetbrains.kotlinx.kover") tasks.withType { useJUnitPlatform() @@ -30,6 +33,12 @@ subprojects { // Simple Dokka V2 configuration - let it use defaults for navigation +tasks.register("koverHtmlReportAll") { + group = "verification" + description = "Generates HTML coverage reports for all library modules." + dependsOn(subprojects.map { "${it.path}:koverHtmlReport" }) +} + // Create a task to generate unified documentation tasks.register("dokkaHtmlMultiModule") { dependsOn(subprojects.map { "${it.name}:dokkaGenerate" }) @@ -95,4 +104,4 @@ tasks.register("dokkaHtmlMultiModule") { println("Unified documentation generated at: ${outputDir.absolutePath}/index.html") } -} \ No newline at end of file +} diff --git a/sdks/community/kotlin/library/client/build.gradle.kts b/sdks/community/kotlin/library/client/build.gradle.kts index b575ccf4b..293059f81 100644 --- a/sdks/community/kotlin/library/client/build.gradle.kts +++ b/sdks/community/kotlin/library/client/build.gradle.kts @@ -7,7 +7,7 @@ plugins { } group = "com.agui" -version = "0.2.1" + version = "0.2.3" repositories { google() @@ -24,8 +24,8 @@ kotlin { freeCompilerArgs.add("-opt-in=kotlin.RequiresOptIn") freeCompilerArgs.add("-opt-in=kotlinx.coroutines.ExperimentalCoroutinesApi") freeCompilerArgs.add("-opt-in=kotlinx.serialization.ExperimentalSerializationApi") - languageVersion.set(org.jetbrains.kotlin.gradle.dsl.KotlinVersion.KOTLIN_2_1) - apiVersion.set(org.jetbrains.kotlin.gradle.dsl.KotlinVersion.KOTLIN_2_1) + languageVersion.set(org.jetbrains.kotlin.gradle.dsl.KotlinVersion.KOTLIN_2_2) + apiVersion.set(org.jetbrains.kotlin.gradle.dsl.KotlinVersion.KOTLIN_2_2) } } } @@ -198,4 +198,4 @@ signing { tasks.withType { useJUnitPlatform() -} \ No newline at end of file +} diff --git a/sdks/community/kotlin/library/client/src/commonMain/kotlin/com/agui/client/AgUiAgent.kt b/sdks/community/kotlin/library/client/src/commonMain/kotlin/com/agui/client/AgUiAgent.kt index a4bfc88be..9e49cf9db 100644 --- a/sdks/community/kotlin/library/client/src/commonMain/kotlin/com/agui/client/AgUiAgent.kt +++ b/sdks/community/kotlin/library/client/src/commonMain/kotlin/com/agui/client/AgUiAgent.kt @@ -42,9 +42,6 @@ open class AgUiAgent( ToolExecutionManager(it, ClientToolResponseHandler(agent)) } - // Track threads that have already received tools (for stateless agent optimization) - private val threadsWithToolsInfo = mutableSetOf() - /** * Run agent with explicit input and return observable event stream */ @@ -103,15 +100,9 @@ open class AgUiAgent( content = message )) - // Only send tools on the first run for each thread (stateless agent optimization) - val isFirstRunForThread = !threadsWithToolsInfo.contains(threadId) + // Always provide the current tool registry so the backend can reliably execute tools val toolRegistry = config.toolRegistry - val toolsToSend = if (isFirstRunForThread && toolRegistry != null) { - threadsWithToolsInfo.add(threadId) - toolRegistry.getAllTools() - } else { - emptyList() - } + val toolsToSend = toolRegistry?.getAllTools() ?: emptyList() val input = RunAgentInput( threadId = threadId, @@ -130,9 +121,15 @@ open class AgUiAgent( * Clear the thread tracking for tools (useful for testing or resetting state) */ fun clearThreadToolsTracking() { - threadsWithToolsInfo.clear() + // Kept for backward compatibility; no caching is performed anymore. } + /** + * Registers an [AgentSubscriber] that will receive lifecycle and event callbacks + * for every run executed through this agent. + */ + fun subscribe(subscriber: AgentSubscriber): AgentSubscription = agent.subscribe(subscriber) + /** * Close the agent and release resources */ @@ -216,4 +213,4 @@ open class AgUiAgentConfig { apiKey?.let { put(apiKeyHeader, it) } putAll(headers) } -} \ No newline at end of file +} diff --git a/sdks/community/kotlin/library/client/src/commonMain/kotlin/com/agui/client/StatefulAgUiAgent.kt b/sdks/community/kotlin/library/client/src/commonMain/kotlin/com/agui/client/StatefulAgUiAgent.kt index 4491cb46a..c174b9531 100644 --- a/sdks/community/kotlin/library/client/src/commonMain/kotlin/com/agui/client/StatefulAgUiAgent.kt +++ b/sdks/community/kotlin/library/client/src/commonMain/kotlin/com/agui/client/StatefulAgUiAgent.kt @@ -9,7 +9,6 @@ private val logger = Logger.withTag("StatefulAgUiAgent") /** * Stateful AG-UI agent that maintains conversation history and state. - * Includes predictive state updates via PredictStateValue. */ open class StatefulAgUiAgent( url: String, @@ -194,4 +193,4 @@ class StatefulAgUiAgentConfig : AgUiAgentConfig() { /** Maximum conversation history length (0 = unlimited) */ var maxHistoryLength: Int = 100 -} \ No newline at end of file +} diff --git a/sdks/community/kotlin/library/client/src/commonMain/kotlin/com/agui/client/agent/AbstractAgent.kt b/sdks/community/kotlin/library/client/src/commonMain/kotlin/com/agui/client/agent/AbstractAgent.kt index 9437d6672..5256de78e 100644 --- a/sdks/community/kotlin/library/client/src/commonMain/kotlin/com/agui/client/agent/AbstractAgent.kt +++ b/sdks/community/kotlin/library/client/src/commonMain/kotlin/com/agui/client/agent/AbstractAgent.kt @@ -1,6 +1,7 @@ package com.agui.client.agent import com.agui.core.types.* +import com.agui.client.chunks.transformChunks import com.agui.client.state.defaultApplyEvents import com.agui.client.verify.verifyEvents import kotlinx.coroutines.* @@ -30,6 +31,15 @@ abstract class AbstractAgent( var state: State = config.initialState protected set + var rawEvents: List = emptyList() + protected set + + var customEvents: List = emptyList() + protected set + + var thinking: ThinkingTelemetryState? = null + protected set + val debug: Boolean = config.debug // Coroutine scope for agent lifecycle @@ -37,12 +47,27 @@ abstract class AbstractAgent( // Current run job for cancellation private var currentRunJob: Job? = null + + // Registered subscribers for lifecycle and event hooks + private val subscribers = mutableListOf() /** * Abstract method to be implemented by concrete agents. * Produces the event stream for the agent run. */ protected abstract fun run(input: RunAgentInput): Flow + + /** + * Registers an [AgentSubscriber] to receive lifecycle and event notifications. + */ + fun subscribe(subscriber: AgentSubscriber): AgentSubscription { + subscribers += subscriber + return object : AgentSubscription { + override fun unsubscribe() { + subscribers.remove(subscriber) + } + } + } /** * Main entry point to run the agent. @@ -53,21 +78,33 @@ abstract class AbstractAgent( * @throws CancellationException if the agent run is cancelled * @throws Exception if an unexpected error occurs during execution */ - suspend fun runAgent(parameters: RunAgentParameters? = null) { + suspend fun runAgent(parameters: RunAgentParameters? = null, subscriber: AgentSubscriber? = null) { agentId = agentId ?: generateId() val input = prepareRunAgentInput(parameters) - + messages = input.messages + state = input.state + val activeSubscribers = subscribers.toMutableList().apply { + subscriber?.let { add(it) } + } + + notifyRunInitialized(input, activeSubscribers) + currentRunJob = agentScope.launch { try { run(input) + .transformChunks(debug) .verifyEvents(debug) - .let { events -> apply(input, events) } - .let { states -> processApplyEvents(input, states) } + .let { events -> apply(input, events, activeSubscribers) } + .let { states -> processApplyEvents(input, states, activeSubscribers) } .catch { error -> - logger.e(error) { "Agent execution failed" } - onError(error) + val stopPropagation = notifyRunFailed(input, activeSubscribers, error) + if (!stopPropagation) { + logger.e(error) { "Agent execution failed" } + onError(error) + } } - .onCompletion { cause -> + .onCompletion { + notifyRunFinalized(input, activeSubscribers) onFinalize() } .collect() @@ -75,11 +112,14 @@ abstract class AbstractAgent( logger.d { "Agent run cancelled" } throw e } catch (e: Exception) { - logger.e(e) { "Unexpected error in agent run" } - onError(e) + val stopPropagation = notifyRunFailed(input, activeSubscribers, e) + if (!stopPropagation) { + logger.e(e) { "Unexpected error in agent run" } + onError(e) + } } } - + currentRunJob?.join() } @@ -112,30 +152,44 @@ abstract class AbstractAgent( * agent.runAgent(parameters) // Suspends until complete * ``` */ - fun runAgentObservable(input: RunAgentInput): Flow { + fun runAgentObservable(input: RunAgentInput, subscriber: AgentSubscriber? = null): Flow { agentId = agentId ?: generateId() - + messages = input.messages + state = input.state + val activeSubscribers = subscribers.toMutableList().apply { + subscriber?.let { add(it) } + } + return run(input) + .transformChunks(debug) .verifyEvents(debug) + .onStart { + notifyRunInitialized(input, activeSubscribers) + } .onEach { event -> - // Run the full state management pipeline on each individual event - // as a side effect, preserving the original event stream try { - flowOf(event) // Create single-event flow - .let { events -> apply(input, events) } - .let { states -> processApplyEvents(input, states) } - .collect() // Consume the state updates + val updatedInput = input.copy( + state = state, + messages = messages.toList() + ) + flowOf(event) + .let { events -> apply(updatedInput, events, activeSubscribers) } + .let { states -> processApplyEvents(input, states, activeSubscribers) } + .collect() } catch (e: Exception) { logger.w(e) { "Error in state management pipeline for event: ${event.eventType}" } - // Don't rethrow - state management errors shouldn't break the event stream } } .catch { error -> - logger.e(error) { "Agent execution failed" } - onError(error) + val stopPropagation = notifyRunFailed(input, activeSubscribers, error) + if (!stopPropagation) { + logger.e(error) { "Agent execution failed" } + onError(error) + } throw error } - .onCompletion { cause -> + .onCompletion { + notifyRunFinalized(input, activeSubscribers) onFinalize() } } @@ -148,9 +202,9 @@ abstract class AbstractAgent( * @return Flow stream of events emitted during agent execution * @see runAgentObservable(RunAgentInput) for the full input version */ - fun runAgentObservable(parameters: RunAgentParameters? = null): Flow { + fun runAgentObservable(parameters: RunAgentParameters? = null, subscriber: AgentSubscriber? = null): Flow { val input = prepareRunAgentInput(parameters) - return runAgentObservable(input) + return runAgentObservable(input, subscriber) } /** @@ -173,11 +227,12 @@ abstract class AbstractAgent( */ protected open fun apply( input: RunAgentInput, - events: Flow + events: Flow, + subscribers: List = emptyList() ): Flow { - return defaultApplyEvents(input, events) + return defaultApplyEvents(input, events, agent = this, subscribers = subscribers) } - + /** * Processes state updates from the apply stage. * Updates the agent's internal state (messages and state) based on the state changes. @@ -189,21 +244,50 @@ abstract class AbstractAgent( */ protected open fun processApplyEvents( input: RunAgentInput, - states: Flow + states: Flow, + subscribers: List = emptyList() ): Flow { return states.onEach { agentState -> - agentState.messages?.let { + var messagesChanged = false + var stateChanged = false + + agentState.messages?.let { messages = it - if (debug) { - logger.d { "Updated messages: ${it.size} messages" } + messagesChanged = true + val preview = it.joinToString(" | ") { msg -> + when (msg) { + is AssistantMessage -> "A:${msg.id}:${msg.content?.take(40)}" + is UserMessage -> "U:${msg.id}:${msg.content.take(40)}" + is SystemMessage -> "S:${msg.id}:${msg.content ?: ""}" + is ToolMessage -> "T:${msg.id}:${msg.content.take(40)}" + else -> "${msg::class.simpleName}:${msg.id}" + } } + logger.d { "Updated messages(${it.size}): $preview" } } - agentState.state?.let { + agentState.state?.let { state = it + stateChanged = true if (debug) { logger.d { "Updated state" } } } + agentState.rawEvents?.let { + rawEvents = it + } + agentState.customEvents?.let { + customEvents = it + } + agentState.thinking?.let { + thinking = it + } + + if (messagesChanged) { + notifyMessagesChanged(subscribers, input) + } + if (stateChanged) { + notifyStateChanged(subscribers, input) + } } } @@ -274,7 +358,130 @@ abstract class AbstractAgent( currentRunJob?.cancel() agentScope.cancel() } - + + private suspend fun notifyRunInitialized( + input: RunAgentInput, + subscribers: List + ) { + if (subscribers.isEmpty()) return + + val mutation = runSubscribersWithMutation(subscribers, messages, state) { subscriber, msgSnapshot, stateSnapshot -> + subscriber.onRunInitialized( + AgentSubscriberParams( + messages = msgSnapshot, + state = stateSnapshot, + agent = this, + input = input + ) + ) + } + applyMutationToAgent(mutation, input, subscribers) + } + + private suspend fun notifyRunFailed( + input: RunAgentInput, + subscribers: List, + error: Throwable + ): Boolean { + if (subscribers.isEmpty()) return false + + val mutation = runSubscribersWithMutation(subscribers, messages, state) { subscriber, msgSnapshot, stateSnapshot -> + subscriber.onRunFailed( + AgentRunFailureParams( + error = error, + messages = msgSnapshot, + state = stateSnapshot, + agent = this, + input = input + ) + ) + } + applyMutationToAgent(mutation, input, subscribers) + return mutation.stopPropagation + } + + private suspend fun notifyRunFinalized( + input: RunAgentInput, + subscribers: List + ) { + if (subscribers.isEmpty()) return + + val mutation = runSubscribersWithMutation(subscribers, messages, state) { subscriber, msgSnapshot, stateSnapshot -> + subscriber.onRunFinalized( + AgentSubscriberParams( + messages = msgSnapshot, + state = stateSnapshot, + agent = this, + input = input + ) + ) + } + applyMutationToAgent(mutation, input, subscribers) + } + + private suspend fun applyMutationToAgent( + mutation: AgentStateMutation, + input: RunAgentInput, + subscribers: List + ) { + var messagesChanged = false + var stateChanged = false + + mutation.messages?.let { + messages = it.toList() + messagesChanged = true + } + mutation.state?.let { + state = it + stateChanged = true + } + + if (messagesChanged) { + notifyMessagesChanged(subscribers, input) + } + if (stateChanged) { + notifyStateChanged(subscribers, input) + } + } + + private suspend fun notifyMessagesChanged( + subscribers: List, + input: RunAgentInput + ) { + if (subscribers.isEmpty()) return + + val stateSnapshot = state + for (subscriber in subscribers) { + subscriber.onMessagesChanged( + AgentStateChangedParams( + messages = messages.deepCopyMessages(), + state = stateSnapshot, + agent = this, + input = input + ) + ) + } + } + + private suspend fun notifyStateChanged( + subscribers: List, + input: RunAgentInput + ) { + if (subscribers.isEmpty()) return + + val stateSnapshot = state + for (subscriber in subscribers) { + subscriber.onStateChanged( + AgentStateChangedParams( + messages = messages.deepCopyMessages(), + state = stateSnapshot, + agent = this, + input = input + ) + ) + } + } + companion object { private fun generateId(): String = "id_${Clock.System.now().toEpochMilliseconds()}" } @@ -341,12 +548,32 @@ data class RunAgentParameters( /** * Represents the transformed agent state. - * Contains the current state of the agent including messages and state data. + * Contains the current state of the agent including messages, thinking telemetry, + * state data, and auxiliary events. * * @property messages Optional list of messages in the current conversation + * @property thinking Optional thinking telemetry describing the agent's internal reasoning stream * @property state Optional state object containing agent-specific data + * @property rawEvents Optional list of RAW events that have been received + * @property customEvents Optional list of CUSTOM events that have been received */ data class AgentState( val messages: List? = null, - val state: State? = null -) \ No newline at end of file + val thinking: ThinkingTelemetryState? = null, + val state: State? = null, + val rawEvents: List? = null, + val customEvents: List? = null +) + +/** + * Represents the agent's thinking telemetry stream. + * + * @property isThinking Whether the agent is actively thinking + * @property title Optional title or description supplied with the thinking step + * @property messages Ordered list of thinking text messages emitted by the agent + */ +data class ThinkingTelemetryState( + val isThinking: Boolean, + val title: String? = null, + val messages: List = emptyList() +) diff --git a/sdks/community/kotlin/library/client/src/commonMain/kotlin/com/agui/client/agent/AgentSubscriber.kt b/sdks/community/kotlin/library/client/src/commonMain/kotlin/com/agui/client/agent/AgentSubscriber.kt new file mode 100644 index 000000000..3b60d31b6 --- /dev/null +++ b/sdks/community/kotlin/library/client/src/commonMain/kotlin/com/agui/client/agent/AgentSubscriber.kt @@ -0,0 +1,139 @@ +package com.agui.client.agent + +import com.agui.core.types.* + +/** + * Represents a mutation requested by an [AgentSubscriber]. + * + * Subscribers can replace the pending message collection, update state, or + * stop propagation so the default handlers skip their own processing. + */ +data class AgentStateMutation( + val messages: List? = null, + val state: State? = null, + val stopPropagation: Boolean = false +) + +/** + * Common parameters shared across subscriber callbacks. + */ +data class AgentSubscriberParams( + val messages: List, + val state: State, + val agent: AbstractAgent, + val input: RunAgentInput +) + +/** + * Parameters delivered when subscribers observe a raw event. + */ +data class AgentEventParams( + val event: BaseEvent, + val messages: List, + val state: State, + val agent: AbstractAgent, + val input: RunAgentInput +) + +/** + * Parameters passed when the run fails with an exception. + */ +data class AgentRunFailureParams( + val error: Throwable, + val messages: List, + val state: State, + val agent: AbstractAgent, + val input: RunAgentInput +) + +/** + * Parameters used when notifying subscribers of state or message changes. + */ +data class AgentStateChangedParams( + val messages: List, + val state: State, + val agent: AbstractAgent, + val input: RunAgentInput +) + +/** + * Subscription handle returned by [AbstractAgent.subscribe]. + */ +interface AgentSubscription { + fun unsubscribe() +} + +/** + * Contract for observers that want to intercept lifecycle or event updates. + * + * All callbacks are optional. Returning [AgentStateMutation.stopPropagation] = true + * prevents the default handlers from mutating the agent state for that event. + */ +interface AgentSubscriber { + suspend fun onRunInitialized(params: AgentSubscriberParams): AgentStateMutation? = null + + suspend fun onRunFailed(params: AgentRunFailureParams): AgentStateMutation? = null + + suspend fun onRunFinalized(params: AgentSubscriberParams): AgentStateMutation? = null + + suspend fun onEvent(params: AgentEventParams): AgentStateMutation? = null + + suspend fun onMessagesChanged(params: AgentStateChangedParams) {} + + suspend fun onStateChanged(params: AgentStateChangedParams) {} +} + +internal fun Message.deepCopy(): Message = when (this) { + is DeveloperMessage -> this.copy() + is SystemMessage -> this.copy() + is AssistantMessage -> this.copy( + content = this.content, + name = this.name, + toolCalls = this.toolCalls?.map { it.copy(function = it.function.copy()) } + ) + is UserMessage -> this.copy() + is ToolMessage -> this.copy() +} + +internal fun List.deepCopyMessages(): List = map { it.deepCopy() } + +/** + * Executes subscribers sequentially, feeding the latest message/state snapshot. + */ +suspend fun runSubscribersWithMutation( + subscribers: List, + messages: List, + state: State, + executor: suspend (AgentSubscriber, List, State) -> AgentStateMutation? +): AgentStateMutation { + var currentMessages = messages + var currentState = state + var aggregatedMessages: List? = null + var aggregatedState: State? = null + var stopPropagation = false + + for (subscriber in subscribers) { + val mutation = executor(subscriber, currentMessages.deepCopyMessages(), currentState) + + if (mutation != null) { + mutation.messages?.let { + currentMessages = it + aggregatedMessages = it + } + mutation.state?.let { + currentState = it + aggregatedState = it + } + if (mutation.stopPropagation) { + stopPropagation = true + break + } + } + } + + return AgentStateMutation( + messages = aggregatedMessages, + state = aggregatedState, + stopPropagation = stopPropagation + ) +} diff --git a/sdks/community/kotlin/library/client/src/commonMain/kotlin/com/agui/client/chunks/ChunkTransform.kt b/sdks/community/kotlin/library/client/src/commonMain/kotlin/com/agui/client/chunks/ChunkTransform.kt index 861e82c92..cdb6a5148 100644 --- a/sdks/community/kotlin/library/client/src/commonMain/kotlin/com/agui/client/chunks/ChunkTransform.kt +++ b/sdks/community/kotlin/library/client/src/commonMain/kotlin/com/agui/client/chunks/ChunkTransform.kt @@ -1,190 +1,253 @@ package com.agui.client.chunks import com.agui.core.types.* -import kotlinx.coroutines.flow.* +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.collect +import kotlinx.coroutines.flow.flow +import kotlinx.serialization.json.JsonElement import co.touchlab.kermit.Logger private val logger = Logger.withTag("ChunkTransform") +private enum class ChunkMode { TEXT, TOOL } + +private data class TextState( + val messageId: String, + var fromChunk: Boolean +) + +private data class ToolState( + val toolCallId: String, + var fromChunk: Boolean +) + /** - * Transforms chunk events (TEXT_MESSAGE_CHUNK, TOOL_CALL_CHUNK) into structured event sequences. - * - * This transform handles automatic start/end sequences for chunk events: - * - TEXT_MESSAGE_CHUNK events are converted into TEXT_MESSAGE_START/CONTENT/END sequences - * - TOOL_CALL_CHUNK events are converted into TOOL_CALL_START/ARGS/END sequences - * - * The transform maintains state to track active sequences and only starts new sequences - * when no active sequence exists or when IDs change. This allows chunk events to - * integrate seamlessly with existing message/tool call flows. - * - * @param debug Whether to enable debug logging - * @return Flow with chunk events transformed into structured sequences + * Converts chunk events (`TEXT_MESSAGE_CHUNK`, `TOOL_CALL_CHUNK`) into structured + * protocol sequences. Behaviour matches the TypeScript SDK so downstream processing + * can assume standard start/content/end triads regardless of the upstream stream shape. */ fun Flow.transformChunks(debug: Boolean = false): Flow { - // State tracking for active sequences - var mode: String? = null // "text" or "tool" - var textMessageId: String? = null - var toolCallId: String? = null - var toolCallName: String? = null - var parentMessageId: String? = null - - return transform { event -> - if (debug) { - logger.d { "[CHUNK_TRANSFORM]: Processing ${event.eventType}" } + var mode: ChunkMode? = null + var textState: TextState? = null + var toolState: ToolState? = null + + suspend fun closeText( + timestamp: Long? = null, + rawEvent: JsonElement? = null, + emit: suspend (BaseEvent) -> Unit + ) { + val state = textState + if (state != null) { + if (state.fromChunk) { + val event = TextMessageEndEvent( + messageId = state.messageId, + timestamp = timestamp, + rawEvent = rawEvent + ) + if (debug) { + logger.d { "[CHUNK_TRANSFORM]: Emit TEXT_MESSAGE_END (${state.messageId})" } + } + emit(event) + } + } else if (debug) { + logger.d { "[CHUNK_TRANSFORM]: No text state to close" } } - - when (event) { - is TextMessageChunkEvent -> { - val messageId = event.messageId - val delta = event.delta - - // Determine if we need to start a new text message - val needsNewTextMessage = mode != "text" || - (messageId != null && messageId != textMessageId) - - if (needsNewTextMessage) { - if (debug) { - logger.d { "[CHUNK_TRANSFORM]: Starting new text message (id: $messageId)" } - } - - // Close any existing tool call sequence first - if (mode == "tool" && toolCallId != null) { - emit(ToolCallEndEvent( - toolCallId = toolCallId!!, - timestamp = event.timestamp, - rawEvent = event.rawEvent - )) + textState = null + if (mode == ChunkMode.TEXT) { + mode = null + } + } + + suspend fun closeTool( + timestamp: Long? = null, + rawEvent: JsonElement? = null, + emit: suspend (BaseEvent) -> Unit + ) { + val state = toolState + if (state != null) { + if (state.fromChunk) { + val event = ToolCallEndEvent( + toolCallId = state.toolCallId, + timestamp = timestamp, + rawEvent = rawEvent + ) + if (debug) { + logger.d { "[CHUNK_TRANSFORM]: Emit TOOL_CALL_END (${state.toolCallId})" } + } + emit(event) + } + } else if (debug) { + logger.d { "[CHUNK_TRANSFORM]: No tool state to close" } + } + toolState = null + if (mode == ChunkMode.TOOL) { + mode = null + } + } + + suspend fun closePending( + timestamp: Long? = null, + rawEvent: JsonElement? = null, + emit: suspend (BaseEvent) -> Unit + ) { + when (mode) { + ChunkMode.TEXT -> closeText(timestamp, rawEvent, emit) + ChunkMode.TOOL -> closeTool(timestamp, rawEvent, emit) + null -> Unit + } + } + + return flow { + collect { event -> + if (debug) { + logger.d { "[CHUNK_TRANSFORM]: Processing ${event.eventType}" } + } + + when (event) { + is TextMessageChunkEvent -> { + val messageId = event.messageId + val delta = event.delta + + val needsNewMessage = mode != ChunkMode.TEXT || + (messageId != null && messageId != textState?.messageId) + + if (needsNewMessage) { + closePending(event.timestamp, event.rawEvent, this@flow::emit) + + if (messageId == null) { + throw IllegalArgumentException("First TEXT_MESSAGE_CHUNK must provide messageId") + } + + emit( + TextMessageStartEvent( + messageId = messageId, + role = event.role ?: Role.ASSISTANT, + timestamp = event.timestamp, + rawEvent = event.rawEvent + ) + ) + + mode = ChunkMode.TEXT + textState = TextState(messageId, fromChunk = true) } - - // Require messageId for the first chunk of a new message - if (messageId == null) { - throw IllegalArgumentException("messageId is required for TEXT_MESSAGE_CHUNK when starting a new text message") + + val activeMessageId = textState?.messageId ?: messageId + ?: throw IllegalArgumentException("Cannot emit TEXT_MESSAGE_CONTENT without messageId") + + if (!delta.isNullOrEmpty()) { + emit( + TextMessageContentEvent( + messageId = activeMessageId, + delta = delta, + timestamp = event.timestamp, + rawEvent = event.rawEvent + ) + ) } - - // Start new text message - emit(TextMessageStartEvent( - messageId = messageId, - timestamp = event.timestamp, - rawEvent = event.rawEvent - )) - - mode = "text" - textMessageId = messageId } - - // Generate content event if delta is present - if (delta != null) { - val currentMessageId = textMessageId ?: messageId - if (currentMessageId == null) { - throw IllegalArgumentException("Cannot generate TEXT_MESSAGE_CONTENT without a messageId") + + is ToolCallChunkEvent -> { + val toolId = event.toolCallId + val toolName = event.toolCallName + val delta = event.delta + + val needsNewToolCall = mode != ChunkMode.TOOL || + (toolId != null && toolId != toolState?.toolCallId) + + if (needsNewToolCall) { + closePending(event.timestamp, event.rawEvent, this@flow::emit) + + if (toolId == null || toolName == null) { + throw IllegalArgumentException("First TOOL_CALL_CHUNK must provide toolCallId and toolCallName") + } + + emit( + ToolCallStartEvent( + toolCallId = toolId, + toolCallName = toolName, + parentMessageId = event.parentMessageId, + timestamp = event.timestamp, + rawEvent = event.rawEvent + ) + ) + + mode = ChunkMode.TOOL + toolState = ToolState(toolId, fromChunk = true) } - - emit(TextMessageContentEvent( - messageId = currentMessageId, - delta = delta, - timestamp = event.timestamp, - rawEvent = event.rawEvent - )) - } - } - - is ToolCallChunkEvent -> { - val toolId = event.toolCallId - val toolName = event.toolCallName - val delta = event.delta - val parentMsgId = event.parentMessageId - - // Determine if we need to start a new tool call - val needsNewToolCall = mode != "tool" || - (toolId != null && toolId != toolCallId) - - if (needsNewToolCall) { - if (debug) { - logger.d { "[CHUNK_TRANSFORM]: Starting new tool call (id: $toolId, name: $toolName)" } + + val activeToolCallId = toolState?.toolCallId ?: toolId + ?: throw IllegalArgumentException("Cannot emit TOOL_CALL_ARGS without toolCallId") + + if (!delta.isNullOrEmpty()) { + emit( + ToolCallArgsEvent( + toolCallId = activeToolCallId, + delta = delta, + timestamp = event.timestamp, + rawEvent = event.rawEvent + ) + ) } - - // Close any existing text message sequence first - if (mode == "text" && textMessageId != null) { - emit(TextMessageEndEvent( - messageId = textMessageId!!, - timestamp = event.timestamp, - rawEvent = event.rawEvent - )) + } + + is TextMessageStartEvent -> { + closePending(event.timestamp, event.rawEvent, this@flow::emit) + mode = ChunkMode.TEXT + textState = TextState(event.messageId, fromChunk = false) + emit(event) + } + + is TextMessageContentEvent -> { + mode = ChunkMode.TEXT + textState = TextState(event.messageId, fromChunk = false) + emit(event) + } + + is TextMessageEndEvent -> { + textState = null + if (mode == ChunkMode.TEXT) { + mode = null } - - // Require toolCallId and toolCallName for the first chunk of a new tool call - if (toolId == null || toolName == null) { - throw IllegalArgumentException("toolCallId and toolCallName are required for TOOL_CALL_CHUNK when starting a new tool call") + emit(event) + } + + is ToolCallStartEvent -> { + closePending(event.timestamp, event.rawEvent, this@flow::emit) + mode = ChunkMode.TOOL + toolState = ToolState(event.toolCallId, fromChunk = false) + emit(event) + } + + is ToolCallArgsEvent -> { + mode = ChunkMode.TOOL + if (toolState?.toolCallId == event.toolCallId) { + toolState?.fromChunk = false + } else { + toolState = ToolState(event.toolCallId, fromChunk = false) } - - // Start new tool call - emit(ToolCallStartEvent( - toolCallId = toolId, - toolCallName = toolName, - parentMessageId = parentMsgId, - timestamp = event.timestamp, - rawEvent = event.rawEvent - )) - - mode = "tool" - toolCallId = toolId - toolCallName = toolName - parentMessageId = parentMsgId + emit(event) } - - // Generate args event if delta is present - if (delta != null) { - val currentToolCallId = toolCallId ?: toolId - if (currentToolCallId == null) { - throw IllegalArgumentException("Cannot generate TOOL_CALL_ARGS without a toolCallId") + + is ToolCallEndEvent -> { + toolState = null + if (mode == ChunkMode.TOOL) { + mode = null } - - emit(ToolCallArgsEvent( - toolCallId = currentToolCallId, - delta = delta, - timestamp = event.timestamp, - rawEvent = event.rawEvent - )) + emit(event) } - } - - // Track state changes from regular events to maintain consistency - is TextMessageStartEvent -> { - mode = "text" - textMessageId = event.messageId - emit(event) - } - - is TextMessageEndEvent -> { - if (mode == "text" && textMessageId == event.messageId) { - mode = null - textMessageId = null + + is RawEvent -> { + // RAW passthrough without closing chunk state + emit(event) } - emit(event) - } - - is ToolCallStartEvent -> { - mode = "tool" - toolCallId = event.toolCallId - toolCallName = event.toolCallName - parentMessageId = event.parentMessageId - emit(event) - } - - is ToolCallEndEvent -> { - if (mode == "tool" && toolCallId == event.toolCallId) { - mode = null - toolCallId = null - toolCallName = null - parentMessageId = null + + else -> { + closePending(event.timestamp, event.rawEvent, this@flow::emit) + emit(event) } - emit(event) - } - - else -> { - // Pass through all other events unchanged - emit(event) } } + + closePending(null, null, this@flow::emit) } -} \ No newline at end of file +} diff --git a/sdks/community/kotlin/library/client/src/commonMain/kotlin/com/agui/client/state/DefaultApplyEvents.kt b/sdks/community/kotlin/library/client/src/commonMain/kotlin/com/agui/client/state/DefaultApplyEvents.kt index a1c0c5bea..55057426e 100644 --- a/sdks/community/kotlin/library/client/src/commonMain/kotlin/com/agui/client/state/DefaultApplyEvents.kt +++ b/sdks/community/kotlin/library/client/src/commonMain/kotlin/com/agui/client/state/DefaultApplyEvents.kt @@ -1,115 +1,196 @@ package com.agui.client.state +import com.agui.client.agent.AbstractAgent +import com.agui.client.agent.AgentEventParams import com.agui.client.agent.AgentState +import com.agui.client.agent.AgentStateMutation +import com.agui.client.agent.AgentSubscriber +import com.agui.client.agent.ThinkingTelemetryState +import com.agui.client.agent.runSubscribersWithMutation import com.agui.core.types.* import com.reidsync.kxjsonpatch.JsonPatch -import kotlinx.coroutines.flow.* -import kotlinx.serialization.json.* +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.transform import co.touchlab.kermit.Logger private val logger = Logger.withTag("DefaultApplyEvents") -/** - * Configuration for predictive state updates during tool execution. - * - * This class defines how to update the agent state based on incoming tool arguments - * before the tool execution is complete. This allows for optimistic UI updates - * and improved user experience. - * - * @param state_key The JSON pointer path in the state to update - * @param tool The name of the tool whose arguments should trigger state updates - * @param tool_argument Optional specific argument name to extract from tool arguments. - * If null, the entire arguments object is used. - */ -data class PredictStateValue( - val state_key: String, - val tool: String, - val tool_argument: String? = null -) - -/** - * Default implementation of event application logic with comprehensive event handling. - * - * This function transforms a stream of AG-UI protocol events into a stream of agent states. - * It handles all standard event types and maintains consistency between messages and state. - * - * Key features: - * - Handles all AG-UI protocol events (text messages, tool calls, state changes) - * - Applies JSON Patch operations for state deltas - * - Supports predictive state updates during tool execution - * - Maintains message history and tool call tracking - * - Provides error handling and recovery for state operations - * - Integrates with custom state change handlers - * - * Event Processing: - * - Text message events: Build and update assistant messages incrementally - * - Tool call events: Track tool calls and their arguments as they stream in - * - State events: Apply snapshots and deltas using RFC 6902 JSON Patch - * - Custom events: Handle special events like predictive state configuration - * - * @param input The initial agent input containing messages, state, and configuration - * @param events Stream of events from the agent to process - * @param stateHandler Optional handler for state change notifications and error handling - * @return Flow of agent states as events are processed - * - * @see AgentState - * @see BaseEvent - * @see StateChangeHandler - */ +private fun createStreamingMessage(messageId: String, role: Role): Message = when (role) { + Role.DEVELOPER -> DeveloperMessage(id = messageId, content = "") + Role.SYSTEM -> SystemMessage(id = messageId, content = "") + Role.ASSISTANT -> AssistantMessage(id = messageId, content = "") + Role.USER -> UserMessage(id = messageId, content = "") + Role.TOOL -> ToolMessage(id = messageId, content = "", toolCallId = messageId) +} + +private fun Message.appendDelta(delta: String): Message = when (this) { + is DeveloperMessage -> copy(content = this.content + delta) + is SystemMessage -> copy(content = (this.content ?: "") + delta) + is AssistantMessage -> copy(content = (this.content ?: "") + delta) + is UserMessage -> copy(content = this.content + delta) + else -> this +} + fun defaultApplyEvents( input: RunAgentInput, events: Flow, - stateHandler: StateChangeHandler? = null + stateHandler: StateChangeHandler? = null, + agent: AbstractAgent? = null, + subscribers: List = emptyList() ): Flow { - // Mutable state copies val messages = input.messages.toMutableList() var state = input.state - var predictState: List? = null - + val rawEvents = mutableListOf() + val customEvents = mutableListOf() + var thinkingActive = false + var thinkingVisible = false + var thinkingTitle: String? = null + val thinkingMessages = mutableListOf() + var thinkingBuffer: StringBuilder? = null + var initialMessagesEmitted = false + + logger.d { + "defaultApplyEvents start: initial messages=${messages.joinToString { "${it.messageRole}:${it.id}" }} state=$state" + } + + fun finalizeThinkingMessage() { + thinkingBuffer?.toString()?.takeIf { it.isNotEmpty() }?.let { + thinkingMessages.add(it) + } + thinkingBuffer = null + } + + fun currentThinkingState(): ThinkingTelemetryState? { + val inProgress = thinkingBuffer?.toString() + val snapshot = mutableListOf().apply { + addAll(thinkingMessages) + inProgress?.takeIf { it.isNotEmpty() }?.let { add(it) } + } + val active = thinkingActive || (inProgress?.isNotEmpty() == true) + if (!thinkingVisible && !active && snapshot.isEmpty() && thinkingTitle == null) { + return null + } + return ThinkingTelemetryState( + isThinking = active, + title = thinkingTitle, + messages = snapshot + ) + } + + suspend fun dispatchToSubscribers(event: BaseEvent): AgentStateMutation { + if (agent == null || subscribers.isEmpty()) { + return AgentStateMutation() + } + return runSubscribersWithMutation(subscribers, messages.toList(), state) { subscriber, msgSnapshot, stateSnapshot -> + subscriber.onEvent( + AgentEventParams( + event = event, + messages = msgSnapshot, + state = stateSnapshot, + agent = agent, + input = input + ) + ) + } + } + + fun applySubscriberMutation(mutation: AgentStateMutation): Pair { + var messagesUpdated = false + var stateUpdated = false + mutation.messages?.let { + messages.clear() + messages.addAll(it) + messagesUpdated = true + } + mutation.state?.let { + state = it + stateUpdated = true + } + return messagesUpdated to stateUpdated + } + return events.transform { event -> + if (!initialMessagesEmitted && messages.isNotEmpty()) { + emit(AgentState(messages = messages.toList())) + initialMessagesEmitted = true + } + + var emitted = false + var subscriberMessagesUpdated = false + var subscriberStateUpdated = false + + if (agent != null && subscribers.isNotEmpty()) { + val mutation = dispatchToSubscribers(event) + val (msgUpdated, stateUpdated) = applySubscriberMutation(mutation) + subscriberMessagesUpdated = subscriberMessagesUpdated || msgUpdated + subscriberStateUpdated = subscriberStateUpdated || stateUpdated + if (mutation.stopPropagation) { + if (subscriberMessagesUpdated || subscriberStateUpdated) { + emit( + AgentState( + messages = if (subscriberMessagesUpdated) messages.toList() else null, + state = if (subscriberStateUpdated) state else null + ) + ) + emitted = true + } + return@transform + } + } + when (event) { is TextMessageStartEvent -> { - messages.add( - AssistantMessage( - id = event.messageId, - content = "" - ) - ) + val role = event.role + messages.add(createStreamingMessage(event.messageId, role)) + logger.d { + "Added streaming message start id=${event.messageId} role=$role; messages=${messages.joinToString { it.id }}" + } emit(AgentState(messages = messages.toList())) + emitted = true } - + is TextMessageContentEvent -> { - val lastMessage = messages.lastOrNull() as? AssistantMessage - if (lastMessage != null && lastMessage.id == event.messageId) { - messages[messages.lastIndex] = lastMessage.copy( - content = (lastMessage.content ?: "") + event.delta - ) + val index = messages.indexOfFirst { it.id == event.messageId } + if (index >= 0) { + messages[index] = messages[index].appendDelta(event.delta) + logger.d { + val updated = messages[index] + val preview = when (updated) { + is AssistantMessage -> updated.content + is UserMessage -> updated.content + is SystemMessage -> updated.content ?: "" + is DeveloperMessage -> updated.content + else -> "" + } + "Updated message ${event.messageId} content='${preview?.take(80)}'" + } emit(AgentState(messages = messages.toList())) + emitted = true + } else { + logger.e { "Received content for unknown message ${event.messageId}; current ids=${messages.joinToString { it.id }}. Dropping delta: '${event.delta.take(80)}'" } } } - + is TextMessageEndEvent -> { // No state update needed } - + is ToolCallStartEvent -> { - val targetMessage = when { - event.parentMessageId != null && - messages.lastOrNull()?.id == event.parentMessageId -> { - messages.last() as? AssistantMessage - } - else -> null - } - - if (targetMessage != null) { - val updatedCalls = (targetMessage.toolCalls ?: emptyList()) + ToolCall( + val parentIndex = event.parentMessageId?.let { id -> + messages.indexOfLast { it.id == id && it is AssistantMessage } + } ?: messages.indexOfLast { it is AssistantMessage } + + val targetAssistant = parentIndex.takeIf { it >= 0 }?.let { messages[it] as AssistantMessage } + + if (targetAssistant != null) { + val updatedCalls = (targetAssistant.toolCalls ?: emptyList()) + ToolCall( id = event.toolCallId, function = FunctionCall( name = event.toolCallName, arguments = "" ) ) - messages[messages.lastIndex] = targetMessage.copy(toolCalls = updatedCalls) + messages[parentIndex] = targetAssistant.copy(toolCalls = updatedCalls) } else { messages.add( AssistantMessage( @@ -128,145 +209,170 @@ fun defaultApplyEvents( ) } emit(AgentState(messages = messages.toList())) + emitted = true } - + is ToolCallArgsEvent -> { - val lastMessage = messages.lastOrNull() as? AssistantMessage - val toolCalls = lastMessage?.toolCalls?.toMutableList() - val lastToolCall = toolCalls?.lastOrNull() - - if (lastToolCall != null && lastToolCall.id == event.toolCallId) { - val updatedCall = lastToolCall.copy( - function = lastToolCall.function.copy( - arguments = lastToolCall.function.arguments + event.delta - ) - ) - toolCalls[toolCalls.lastIndex] = updatedCall - messages[messages.lastIndex] = lastMessage.copy(toolCalls = toolCalls) - - // Handle predictive state updates - var stateUpdated = false - predictState?.find { it.tool == updatedCall.function.name }?.let { config -> - try { - val newState = updatePredictiveState( - state, - updatedCall.function.arguments, - config + val messageIndex = messages.indexOfLast { message -> + (message as? AssistantMessage)?.toolCalls?.any { it.id == event.toolCallId } == true + } + if (messageIndex >= 0) { + val assistantMessage = messages[messageIndex] as AssistantMessage + val updatedCalls = assistantMessage.toolCalls?.map { toolCall -> + if (toolCall.id == event.toolCallId) { + toolCall.copy( + function = toolCall.function.copy( + arguments = toolCall.function.arguments + event.delta + ) ) - if (newState != null) { - state = newState - stateUpdated = true - } - } catch (e: Exception) { - logger.d { "Failed to update predictive state: ${e.message}" } + } else { + toolCall } } - - if (stateUpdated) { - emit(AgentState(messages = messages.toList(), state = state)) - } else { - emit(AgentState(messages = messages.toList())) - } - } else { - emit(AgentState(messages = messages.toList())) + messages[messageIndex] = assistantMessage.copy(toolCalls = updatedCalls) } + emit(AgentState(messages = messages.toList())) + emitted = true } - + is ToolCallEndEvent -> { // No state update needed } - + + is ToolCallResultEvent -> { + val toolMessage = ToolMessage( + id = event.messageId, + content = event.content, + toolCallId = event.toolCallId, + name = event.role + ) + messages.add(toolMessage) + emit(AgentState(messages = messages.toList())) + emitted = true + } + + is RunStartedEvent -> { + thinkingActive = false + thinkingVisible = false + thinkingTitle = null + thinkingMessages.clear() + thinkingBuffer = null + currentThinkingState()?.let { + emit(AgentState(thinking = it)) + emitted = true + } ?: run { + emit(AgentState(thinking = ThinkingTelemetryState(isThinking = false, title = null, messages = emptyList()))) + emitted = true + } + } + is StateSnapshotEvent -> { state = event.snapshot stateHandler?.onStateSnapshot(state) emit(AgentState(state = state)) + emitted = true } - + is StateDeltaEvent -> { try { - // Use JsonPatch library for proper patch application state = JsonPatch.apply(event.delta, state) stateHandler?.onStateDelta(event.delta) emit(AgentState(state = state)) + emitted = true } catch (e: Exception) { logger.e(e) { "Failed to apply state delta" } stateHandler?.onStateError(e, event.delta) } } - + is MessagesSnapshotEvent -> { messages.clear() messages.addAll(event.messages) emit(AgentState(messages = messages.toList())) + emitted = true + } + + is RawEvent -> { + rawEvents.add(event) + emit(AgentState(rawEvents = rawEvents.toList())) + emitted = true } - + is CustomEvent -> { - if (event.name == "PredictState") { - predictState = parsePredictState(event.value) + customEvents.add(event) + emit(AgentState(customEvents = customEvents.toList())) + emitted = true + } + + is ThinkingStartEvent -> { + thinkingActive = true + thinkingVisible = true + thinkingTitle = event.title + thinkingMessages.clear() + thinkingBuffer = null + currentThinkingState()?.let { + emit(AgentState(thinking = it)) + emitted = true + } + } + + is ThinkingEndEvent -> { + finalizeThinkingMessage() + thinkingActive = false + currentThinkingState()?.let { + emit(AgentState(thinking = it)) + emitted = true + } + } + + is ThinkingTextMessageStartEvent -> { + thinkingVisible = true + if (!thinkingActive) { + thinkingActive = true + } + finalizeThinkingMessage() + thinkingBuffer = StringBuilder() + currentThinkingState()?.let { + emit(AgentState(thinking = it)) + emitted = true } } - - is StepFinishedEvent -> { - // Reset predictive state after step is finished - predictState = null + + is ThinkingTextMessageContentEvent -> { + thinkingVisible = true + if (!thinkingActive) { + thinkingActive = true + } + if (thinkingBuffer == null) { + thinkingBuffer = StringBuilder() + } + thinkingBuffer!!.append(event.delta) + currentThinkingState()?.let { + emit(AgentState(thinking = it)) + emitted = true + } } - + + is ThinkingTextMessageEndEvent -> { + finalizeThinkingMessage() + currentThinkingState()?.let { + emit(AgentState(thinking = it)) + emitted = true + } + } + else -> { // Other events don't affect state } } - } -} -/** - * Parses predictive state configuration from a JSON element. - */ -private fun parsePredictState(value: JsonElement): List? { - return try { - value.jsonArray.map { element -> - val obj = element.jsonObject - PredictStateValue( - state_key = obj["state_key"]!!.jsonPrimitive.content, - tool = obj["tool"]!!.jsonPrimitive.content, - tool_argument = obj["tool_argument"]?.jsonPrimitive?.content + if (!emitted && (subscriberMessagesUpdated || subscriberStateUpdated)) { + emit( + AgentState( + messages = if (subscriberMessagesUpdated) messages.toList() else null, + state = if (subscriberStateUpdated) state else null + ) ) } - } catch (e: Exception) { - logger.d { "Failed to parse predictive state: ${e.message}" } - null } } - -/** - * Updates state based on tool arguments and predictive state configuration. - */ -private fun updatePredictiveState( - currentState: State, - toolArgs: String, - config: PredictStateValue -): State? { - return try { - // Try to parse the accumulated arguments - val parsedArgs = Json.parseToJsonElement(toolArgs).jsonObject - - val newValue = if (config.tool_argument != null) { - // Extract specific argument - parsedArgs[config.tool_argument] - } else { - // Use entire arguments object - parsedArgs - } - - if (newValue != null) { - // Create updated state - val stateObj = currentState.jsonObject.toMutableMap() - stateObj[config.state_key] = newValue - JsonObject(stateObj) - } else { - null - } - } catch (e: Exception) { - // Arguments not yet valid JSON, ignore - null - } -} \ No newline at end of file diff --git a/sdks/community/kotlin/library/client/src/commonMain/kotlin/com/agui/client/tools/ClientToolResponseHandler.kt b/sdks/community/kotlin/library/client/src/commonMain/kotlin/com/agui/client/tools/ClientToolResponseHandler.kt index c852b4940..894ddc88b 100644 --- a/sdks/community/kotlin/library/client/src/commonMain/kotlin/com/agui/client/tools/ClientToolResponseHandler.kt +++ b/sdks/community/kotlin/library/client/src/commonMain/kotlin/com/agui/client/tools/ClientToolResponseHandler.kt @@ -40,18 +40,13 @@ class ClientToolResponseHandler( messages = listOf(toolMessage) ) - // Send through HTTP agent + // Send through HTTP agent by executing a one-off run with the tool message payload. try { - httpAgent.runAgent(RunAgentParameters( - runId = input.runId, - tools = input.tools, - context = input.context, - forwardedProps = input.forwardedProps - )) + httpAgent.runAgentObservable(input).collect() logger.d { "Tool response sent successfully" } } catch (e: Exception) { logger.e(e) { "Failed to send tool response" } throw e } } -} \ No newline at end of file +} diff --git a/sdks/community/kotlin/library/client/src/commonTest/kotlin/com/agui/client/AgUiAgentToolsTest.kt b/sdks/community/kotlin/library/client/src/commonTest/kotlin/com/agui/client/AgUiAgentToolsTest.kt index fbc741e01..df3ea798f 100644 --- a/sdks/community/kotlin/library/client/src/commonTest/kotlin/com/agui/client/AgUiAgentToolsTest.kt +++ b/sdks/community/kotlin/library/client/src/commonTest/kotlin/com/agui/client/AgUiAgentToolsTest.kt @@ -12,58 +12,34 @@ import kotlin.test.* class AgUiAgentToolsTest { @Test - fun testToolsOnlysentOnFirstMessagePerThread() = runTest { + fun testToolsSentOnEveryMessagePerThread() = runTest { // Create a mock agent that captures the tools sent in each request val mockAgent = MockAgentWithToolsTracking() val thread1 = "thread_1" val thread2 = "thread_2" - // First message on thread1 - should include tools + // Messages on thread1 - each should include tools mockAgent.sendMessage("First message", thread1).toList() assertTrue(mockAgent.lastRequestHadTools, "First message on thread1 should include tools") assertEquals(2, mockAgent.lastToolsCount, "Should have 2 tools") - - // Second message on thread1 - should NOT include tools + mockAgent.sendMessage("Second message", thread1).toList() - assertFalse(mockAgent.lastRequestHadTools, "Second message on thread1 should not include tools") - assertEquals(0, mockAgent.lastToolsCount, "Should have 0 tools") - - // Third message on thread1 - should still NOT include tools + assertTrue(mockAgent.lastRequestHadTools, "Second message on thread1 should include tools") + assertEquals(2, mockAgent.lastToolsCount, "Should have 2 tools") + mockAgent.sendMessage("Third message", thread1).toList() - assertFalse(mockAgent.lastRequestHadTools, "Third message on thread1 should not include tools") - assertEquals(0, mockAgent.lastToolsCount, "Should have 0 tools") - - // First message on thread2 - should include tools (different thread) + assertTrue(mockAgent.lastRequestHadTools, "Third message on thread1 should include tools") + assertEquals(2, mockAgent.lastToolsCount, "Should have 2 tools") + + // Messages on thread2 should also include tools every time mockAgent.sendMessage("First message on thread2", thread2).toList() assertTrue(mockAgent.lastRequestHadTools, "First message on thread2 should include tools") assertEquals(2, mockAgent.lastToolsCount, "Should have 2 tools") - - // Second message on thread2 - should NOT include tools + mockAgent.sendMessage("Second message on thread2", thread2).toList() - assertFalse(mockAgent.lastRequestHadTools, "Second message on thread2 should not include tools") - assertEquals(0, mockAgent.lastToolsCount, "Should have 0 tools") - } - - @Test - fun testClearThreadToolsTracking() = runTest { - val mockAgent = MockAgentWithToolsTracking() - val threadId = "test_thread" - - // First message - should include tools - mockAgent.sendMessage("First message", threadId).toList() - assertTrue(mockAgent.lastRequestHadTools, "First message should include tools") - - // Second message - should NOT include tools - mockAgent.sendMessage("Second message", threadId).toList() - assertFalse(mockAgent.lastRequestHadTools, "Second message should not include tools") - - // Clear tracking - mockAgent.clearThreadToolsTracking() - - // Next message should include tools again (tracking was cleared) - mockAgent.sendMessage("Message after clear", threadId).toList() - assertTrue(mockAgent.lastRequestHadTools, "Message after clearing should include tools again") + assertTrue(mockAgent.lastRequestHadTools, "Second message on thread2 should include tools") + assertEquals(2, mockAgent.lastToolsCount, "Should have 2 tools") } @Test @@ -149,4 +125,4 @@ class AgUiAgentToolsTest { return ToolExecutionResult.success(JsonPrimitive("Test 2")) } } -} \ No newline at end of file +} diff --git a/sdks/community/kotlin/library/client/src/commonTest/kotlin/com/agui/client/agent/AbstractAgentTest.kt b/sdks/community/kotlin/library/client/src/commonTest/kotlin/com/agui/client/agent/AbstractAgentTest.kt new file mode 100644 index 000000000..e04dd9ecf --- /dev/null +++ b/sdks/community/kotlin/library/client/src/commonTest/kotlin/com/agui/client/agent/AbstractAgentTest.kt @@ -0,0 +1,153 @@ +package com.agui.client.agent + +import com.agui.core.types.BaseEvent +import com.agui.core.types.Role +import com.agui.core.types.RunAgentInput +import com.agui.core.types.RunFinishedEvent +import com.agui.core.types.RunStartedEvent +import com.agui.core.types.TextMessageContentEvent +import com.agui.core.types.TextMessageEndEvent +import com.agui.core.types.TextMessageStartEvent +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertTrue +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.flow +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.test.runTest + +class AbstractAgentTest { + + @Test + fun runAgent_notifiesSubscribersAndUpdatesMessages() = runTest { + val agent = RecordingAgent( + events = flowOf( + RunStartedEvent(threadId = "thread-1", runId = "run-1"), + TextMessageStartEvent(messageId = "assistant-1", role = Role.ASSISTANT), + TextMessageContentEvent(messageId = "assistant-1", delta = "Hello"), + TextMessageEndEvent(messageId = "assistant-1"), + RunFinishedEvent(threadId = "thread-1", runId = "run-1") + ) + ) + val subscriber = RecordingSubscriber() + agent.subscribe(subscriber) + + agent.runAgent() + + val messages = agent.messages + assertEquals(1, messages.size) + val assistant = messages.first() + assertEquals("assistant-1", assistant.id) + assertEquals("Hello", assistant.content) + + assertEquals(1, subscriber.initializedCount) + assertEquals(5, subscriber.eventCount) + assertEquals(1, subscriber.finalizedCount) + assertEquals(0, subscriber.failedCount) + assertEquals(1, agent.finalizeCount) + assertEquals(0, agent.errorCount) + } + + @Test + fun runAgent_propagatesErrorsThroughSubscribers() = runTest { + val agent = RecordingAgent( + events = flow { + emit(RunStartedEvent(threadId = "thread-err", runId = "run-err")) + emit(TextMessageStartEvent(messageId = "assistant-err", role = Role.ASSISTANT)) + throw IllegalStateException("boom") + } + ) + val subscriber = RecordingSubscriber() + agent.subscribe(subscriber) + + agent.runAgent() + + assertEquals(1, subscriber.initializedCount) + assertEquals(2, subscriber.eventCount) + assertEquals(1, subscriber.failedCount) + assertEquals(1, subscriber.finalizedCount) + assertEquals(1, agent.errorCount) + assertEquals(1, agent.finalizeCount) + // Messages should still contain the started streaming message even after failure + assertTrue(agent.messages.any { it.id == "assistant-err" }) + } + + @Test + fun runAgentObservable_streamsEventsWithoutCompletingStatePipeline() = runTest { + val agent = RecordingAgent( + events = flowOf( + RunStartedEvent(threadId = "thread-stream", runId = "run-stream"), + TextMessageStartEvent(messageId = "assistant-stream", role = Role.ASSISTANT), + TextMessageContentEvent(messageId = "assistant-stream", delta = "Streaming"), + TextMessageEndEvent(messageId = "assistant-stream"), + RunFinishedEvent(threadId = "thread-stream", runId = "run-stream") + ) + ) + val subscriber = RecordingSubscriber() + + val collected = mutableListOf() + agent.runAgentObservable(subscriber = subscriber).collect { event -> + collected += event.eventType.name + } + + assertEquals( + listOf( + "RUN_STARTED", + "TEXT_MESSAGE_START", + "TEXT_MESSAGE_CONTENT", + "TEXT_MESSAGE_END", + "RUN_FINISHED" + ), + collected + ) + assertEquals(1, subscriber.initializedCount) + assertEquals(collected.size, subscriber.eventCount) + assertEquals(1, subscriber.finalizedCount) + assertFalse(agent.messages.isEmpty()) + } + + private class RecordingAgent( + private val events: Flow + ) : AbstractAgent() { + var errorCount = 0 + var finalizeCount = 0 + + override fun run(input: RunAgentInput): Flow = events + + override fun onError(error: Throwable) { + errorCount++ + } + + override fun onFinalize() { + finalizeCount++ + } + } + + private class RecordingSubscriber : AgentSubscriber { + var initializedCount = 0 + var finalizedCount = 0 + var failedCount = 0 + var eventCount = 0 + + override suspend fun onRunInitialized(params: AgentSubscriberParams): AgentStateMutation? { + initializedCount++ + return null + } + + override suspend fun onRunFinalized(params: AgentSubscriberParams): AgentStateMutation? { + finalizedCount++ + return null + } + + override suspend fun onRunFailed(params: AgentRunFailureParams): AgentStateMutation? { + failedCount++ + return null + } + + override suspend fun onEvent(params: AgentEventParams): AgentStateMutation? { + eventCount++ + return null + } + } +} diff --git a/sdks/community/kotlin/library/client/src/commonTest/kotlin/com/agui/client/chunks/ChunkTransformTest.kt b/sdks/community/kotlin/library/client/src/commonTest/kotlin/com/agui/client/chunks/ChunkTransformTest.kt index 4adec6150..e7e444e4d 100644 --- a/sdks/community/kotlin/library/client/src/commonTest/kotlin/com/agui/client/chunks/ChunkTransformTest.kt +++ b/sdks/community/kotlin/library/client/src/commonTest/kotlin/com/agui/client/chunks/ChunkTransformTest.kt @@ -12,18 +12,18 @@ class ChunkTransformTest { val events = flowOf( RunStartedEvent(threadId = "t1", runId = "r1"), TextMessageChunkEvent( - messageId = "msg1", + messageId = "msg1", delta = "Hello" ), TextMessageChunkEvent( - messageId = "msg1", + messageId = "msg1", delta = " world" ) ) - + val result = events.transformChunks().toList() - - assertEquals(4, result.size) + + assertEquals(5, result.size) assertTrue(result[0] is RunStartedEvent) assertTrue(result[1] is TextMessageStartEvent) assertEquals("msg1", (result[1] as TextMessageStartEvent).messageId) @@ -31,6 +31,23 @@ class ChunkTransformTest { assertEquals("Hello", (result[2] as TextMessageContentEvent).delta) assertTrue(result[3] is TextMessageContentEvent) assertEquals(" world", (result[3] as TextMessageContentEvent).delta) + assertTrue(result[4] is TextMessageEndEvent) + assertEquals("msg1", (result[4] as TextMessageEndEvent).messageId) + } + + @Test + fun testTextMessageChunkRolePropagation() = runTest { + val events = flowOf( + TextMessageChunkEvent( + messageId = "msg1", + role = Role.DEVELOPER, + delta = "Hello" + ) + ) + + val result = events.transformChunks().toList() + val startEvent = result.first { it is TextMessageStartEvent } as TextMessageStartEvent + assertEquals(Role.DEVELOPER, startEvent.role) } @Test @@ -50,7 +67,7 @@ class ChunkTransformTest { val result = events.transformChunks().toList() - assertEquals(4, result.size) + assertEquals(5, result.size) assertTrue(result[0] is RunStartedEvent) assertTrue(result[1] is ToolCallStartEvent) assertEquals("tool1", (result[1] as ToolCallStartEvent).toolCallId) @@ -59,6 +76,8 @@ class ChunkTransformTest { assertEquals("{\"param\":", (result[2] as ToolCallArgsEvent).delta) assertTrue(result[3] is ToolCallArgsEvent) assertEquals("\"value\"}", (result[3] as ToolCallArgsEvent).delta) + assertTrue(result[4] is ToolCallEndEvent) + assertEquals("tool1", (result[4] as ToolCallEndEvent).toolCallId) } @Test @@ -123,12 +142,12 @@ class ChunkTransformTest { assertEquals(5, result.size) assertTrue(result[0] is RunStartedEvent) assertTrue(result[1] is ToolCallStartEvent) - assertTrue(result[2] is ToolCallEndEvent) - assertEquals("tool1", (result[2] as ToolCallEndEvent).toolCallId) - assertTrue(result[3] is TextMessageStartEvent) - assertEquals("msg1", (result[3] as TextMessageStartEvent).messageId) - assertTrue(result[4] is TextMessageContentEvent) - assertEquals("Hello", (result[4] as TextMessageContentEvent).delta) + assertTrue(result[2] is TextMessageStartEvent) + assertEquals("msg1", (result[2] as TextMessageStartEvent).messageId) + assertTrue(result[3] is TextMessageContentEvent) + assertEquals("Hello", (result[3] as TextMessageContentEvent).delta) + assertTrue(result[4] is TextMessageEndEvent) + assertEquals("msg1", (result[4] as TextMessageEndEvent).messageId) } @Test @@ -148,12 +167,13 @@ class ChunkTransformTest { assertEquals(5, result.size) assertTrue(result[0] is RunStartedEvent) assertTrue(result[1] is TextMessageStartEvent) - assertTrue(result[2] is TextMessageEndEvent) - assertEquals("msg1", (result[2] as TextMessageEndEvent).messageId) - assertTrue(result[3] is ToolCallStartEvent) - assertEquals("tool1", (result[3] as ToolCallStartEvent).toolCallId) - assertTrue(result[4] is ToolCallArgsEvent) - assertEquals("{}", (result[4] as ToolCallArgsEvent).delta) + assertEquals("msg1", (result[1] as TextMessageStartEvent).messageId) + assertTrue(result[2] is ToolCallStartEvent) + assertEquals("tool1", (result[2] as ToolCallStartEvent).toolCallId) + assertTrue(result[3] is ToolCallArgsEvent) + assertEquals("{}", (result[3] as ToolCallArgsEvent).delta) + assertTrue(result[4] is ToolCallEndEvent) + assertEquals("tool1", (result[4] as ToolCallEndEvent).toolCallId) } @Test @@ -166,18 +186,23 @@ class ChunkTransformTest { val result = events.transformChunks().toList() - assertEquals(5, result.size) + assertEquals(7, result.size) assertTrue(result[0] is RunStartedEvent) // First message assertTrue(result[1] is TextMessageStartEvent) assertEquals("msg1", (result[1] as TextMessageStartEvent).messageId) assertTrue(result[2] is TextMessageContentEvent) assertEquals("First", (result[2] as TextMessageContentEvent).delta) - // Second message - assertTrue(result[3] is TextMessageStartEvent) - assertEquals("msg2", (result[3] as TextMessageStartEvent).messageId) - assertTrue(result[4] is TextMessageContentEvent) - assertEquals("Second", (result[4] as TextMessageContentEvent).delta) + assertTrue(result[3] is TextMessageEndEvent) + assertNull(result[3].timestamp) + assertEquals("msg1", (result[3] as TextMessageEndEvent).messageId) + // Second message + assertTrue(result[4] is TextMessageStartEvent) + assertEquals("msg2", (result[4] as TextMessageStartEvent).messageId) + assertTrue(result[5] is TextMessageContentEvent) + assertEquals("Second", (result[5] as TextMessageContentEvent).delta) + assertTrue(result[6] is TextMessageEndEvent) + assertEquals("msg2", (result[6] as TextMessageEndEvent).messageId) } @Test @@ -198,7 +223,7 @@ class ChunkTransformTest { val result = events.transformChunks().toList() - assertEquals(5, result.size) + assertEquals(7, result.size) assertTrue(result[0] is RunStartedEvent) // First tool call assertTrue(result[1] is ToolCallStartEvent) @@ -206,12 +231,16 @@ class ChunkTransformTest { assertEquals("first_tool", (result[1] as ToolCallStartEvent).toolCallName) assertTrue(result[2] is ToolCallArgsEvent) assertEquals("first", (result[2] as ToolCallArgsEvent).delta) + assertTrue(result[3] is ToolCallEndEvent) + assertEquals("tool1", (result[3] as ToolCallEndEvent).toolCallId) // Second tool call - assertTrue(result[3] is ToolCallStartEvent) - assertEquals("tool2", (result[3] as ToolCallStartEvent).toolCallId) - assertEquals("second_tool", (result[3] as ToolCallStartEvent).toolCallName) - assertTrue(result[4] is ToolCallArgsEvent) - assertEquals("second", (result[4] as ToolCallArgsEvent).delta) + assertTrue(result[4] is ToolCallStartEvent) + assertEquals("tool2", (result[4] as ToolCallStartEvent).toolCallId) + assertEquals("second_tool", (result[4] as ToolCallStartEvent).toolCallName) + assertTrue(result[5] is ToolCallArgsEvent) + assertEquals("second", (result[5] as ToolCallArgsEvent).delta) + assertTrue(result[6] is ToolCallEndEvent) + assertEquals("tool2", (result[6] as ToolCallEndEvent).toolCallId) } @Test @@ -248,12 +277,14 @@ class ChunkTransformTest { val result = events.transformChunks().toList() - assertEquals(4, result.size) + assertEquals(5, result.size) assertTrue(result[0] is RunStartedEvent) assertTrue(result[1] is TextMessageStartEvent) assertTrue(result[2] is TextMessageEndEvent) assertEquals("msg1", (result[2] as TextMessageEndEvent).messageId) assertTrue(result[3] is ToolCallStartEvent) + assertTrue(result[4] is ToolCallEndEvent) + assertEquals("tool1", (result[4] as ToolCallEndEvent).toolCallId) } @Test @@ -270,10 +301,11 @@ class ChunkTransformTest { val result = events.transformChunks().toList() - assertEquals(3, result.size) + assertEquals(4, result.size) assertTrue(result[1] is TextMessageStartEvent) assertEquals(timestamp, result[1].timestamp) assertTrue(result[2] is TextMessageContentEvent) assertEquals(timestamp, result[2].timestamp) + assertTrue(result[3] is TextMessageEndEvent) } -} \ No newline at end of file +} diff --git a/sdks/community/kotlin/library/client/src/commonTest/kotlin/com/agui/client/sse/SseParserTest.kt b/sdks/community/kotlin/library/client/src/commonTest/kotlin/com/agui/client/sse/SseParserTest.kt new file mode 100644 index 000000000..95d813be5 --- /dev/null +++ b/sdks/community/kotlin/library/client/src/commonTest/kotlin/com/agui/client/sse/SseParserTest.kt @@ -0,0 +1,37 @@ +package com.agui.client.sse + +import com.agui.core.types.BaseEvent +import com.agui.core.types.Role +import com.agui.core.types.TextMessageStartEvent +import com.agui.core.types.AgUiJson +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertIs +import kotlin.test.assertTrue +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.flow.toList +import kotlinx.coroutines.test.runTest + +class SseParserTest { + + @Test + fun parseFlow_filtersMalformedEvents() = runTest { + val parser = SseParser() + val validEvent = TextMessageStartEvent(messageId = "stream-1", role = Role.ASSISTANT) + val serialized = AgUiJson.encodeToString(BaseEvent.serializer(), validEvent) + val payloads = flowOf( + "not-json", + serialized, + "{ \"event\": \"missing \"", + " $serialized " + ) + + val parsed = parser.parseFlow(payloads).toList() + + assertEquals(2, parsed.size) + parsed.forEach { event -> + val start = assertIs(event) + assertEquals("stream-1", start.messageId) + } + } +} diff --git a/sdks/community/kotlin/library/client/src/commonTest/kotlin/com/agui/client/state/DefaultApplyEventsTest.kt b/sdks/community/kotlin/library/client/src/commonTest/kotlin/com/agui/client/state/DefaultApplyEventsTest.kt new file mode 100644 index 000000000..9e5b63416 --- /dev/null +++ b/sdks/community/kotlin/library/client/src/commonTest/kotlin/com/agui/client/state/DefaultApplyEventsTest.kt @@ -0,0 +1,215 @@ +package com.agui.client.state + +import com.agui.client.agent.AbstractAgent +import com.agui.client.agent.AgentEventParams +import com.agui.client.agent.AgentStateMutation +import com.agui.client.agent.AgentSubscriber +import com.agui.client.chunks.transformChunks +import com.agui.core.types.* +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.flow.toList +import kotlinx.coroutines.test.runTest +import kotlinx.serialization.json.buildJsonObject +import kotlinx.serialization.json.put +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNotNull +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +class DefaultApplyEventsTest { + private fun baseInput(): RunAgentInput = RunAgentInput( + threadId = "thread", + runId = "run" + ) + + private fun dummyAgent(): AbstractAgent = object : AbstractAgent() { + override fun run(input: RunAgentInput): Flow = flowOf() + } + + @Test + fun surfacesRawEvents() = runTest { + val input = baseInput() + val rawEvent = RawEvent( + event = buildJsonObject { put("type", "diagnostic") }, + source = "test-source" + ) + + val states = defaultApplyEvents(input, flowOf(rawEvent)).toList() + + assertEquals(1, states.size) + val state = states.first() + assertNotNull(state.rawEvents) + assertEquals(listOf(rawEvent), state.rawEvents) + } + + @Test + fun surfacesCustomEvents() = runTest { + val input = baseInput() + val customEvent = CustomEvent( + name = "ProgressUpdate", + value = buildJsonObject { put("percent", 50) } + ) + + val states = defaultApplyEvents(input, flowOf(customEvent)).toList() + + assertEquals(1, states.size) + val state = states.first() + assertNotNull(state.customEvents) + assertEquals(listOf(customEvent), state.customEvents) + } + + @Test + fun accumulatesMultipleCustomEvents() = runTest { + val input = baseInput() + val customEvents = listOf( + CustomEvent( + name = "ProgressUpdate", + value = buildJsonObject { put("percent", 10) } + ), + CustomEvent( + name = "ProgressUpdate", + value = buildJsonObject { put("percent", 80) } + ) + ) + + val states = defaultApplyEvents(input, flowOf(*customEvents.toTypedArray())).toList() + + assertEquals(customEvents.size, states.size) + val latestState = states.last() + assertEquals(customEvents, latestState.customEvents) + } + + @Test + fun accumulatesMultipleRawEvents() = runTest { + val input = baseInput() + val rawEvents = listOf( + RawEvent(event = buildJsonObject { put("type", "diagnostic") }), + RawEvent(event = buildJsonObject { put("type", "metric") }, source = "collector") + ) + + val states = defaultApplyEvents(input, flowOf(*rawEvents.toTypedArray())).toList() + + assertEquals(rawEvents.size, states.size) + val latestState = states.last() + assertEquals(rawEvents, latestState.rawEvents) + } + + @Test + fun transformsTextMessageChunksIntoAssistantMessage() = runTest { + val input = baseInput() + val events = flowOf( + TextMessageChunkEvent(messageId = "msg1", delta = "Hello "), + TextMessageChunkEvent(delta = "world!") + ) + + val states = defaultApplyEvents(input, events.transformChunks()).toList() + + val latestMessages = states.last().messages + assertNotNull(latestMessages) + val assistantMessage = latestMessages.last() as AssistantMessage + assertEquals("Hello world!", assistantMessage.content) + } + + @Test + fun respectsNonAssistantRolesForTextMessages() = runTest { + val input = baseInput() + val events = flowOf( + TextMessageStartEvent(messageId = "dev1", role = Role.DEVELOPER), + TextMessageContentEvent(messageId = "dev1", delta = "Configure"), + TextMessageContentEvent(messageId = "dev1", delta = " agent") + ) + + val states = defaultApplyEvents(input, events).toList() + + val latestMessages = states.last().messages + assertNotNull(latestMessages) + val developerMessage = latestMessages.last() as DeveloperMessage + assertEquals("Configure agent", developerMessage.content) + } + + @Test + fun subscriberCanStopPropagationBeforeMutation() = runTest { + val input = baseInput() + val agent = dummyAgent() + val subscriber = object : AgentSubscriber { + override suspend fun onEvent(params: AgentEventParams): AgentStateMutation? { + return AgentStateMutation( + messages = params.messages + UserMessage(id = "u1", content = "hi"), + stopPropagation = true + ) + } + } + + val states = defaultApplyEvents( + input, + flowOf(TextMessageStartEvent(messageId = "msg1")), + agent = agent, + subscribers = listOf(subscriber) + ).toList() + + assertEquals(1, states.size) + val messages = states.first().messages + assertNotNull(messages) + val userMessage = messages.first() as UserMessage + assertEquals("hi", userMessage.content) + } + + @Test + fun appendsToolCallResultAsToolMessage() = runTest { + val input = baseInput() + val events = flowOf( + ToolCallStartEvent(toolCallId = "call1", toolCallName = "lookup"), + ToolCallArgsEvent(toolCallId = "call1", delta = "{\"arg\":\"value\"}"), + ToolCallEndEvent(toolCallId = "call1"), + ToolCallResultEvent(messageId = "tool_msg", toolCallId = "call1", content = "done") + ) + + val states = defaultApplyEvents(input, events).toList() + val messages = states.last().messages + assertNotNull(messages) + val toolMessages = messages.filterIsInstance() + assertEquals(1, toolMessages.size) + val toolMessage = toolMessages.first() + assertEquals("done", toolMessage.content) + assertEquals("call1", toolMessage.toolCallId) + assertTrue(messages.any { it is AssistantMessage }) + } + + @Test + fun tracksThinkingTelemetryDuringStream() = runTest { + val input = baseInput() + val events = flowOf( + ThinkingStartEvent(title = "Planning"), + ThinkingTextMessageStartEvent(), + ThinkingTextMessageContentEvent(delta = "Step 1"), + ThinkingTextMessageContentEvent(delta = " -> Step 2") + ) + + val states = defaultApplyEvents(input, events).toList() + val thinking = states.last().thinking + assertNotNull(thinking) + assertTrue(thinking.isThinking) + assertEquals("Planning", thinking.title) + assertEquals(listOf("Step 1 -> Step 2"), thinking.messages) + } + + @Test + fun thinkingEndMarksStatusInactive() = runTest { + val input = baseInput() + val events = flowOf( + ThinkingStartEvent(title = "Reasoning"), + ThinkingTextMessageStartEvent(), + ThinkingTextMessageContentEvent(delta = "Considering options"), + ThinkingTextMessageEndEvent(), + ThinkingEndEvent() + ) + + val states = defaultApplyEvents(input, events).toList() + val thinking = states.last().thinking + assertNotNull(thinking) + assertFalse(thinking.isThinking) + assertEquals(listOf("Considering options"), thinking.messages) + } +} diff --git a/sdks/community/kotlin/library/core/build.gradle.kts b/sdks/community/kotlin/library/core/build.gradle.kts index cdf282f54..8edc549a1 100644 --- a/sdks/community/kotlin/library/core/build.gradle.kts +++ b/sdks/community/kotlin/library/core/build.gradle.kts @@ -7,7 +7,7 @@ plugins { } group = "com.agui" -version = "0.2.1" + version = "0.2.3" repositories { google() @@ -24,8 +24,8 @@ kotlin { freeCompilerArgs.add("-opt-in=kotlin.RequiresOptIn") freeCompilerArgs.add("-opt-in=kotlinx.coroutines.ExperimentalCoroutinesApi") freeCompilerArgs.add("-opt-in=kotlinx.serialization.ExperimentalSerializationApi") - languageVersion.set(org.jetbrains.kotlin.gradle.dsl.KotlinVersion.KOTLIN_2_1) - apiVersion.set(org.jetbrains.kotlin.gradle.dsl.KotlinVersion.KOTLIN_2_1) + languageVersion.set(org.jetbrains.kotlin.gradle.dsl.KotlinVersion.KOTLIN_2_2) + apiVersion.set(org.jetbrains.kotlin.gradle.dsl.KotlinVersion.KOTLIN_2_2) } } } @@ -175,4 +175,4 @@ signing { tasks.withType { useJUnitPlatform() -} \ No newline at end of file +} diff --git a/sdks/community/kotlin/library/core/src/commonMain/kotlin/com/agui/core/types/Events.kt b/sdks/community/kotlin/library/core/src/commonMain/kotlin/com/agui/core/types/Events.kt index 96172fc4c..98c77362c 100644 --- a/sdks/community/kotlin/library/core/src/commonMain/kotlin/com/agui/core/types/Events.kt +++ b/sdks/community/kotlin/library/core/src/commonMain/kotlin/com/agui/core/types/Events.kt @@ -283,13 +283,12 @@ data class StepFinishedEvent( @SerialName("TEXT_MESSAGE_START") data class TextMessageStartEvent( val messageId: String, + val role: Role = Role.ASSISTANT, override val timestamp: Long? = null, override val rawEvent: JsonElement? = null ) : BaseEvent () { @Transient override val eventType: EventType = EventType.TEXT_MESSAGE_START - // Needed for serialization/deserialization for protocol correctness - val role : String = "assistant" } /** @@ -701,6 +700,7 @@ data class ThinkingTextMessageEndEvent( @SerialName("TEXT_MESSAGE_CHUNK") data class TextMessageChunkEvent( val messageId: String? = null, + val role: Role? = null, val delta: String? = null, override val timestamp: Long? = null, override val rawEvent: JsonElement? = null diff --git a/sdks/community/kotlin/library/core/src/commonTest/kotlin/com/agui/tests/ToolSerializationDebugTest.kt b/sdks/community/kotlin/library/core/src/commonTest/kotlin/com/agui/tests/ToolSerializationDebugTest.kt index e3403bdcd..c2ec0ac85 100644 --- a/sdks/community/kotlin/library/core/src/commonTest/kotlin/com/agui/tests/ToolSerializationDebugTest.kt +++ b/sdks/community/kotlin/library/core/src/commonTest/kotlin/com/agui/tests/ToolSerializationDebugTest.kt @@ -8,42 +8,45 @@ import kotlin.test.Test class ToolSerializationDebugTest { @Test - fun testUserConfirmationToolSerialization() { - // Create the user_confirmation tool exactly as in theConfirmationToolExecutor - val userConfirmationTool = Tool( - name = "user_confirmation", - description = "Request user confirmation for an action with specified importance level", + fun testChangeBackgroundToolSerialization() { + // Create the change_background tool definition to ensure serialization stays consistent + val backgroundTool = Tool( + name = "change_background", + description = "Update the application's background or surface colour", parameters = buildJsonObject { put("type", "object") putJsonObject("properties") { - putJsonObject("message") { + putJsonObject("color") { put("type", "string") - put("description", "The confirmation message to display to the user") + put( + "description", + "Colour in hex format (e.g. #RRGGBB or #RRGGBBAA) to apply to the background" + ) } - putJsonObject("importance") { + putJsonObject("description") { put("type", "string") - put("enum", buildJsonArray { - add("critical") - add("high") - add("medium") - add("low") - }) - put("description", "The importance level of the confirmation") - put("default", "medium") + put( + "description", + "Optional human readable description of the new background" + ) } - putJsonObject("details") { - put("type", "string") - put("description", "Optional additional details about the action requiring confirmation") + putJsonObject("reset") { + put("type", "boolean") + put( + "description", + "Set to true to reset the background to the default theme" + ) + put("default", JsonPrimitive(false)) } } putJsonArray("required") { - add("message") + add("color") } } ) // Serialize just the tool - val toolJson = AgUiJson.encodeToString(userConfirmationTool) + val toolJson = AgUiJson.encodeToString(backgroundTool) println("\n=== Tool JSON ===") println(toolJson) @@ -58,7 +61,7 @@ class ToolSerializationDebugTest { content = "delete user data" ) ), - tools = listOf(userConfirmationTool), + tools = listOf(backgroundTool), context = emptyList(), forwardedProps = JsonObject(emptyMap()) ) diff --git a/sdks/community/kotlin/library/core/src/jvmTest/kotlin/com/agui/platform/PlatformJvmTest.kt b/sdks/community/kotlin/library/core/src/jvmTest/kotlin/com/agui/platform/PlatformJvmTest.kt new file mode 100644 index 000000000..a409a91ae --- /dev/null +++ b/sdks/community/kotlin/library/core/src/jvmTest/kotlin/com/agui/platform/PlatformJvmTest.kt @@ -0,0 +1,13 @@ +package com.agui.platform + +import kotlin.test.Test +import kotlin.test.assertTrue + +class PlatformJvmTest { + + @Test + fun platformProvidesJvmDetails() { + assertTrue(Platform.name.startsWith("JVM"), "Expected JVM platform name, got ${Platform.name}") + assertTrue(Platform.availableProcessors > 0, "Available processors should be positive") + } +} diff --git a/sdks/community/kotlin/library/gradle.properties b/sdks/community/kotlin/library/gradle.properties index 850eb5c4f..26a573fc6 100644 --- a/sdks/community/kotlin/library/gradle.properties +++ b/sdks/community/kotlin/library/gradle.properties @@ -7,9 +7,9 @@ org.gradle.caching=true kotlin.code.style=official kotlin.mpp.androidSourceSetLayoutVersion=2 # Enable K2 compiler -kotlin.compiler.version=2.2.0 -kotlin.compiler.languageVersion=2.2.0 -kotlin.compiler.apiVersion=2.2.0 +kotlin.compiler.version=2.2.20 +kotlin.compiler.languageVersion=2.2 +kotlin.compiler.apiVersion=2.2 kotlin.compiler.k2=true kotlin.native.ignoreDisabledTargets=true @@ -30,4 +30,4 @@ org.jetbrains.dokka.experimental.gradle.pluginMode=V2Enabled # signingKey=YOUR_SIGNING_KEY # signingPassword=YOUR_SIGNING_PASSWORD # ossrhUsername=YOUR_OSSRH_USERNAME -# ossrhPassword=YOUR_OSSRH_PASSWORD \ No newline at end of file +# ossrhPassword=YOUR_OSSRH_PASSWORD diff --git a/sdks/community/kotlin/library/gradle/libs.versions.toml b/sdks/community/kotlin/library/gradle/libs.versions.toml index c4e056417..163192409 100644 --- a/sdks/community/kotlin/library/gradle/libs.versions.toml +++ b/sdks/community/kotlin/library/gradle/libs.versions.toml @@ -1,6 +1,6 @@ [versions] core-ktx = "1.16.0" -kotlin = "2.1.21" +kotlin = "2.2.20" kotlin-json-patch = "1.0.0" #Downgrading to avoid an R8 error ktor = "3.1.3" @@ -50,4 +50,4 @@ kotlinx-common = [ "kotlinx-coroutines-core", "kotlinx-serialization-json", "kotlinx-datetime" -] \ No newline at end of file +] diff --git a/sdks/community/kotlin/library/tools/build.gradle.kts b/sdks/community/kotlin/library/tools/build.gradle.kts index e95ee4efa..d182b30ce 100644 --- a/sdks/community/kotlin/library/tools/build.gradle.kts +++ b/sdks/community/kotlin/library/tools/build.gradle.kts @@ -7,7 +7,7 @@ plugins { } group = "com.agui" -version = "0.2.1" + version = "0.2.3" repositories { google() @@ -24,8 +24,8 @@ kotlin { freeCompilerArgs.add("-opt-in=kotlin.RequiresOptIn") freeCompilerArgs.add("-opt-in=kotlinx.coroutines.ExperimentalCoroutinesApi") freeCompilerArgs.add("-opt-in=kotlinx.serialization.ExperimentalSerializationApi") - languageVersion.set(org.jetbrains.kotlin.gradle.dsl.KotlinVersion.KOTLIN_2_1) - apiVersion.set(org.jetbrains.kotlin.gradle.dsl.KotlinVersion.KOTLIN_2_1) + languageVersion.set(org.jetbrains.kotlin.gradle.dsl.KotlinVersion.KOTLIN_2_2) + apiVersion.set(org.jetbrains.kotlin.gradle.dsl.KotlinVersion.KOTLIN_2_2) } } } @@ -168,4 +168,4 @@ signing { tasks.withType { useJUnitPlatform() -} \ No newline at end of file +} diff --git a/sdks/community/kotlin/library/tools/src/commonMain/kotlin/com/agui/tools/ToolExecutionManager.kt b/sdks/community/kotlin/library/tools/src/commonMain/kotlin/com/agui/tools/ToolExecutionManager.kt index 83c4d7ab1..6c9f0c98b 100644 --- a/sdks/community/kotlin/library/tools/src/commonMain/kotlin/com/agui/tools/ToolExecutionManager.kt +++ b/sdks/community/kotlin/library/tools/src/commonMain/kotlin/com/agui/tools/ToolExecutionManager.kt @@ -133,7 +133,7 @@ class ToolExecutionManager( // Create tool message response val toolMessage = ToolMessage( id = generateMessageId(), - content = formatToolResponse(result, toolName), + content = formatToolResponse(result), toolCallId = toolCallId ) @@ -180,30 +180,10 @@ class ToolExecutionManager( /** * Formats a tool execution result into a response message. */ - private fun formatToolResponse(result: ToolExecutionResult, toolName: String): String { - return if (result.success) { - // Format successful response - buildString { - append("Tool '$toolName' executed successfully") - if (result.message != null) { - append(": ${result.message}") - } - if (result.result != null) { - append("\nResult: ${result.result}") - } - } - } else { - // Format error response - buildString { - append("Tool '$toolName' execution failed") - if (result.message != null) { - append(": ${result.message}") - } - if (result.result != null) { - append("\nDetails: ${result.result}") - } - } - } + private fun formatToolResponse(result: ToolExecutionResult): String { + result.result?.toString()?.takeIf { it.isNotEmpty() }?.let { return it } + result.message?.takeIf { it.isNotEmpty() }?.let { return it } + return if (result.success) "true" else "false" } /** @@ -306,4 +286,4 @@ class LoggingToolResponseHandler : ToolResponseHandler { "Tool response (thread: $threadId, run: $runId): ${toolMessage.content}" } } -} \ No newline at end of file +} diff --git a/sdks/community/kotlin/library/tools/src/commonTest/kotlin/com/agui/tools/CircuitBreakerTest.kt b/sdks/community/kotlin/library/tools/src/commonTest/kotlin/com/agui/tools/CircuitBreakerTest.kt new file mode 100644 index 000000000..f8c91905c --- /dev/null +++ b/sdks/community/kotlin/library/tools/src/commonTest/kotlin/com/agui/tools/CircuitBreakerTest.kt @@ -0,0 +1,65 @@ +package com.agui.tools + +import kotlin.test.AfterTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertTrue +import kotlinx.coroutines.delay +import kotlinx.coroutines.runBlocking + +class CircuitBreakerTest { + + private val config = CircuitBreakerConfig( + failureThreshold = 2, + recoveryTimeoutMs = 20, + successThreshold = 2 + ) + + private val breaker = CircuitBreaker(config) + + @AfterTest + fun cleanup() { + breaker.reset() + } + + @Test + fun opensAfterConfiguredFailures() { + breaker.recordFailure() + assertFalse(breaker.isOpen()) + + breaker.recordFailure() + assertTrue(breaker.isOpen()) + assertEquals(CircuitBreakerState.OPEN, breaker.getState()) + } + + @Test + fun transitionsToHalfOpenAfterTimeoutAndClosesOnSuccess() = runBlocking { + breaker.recordFailure() + breaker.recordFailure() + assertTrue(breaker.isOpen()) + + delay(25) + assertFalse(breaker.isOpen()) + assertEquals(CircuitBreakerState.HALF_OPEN, breaker.getState()) + + breaker.recordSuccess() + assertEquals(CircuitBreakerState.HALF_OPEN, breaker.getState()) + + breaker.recordSuccess() + assertEquals(CircuitBreakerState.CLOSED, breaker.getState()) + assertFalse(breaker.isOpen()) + } + + @Test + fun failureDuringHalfOpenReopensCircuit() = runBlocking { + breaker.recordFailure() + breaker.recordFailure() + delay(25) + assertFalse(breaker.isOpen()) // transitions to half-open + + breaker.recordFailure() + assertTrue(breaker.isOpen()) + assertEquals(CircuitBreakerState.OPEN, breaker.getState()) + } +} diff --git a/sdks/community/kotlin/library/tools/src/commonTest/kotlin/com/agui/tools/ToolErrorHandlerTest.kt b/sdks/community/kotlin/library/tools/src/commonTest/kotlin/com/agui/tools/ToolErrorHandlerTest.kt new file mode 100644 index 000000000..b74652876 --- /dev/null +++ b/sdks/community/kotlin/library/tools/src/commonTest/kotlin/com/agui/tools/ToolErrorHandlerTest.kt @@ -0,0 +1,112 @@ +package com.agui.tools + +import com.agui.core.types.FunctionCall +import com.agui.core.types.ToolCall +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertIs +import kotlin.test.assertTrue +import kotlinx.coroutines.test.runTest + +class ToolErrorHandlerTest { + + private fun context(toolName: String = "calculator") = ToolExecutionContext( + toolCall = ToolCall( + id = "call-1", + function = FunctionCall( + name = toolName, + arguments = """{"value":42}""" + ) + ) + ) + + @Test + fun retryableErrorsReturnRetryDecision() = runTest { + val handler = ToolErrorHandler( + ToolErrorConfig( + maxRetryAttempts = 3, + baseRetryDelayMs = 100, + retryStrategy = RetryStrategy.FIXED, + circuitBreakerConfig = CircuitBreakerConfig(failureThreshold = 3, recoveryTimeoutMs = 100, successThreshold = 1) + ) + ) + + val decision = handler.handleError( + error = ToolTimeoutException("timeout"), + context = context(), + attempt = 1 + ) + + val retry = assertIs(decision) + assertEquals(100, retry.delayMs) + assertEquals(3, retry.maxAttempts) + } + + @Test + fun exponentialBackoffIsCappedAtConfiguredMaximum() = runTest { + val handler = ToolErrorHandler( + ToolErrorConfig( + maxRetryAttempts = 5, + baseRetryDelayMs = 50, + maxRetryDelayMs = 120, + retryStrategy = RetryStrategy.EXPONENTIAL, + circuitBreakerConfig = CircuitBreakerConfig(failureThreshold = 4, recoveryTimeoutMs = 100, successThreshold = 1) + ) + ) + + val decision = handler.handleError( + error = ToolNetworkException("network blip"), + context = context(), + attempt = 3 + ) + + val retry = assertIs(decision) + assertEquals(120, retry.delayMs) // capped at maxRetryDelayMs + } + + @Test + fun nonRetryableFailuresOpenCircuitAndReportStats() = runTest { + val handler = ToolErrorHandler( + ToolErrorConfig( + maxRetryAttempts = 2, + retryStrategy = RetryStrategy.FIXED, + circuitBreakerConfig = CircuitBreakerConfig( + failureThreshold = 1, + recoveryTimeoutMs = 60_000, + successThreshold = 1 + ) + ) + ) + + val failDecision = handler.handleError( + error = ToolValidationException("invalid arguments"), + context = context(), + attempt = 1 + ) + + val fail = assertIs(failDecision) + assertTrue(fail.message.contains("invalid")) + assertTrue(fail.shouldReport.not()) + + val stats = handler.getErrorStats("calculator") + assertEquals(1, stats.totalAttempts) + assertEquals(CircuitBreakerState.OPEN, stats.circuitBreakerState) + + val secondDecision = handler.handleError( + error = ToolValidationException("still invalid"), + context = context(), + attempt = 1 + ) + + val secondFail = assertIs(secondDecision) + assertTrue( + secondFail.message.contains("temporarily unavailable"), + "Unexpected message: ${secondFail.message}" + ) + + handler.recordSuccess("calculator") + val resetStats = handler.getErrorStats("calculator") + assertEquals(CircuitBreakerState.CLOSED, resetStats.circuitBreakerState) + assertEquals(0, resetStats.totalAttempts) + } +} diff --git a/sdks/community/kotlin/library/tools/src/commonTest/kotlin/com/agui/tools/ToolExecutionManagerTest.kt b/sdks/community/kotlin/library/tools/src/commonTest/kotlin/com/agui/tools/ToolExecutionManagerTest.kt new file mode 100644 index 000000000..627ec7625 --- /dev/null +++ b/sdks/community/kotlin/library/tools/src/commonTest/kotlin/com/agui/tools/ToolExecutionManagerTest.kt @@ -0,0 +1,88 @@ +package com.agui.tools + +import com.agui.core.types.Tool +import com.agui.core.types.ToolCallStartEvent +import com.agui.core.types.ToolCallArgsEvent +import com.agui.core.types.ToolCallEndEvent +import com.agui.core.types.ToolMessage +import com.agui.core.types.RunFinishedEvent +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.flow.toList +import kotlinx.coroutines.test.runTest +import kotlinx.serialization.json.JsonObject +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +class ToolExecutionManagerTest { + + @Test + fun executesRegisteredToolAndEmitsLifecycleEvents() = runTest { + val registry = DefaultToolRegistry() + val handler = RecordingResponseHandler() + val manager = ToolExecutionManager(registry, handler) + + registry.registerTool( + object : AbstractToolExecutor( + Tool( + name = "echo", + description = "Returns the provided text", + parameters = JsonObject(emptyMap()) + ) + ) { + override suspend fun executeInternal(context: ToolExecutionContext): ToolExecutionResult { + return ToolExecutionResult.success(message = "echo:${context.toolCall.function.arguments}") + } + } + ) + + val streamEvents = listOf( + ToolCallStartEvent(toolCallId = "call-1", toolCallName = "echo"), + ToolCallArgsEvent(toolCallId = "call-1", delta = """{"text":"hi"}"""), + ToolCallEndEvent(toolCallId = "call-1"), + RunFinishedEvent(threadId = "thread-1", runId = "run-1") + ) + + val collected = manager.processEventStream( + events = flowOf(*streamEvents.toTypedArray()), + threadId = "thread-1", + runId = "run-1" + ).toList() + + assertEquals(streamEvents, collected) + assertEquals(1, handler.messages.size) + val response = handler.messages.single() + assertEquals("call-1", response.toolCallId) + assertEquals("""echo:{"text":"hi"}""", response.content) + } + + @Test + fun missingToolProducesFailureResponse() = runTest { + val registry = DefaultToolRegistry() // intentionally empty + val handler = RecordingResponseHandler() + val manager = ToolExecutionManager(registry, handler) + + manager.processEventStream( + events = flowOf( + ToolCallStartEvent(toolCallId = "missing-1", toolCallName = "unknown"), + ToolCallArgsEvent(toolCallId = "missing-1", delta = "{}"), + ToolCallEndEvent(toolCallId = "missing-1"), + RunFinishedEvent(threadId = "thread-2", runId = "run-2") + ), + threadId = "thread-2", + runId = "run-2" + ).toList() + + assertEquals(1, handler.messages.size) + val response = handler.messages.single() + assertEquals("missing-1", response.toolCallId) + assertTrue(response.content.contains("not available")) + } + + private class RecordingResponseHandler : ToolResponseHandler { + val messages = mutableListOf() + override suspend fun sendToolResponse(toolMessage: ToolMessage, threadId: String?, runId: String?) { + messages += toolMessage.copy(content = toolMessage.content) + } + } +}