diff --git a/AGENTS.md b/AGENTS.md index 70df22c..677e7e2 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -13,7 +13,7 @@ ResolveKit Android SDK — native runtime, UI, and tool function integration for ResolveKit Android SDK provides runtime orchestration, tool calling, and chat UI surfaces for Kotlin apps. -**Tech Stack**: Kotlin 1.9.22, Compose, KSP, OkHttp, kotlinx.serialization +**Tech Stack**: Kotlin 1.9.25, Compose, KSP, OkHttp, kotlinx.serialization **Min SDK**: 26 | **Compile SDK**: 36 | **JDK**: 17 **Packages**: `app.resolvekit:sdk`, `app.resolvekit:authoring`, `app.resolvekit:ksp` @@ -33,6 +33,11 @@ curl -sL https://raw.githubusercontent.com/resolve-kit/resolvekit-skills/master/ When a user asks to integrate ResolveKit into their Android project, load `resolvekit-android-integration` and follow its steps. +## Documentation + +- [Documentation Index](docs/INDEX.md) — full map of durable docs, execution plans, and tech debt tracking. +- [Agent-First Harness Notes](docs/agent-first/README.md) — how this repo's agent-first doc structure is enforced in CI. + ## First Read 1. `build.gradle.kts` for project structure and plugins. diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index d3ea6e9..66681ff 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -1,6 +1,6 @@ [versions] kotlin = "1.9.25" -ksp = "1.9.22-1.0.17" +ksp = "1.9.25-1.0.20" coroutines = "1.7.3" serialization = "1.6.3" okhttp = "4.12.0" diff --git a/networking/src/main/kotlin/app/resolvekit/networking/ResolveKitAPIClient.kt b/networking/src/main/kotlin/app/resolvekit/networking/ResolveKitAPIClient.kt index 94f4d1c..aba98e8 100644 --- a/networking/src/main/kotlin/app/resolvekit/networking/ResolveKitAPIClient.kt +++ b/networking/src/main/kotlin/app/resolvekit/networking/ResolveKitAPIClient.kt @@ -92,6 +92,13 @@ class ResolveKitAPIClient( request: ToolResultRequest ): ToolResultResponse = post("/v1/sessions/$sessionId/tool-results", request, token) + /** POST /v1/sessions/{id}/feedback — submit a CSAT rating (1-5). */ + suspend fun submitFeedback( + sessionId: String, + token: String, + request: FeedbackRequest + ): FeedbackResponse = post("/v1/sessions/$sessionId/feedback", request, token) + // ------------------------------------------------------------------------- // Internal helpers // ------------------------------------------------------------------------- diff --git a/networking/src/main/kotlin/app/resolvekit/networking/ResolveKitEventStreamClient.kt b/networking/src/main/kotlin/app/resolvekit/networking/ResolveKitEventStreamClient.kt index 25d0243..e23830b 100644 --- a/networking/src/main/kotlin/app/resolvekit/networking/ResolveKitEventStreamClient.kt +++ b/networking/src/main/kotlin/app/resolvekit/networking/ResolveKitEventStreamClient.kt @@ -114,6 +114,21 @@ class ResolveKitEventStreamClient( ResolveKitEvent.ServerError(envelope.eventId, p.code, p.message, p.recoverable) }.getOrNull() + "session_escalated" -> runCatching { + val p = json.decodeFromJsonElement(envelope.payload) + ResolveKitEvent.SessionEscalated(envelope.eventId, p.reason) + }.getOrNull() + + "human_message" -> runCatching { + val p = json.decodeFromJsonElement(envelope.payload) + ResolveKitEvent.HumanMessage(envelope.eventId, p.messageId, p.text) + }.getOrNull() + + "feedback_requested" -> runCatching { + val p = json.decodeFromJsonElement(envelope.payload) + ResolveKitEvent.FeedbackRequested(envelope.eventId, p.immediate) + }.getOrDefault(ResolveKitEvent.FeedbackRequested(envelope.eventId, immediate = false)) + else -> ResolveKitEvent.Unknown(envelope.eventId, envelope.type) } } diff --git a/networking/src/main/kotlin/app/resolvekit/networking/models/EventModels.kt b/networking/src/main/kotlin/app/resolvekit/networking/models/EventModels.kt index 94f7a17..e7fb6e3 100644 --- a/networking/src/main/kotlin/app/resolvekit/networking/models/EventModels.kt +++ b/networking/src/main/kotlin/app/resolvekit/networking/models/EventModels.kt @@ -42,6 +42,22 @@ data class ServerErrorPayload( val recoverable: Boolean = true ) +@Serializable +data class SessionEscalatedPayload( + val reason: String +) + +@Serializable +data class HumanMessagePayload( + @SerialName("message_id") val messageId: String, + val text: String +) + +@Serializable +data class FeedbackRequestedPayload( + val immediate: Boolean = false +) + sealed class ResolveKitEvent { data class TextDelta( val eventId: String, @@ -69,6 +85,22 @@ sealed class ResolveKitEvent { val recoverable: Boolean ) : ResolveKitEvent() + data class SessionEscalated( + val eventId: String, + val reason: String + ) : ResolveKitEvent() + + data class HumanMessage( + val eventId: String, + val messageId: String, + val text: String + ) : ResolveKitEvent() + + data class FeedbackRequested( + val eventId: String, + val immediate: Boolean + ) : ResolveKitEvent() + data class Unknown( val eventId: String, val type: String diff --git a/networking/src/main/kotlin/app/resolvekit/networking/models/SessionModels.kt b/networking/src/main/kotlin/app/resolvekit/networking/models/SessionModels.kt index b16de09..76f6223 100644 --- a/networking/src/main/kotlin/app/resolvekit/networking/models/SessionModels.kt +++ b/networking/src/main/kotlin/app/resolvekit/networking/models/SessionModels.kt @@ -78,6 +78,20 @@ data class ToolResultResponse( val deduplicated: Boolean = false ) +@Serializable +data class FeedbackRequest( + val rating: Int, + val comment: String? = null +) + +@Serializable +data class FeedbackResponse( + val id: String, + @SerialName("session_id") val sessionId: String, + val rating: Int, + val comment: String? = null +) + @Serializable data class SessionLocalization( val locale: String, diff --git a/sample/build.gradle.kts b/sample/build.gradle.kts index eb294ea..05efe93 100644 --- a/sample/build.gradle.kts +++ b/sample/build.gradle.kts @@ -38,7 +38,10 @@ android { } composeOptions { - kotlinCompilerExtensionVersion = "1.5.8" + // Must track the Compose-Kotlin compatibility map for the Kotlin + // version pinned in gradle/libs.versions.toml (currently 1.9.25). + // https://developer.android.com/jetpack/androidx/releases/compose-kotlin + kotlinCompilerExtensionVersion = "1.5.15" } compileOptions { diff --git a/sample/src/main/AndroidManifest.xml b/sample/src/main/AndroidManifest.xml index b29d3b5..a1279ce 100644 --- a/sample/src/main/AndroidManifest.xml +++ b/sample/src/main/AndroidManifest.xml @@ -6,7 +6,10 @@ + android:theme="@style/Theme.Material3.Light.NoActionBar" + android:usesCleartextTraffic="true"> + = mapOf( + override val resolveKitParametersSchema: app.resolvekit.core.JSONObject = mapOf( "type" to JSONValue.String("object"), "properties" to JSONValue.Object(mapOf( "message" to JSONValue.Object(mapOf("type" to JSONValue.String("string"))) diff --git a/ui/build.gradle.kts b/ui/build.gradle.kts index f22cd60..a700742 100644 --- a/ui/build.gradle.kts +++ b/ui/build.gradle.kts @@ -25,7 +25,10 @@ android { } composeOptions { - kotlinCompilerExtensionVersion = "1.5.8" + // Must track the Compose-Kotlin compatibility map for the Kotlin + // version pinned in gradle/libs.versions.toml (currently 1.9.25). + // https://developer.android.com/jetpack/androidx/releases/compose-kotlin + kotlinCompilerExtensionVersion = "1.5.15" } compileOptions { diff --git a/ui/src/main/kotlin/app/resolvekit/ui/ResolveKitChatView.kt b/ui/src/main/kotlin/app/resolvekit/ui/ResolveKitChatView.kt index 369877d..29e9f3d 100644 --- a/ui/src/main/kotlin/app/resolvekit/ui/ResolveKitChatView.kt +++ b/ui/src/main/kotlin/app/resolvekit/ui/ResolveKitChatView.kt @@ -45,6 +45,8 @@ fun ResolveKitChatView( val messagePlaceholder by runtime.messagePlaceholder.collectAsState() val appearanceMode by runtime.appearanceMode.collectAsState() val chatTheme by runtime.chatTheme.collectAsState() + val isEscalated by runtime.isEscalated.collectAsState() + val pendingFeedbackRequest by runtime.pendingFeedbackRequest.collectAsState() val scope = rememberCoroutineScope() val listState = rememberLazyListState() @@ -117,6 +119,10 @@ fun ResolveKitChatView( else -> Unit } + if (isEscalated) { + ConnectionBanner("Connecting you with a human agent…", palette) + } + // ----- Message list ----- LazyColumn( state = listState, @@ -157,6 +163,14 @@ fun ResolveKitChatView( ) } + if (pendingFeedbackRequest) { + FeedbackPromptCard( + palette = palette, + onRate = { rating -> scope.launch { runtime.submitFeedback(rating) } }, + onDismiss = { runtime.dismissFeedbackRequest() } + ) + } + var inputText by remember { mutableStateOf("") } val canSend = inputText.isNotBlank() && !isTurnInProgress && connectionState == ResolveKitConnectionState.ACTIVE @@ -252,12 +266,61 @@ private fun ChatBubble(message: ResolveKitChatMessage, palette: ResolveKitPalett color = if (isUser) palette.userBubbleBackground else palette.assistantBubbleBackground, modifier = Modifier.widthIn(max = 300.dp) ) { + Column(modifier = Modifier.padding(horizontal = 14.dp, vertical = 10.dp)) { + if (message.role == ChatMessageRole.HUMAN_AGENT) { + Text( + text = "Support Agent", + color = palette.statusText, + style = MaterialTheme.typography.labelSmall + ) + Spacer(Modifier.height(2.dp)) + } + Text( + text = message.text, + color = if (isUser) palette.userBubbleText else palette.assistantBubbleText, + style = MaterialTheme.typography.bodyMedium + ) + } + } + } +} + +@Composable +private fun FeedbackPromptCard( + palette: ResolveKitPaletteColors, + onRate: (Int) -> Unit, + onDismiss: () -> Unit +) { + Surface( + color = palette.toolCardBackground, + shape = RoundedCornerShape(14.dp), + border = BorderStroke(1.dp, palette.toolCardBorder), + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 12.dp, vertical = 4.dp) + ) { + Column(modifier = Modifier.padding(horizontal = 14.dp, vertical = 10.dp)) { Text( - text = message.text, - modifier = Modifier.padding(horizontal = 14.dp, vertical = 10.dp), - color = if (isUser) palette.userBubbleText else palette.assistantBubbleText, - style = MaterialTheme.typography.bodyMedium + text = "How did we do?", + color = palette.titleText, + style = MaterialTheme.typography.titleSmall ) + Spacer(Modifier.height(8.dp)) + Row(verticalAlignment = Alignment.CenterVertically) { + for (rating in 1..5) { + TextButton(onClick = { onRate(rating) }) { + Text( + text = "★", + color = palette.statusText, + style = MaterialTheme.typography.titleMedium + ) + } + } + Spacer(Modifier.weight(1f)) + TextButton(onClick = onDismiss) { + Text("Dismiss", color = palette.statusText) + } + } } } } diff --git a/ui/src/main/kotlin/app/resolvekit/ui/ResolveKitRuntime.kt b/ui/src/main/kotlin/app/resolvekit/ui/ResolveKitRuntime.kt index 3d4f42c..55df0a7 100644 --- a/ui/src/main/kotlin/app/resolvekit/ui/ResolveKitRuntime.kt +++ b/ui/src/main/kotlin/app/resolvekit/ui/ResolveKitRuntime.kt @@ -81,6 +81,15 @@ class ResolveKitRuntime( private val _executionLog = MutableStateFlow>(emptyList()) val executionLog: StateFlow> = _executionLog.asStateFlow() + private val _isEscalated = MutableStateFlow(false) + val isEscalated: StateFlow = _isEscalated.asStateFlow() + + private val _escalationReason = MutableStateFlow(null) + val escalationReason: StateFlow = _escalationReason.asStateFlow() + + private val _pendingFeedbackRequest = MutableStateFlow(false) + val pendingFeedbackRequest: StateFlow = _pendingFeedbackRequest.asStateFlow() + // ------------------------------------------------------------------------- // Internal session state // ------------------------------------------------------------------------- @@ -94,6 +103,7 @@ class ResolveKitRuntime( private var eventStreamJob: Job? = null private var heartbeatJob: Job? = null private var batchCoalesceJob: Job? = null + private var feedbackPromptDelayJob: Job? = null private var networkCallback: ConnectivityManager.NetworkCallback? = null private val lastEventTimeMs = java.util.concurrent.atomic.AtomicLong(0L) private val batchMutex = Mutex() @@ -189,6 +199,7 @@ class ResolveKitRuntime( val role = when (msg.role) { "user" -> ChatMessageRole.USER "assistant" -> ChatMessageRole.ASSISTANT + "human_agent" -> ChatMessageRole.HUMAN_AGENT else -> return@mapNotNull null } ResolveKitChatMessage(role = role, text = msg.content ?: "") @@ -244,6 +255,10 @@ class ResolveKitRuntime( _toolCallChecklist.value = emptyList() _toolCallBatchState.value = ResolveKitToolCallBatchState.IDLE _isTurnInProgress.value = false + _isEscalated.value = false + _escalationReason.value = null + _pendingFeedbackRequest.value = false + cancelPendingFeedbackPrompt() sessionId = null chatCapabilityToken = null eventsUrl = null @@ -287,6 +302,8 @@ class ResolveKitRuntime( text = text ) _isTurnInProgress.value = true + cancelPendingFeedbackPrompt() + _pendingFeedbackRequest.value = false val requestId = UUID.randomUUID().toString() log("Sending message requestId=$requestId") @@ -301,6 +318,25 @@ class ResolveKitRuntime( } } + /** Submit a CSAT rating (1-5) for the current session, dismissing the pending feedback prompt. */ + suspend fun submitFeedback(rating: Int, comment: String? = null) { + val sid = sessionId ?: return + val tok = chatCapabilityToken ?: return + _pendingFeedbackRequest.value = false + cancelPendingFeedbackPrompt() + runCatching { + apiClient.submitFeedback(sid, tok, FeedbackRequest(rating = rating, comment = comment)) + }.onFailure { + log("submitFeedback failed: ${it.message}") + } + } + + /** Dismiss the feedback prompt without submitting a rating. */ + fun dismissFeedbackRequest() { + _pendingFeedbackRequest.value = false + cancelPendingFeedbackPrompt() + } + /** Approve all tools in the current batch. Executes them and submits results. */ suspend fun approveToolCallBatch() { val items = _toolCallChecklist.value.toList() @@ -498,6 +534,39 @@ class ResolveKitRuntime( } } + is app.resolvekit.networking.models.ResolveKitEvent.SessionEscalated -> { + lastEventCursor = event.eventId + saveEventCursor(event.eventId, sessionId ?: return) + _escalationReason.value = event.reason + _isEscalated.value = true + _isTurnInProgress.value = false + currentTurnId = null + // Suppress any CSAT prompt still pending from the AI's last reply — + // it shouldn't surface while the user is waiting on a human handoff. + cancelPendingFeedbackPrompt() + _pendingFeedbackRequest.value = false + } + + is app.resolvekit.networking.models.ResolveKitEvent.HumanMessage -> { + lastEventCursor = event.eventId + saveEventCursor(event.eventId, sessionId ?: return) + _messages.value = _messages.value + ResolveKitChatMessage( + role = ChatMessageRole.HUMAN_AGENT, + text = event.text + ) + } + + is app.resolvekit.networking.models.ResolveKitEvent.FeedbackRequested -> { + lastEventCursor = event.eventId + saveEventCursor(event.eventId, sessionId ?: return) + if (event.immediate) { + cancelPendingFeedbackPrompt() + _pendingFeedbackRequest.value = true + } else { + scheduleFeedbackPrompt() + } + } + is app.resolvekit.networking.models.ResolveKitEvent.Unknown -> { lastEventCursor = event.eventId saveEventCursor(event.eventId, sessionId ?: return) @@ -700,6 +769,20 @@ class ResolveKitRuntime( _executionLog.value = (_executionLog.value + message).takeLast(200) } + /** Show the CSAT prompt only after a short pause with no follow-up message. */ + private fun scheduleFeedbackPrompt() { + feedbackPromptDelayJob?.cancel() + feedbackPromptDelayJob = scope.launch { + delay(15_000) + _pendingFeedbackRequest.value = true + } + } + + private fun cancelPendingFeedbackPrompt() { + feedbackPromptDelayJob?.cancel() + feedbackPromptDelayJob = null + } + private fun applySession(session: SessionResponse, clearPendingResults: Boolean) { val previousSessionId = sessionId sessionId = session.id diff --git a/ui/src/main/kotlin/app/resolvekit/ui/models/ChatMessage.kt b/ui/src/main/kotlin/app/resolvekit/ui/models/ChatMessage.kt index 8fb6260..8723a64 100644 --- a/ui/src/main/kotlin/app/resolvekit/ui/models/ChatMessage.kt +++ b/ui/src/main/kotlin/app/resolvekit/ui/models/ChatMessage.kt @@ -3,7 +3,7 @@ package app.resolvekit.ui.models import java.util.Date import java.util.UUID -enum class ChatMessageRole { USER, ASSISTANT, SYSTEM } +enum class ChatMessageRole { USER, ASSISTANT, SYSTEM, HUMAN_AGENT } data class ResolveKitChatMessage( val id: UUID = UUID.randomUUID(),