From 98993835ba72a4c129ab4e54511d0b1e065b28b5 Mon Sep 17 00:00:00 2001 From: Jebran Syed Date: Mon, 10 Aug 2026 16:31:27 -0700 Subject: [PATCH 1/2] Add Android client-hosted device agent Register an inline Android device schema from the mobile sample, route executeAction callbacks to alarm and timer intents, return action results, and cover the registration and parsing contract with focused tests. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- android/samples/mobile-2/README.md | 27 ++- .../assets/typeagent/androidDeviceSchema.ts | 24 +++ .../typeagentchat/AlarmActionParser.kt | 3 +- .../typeagentchat/AndroidDeviceAgent.kt | 106 ++++++++++ .../com/example/typeagentchat/MainActivity.kt | 54 +++-- .../example/typeagentchat/WebSocketManager.kt | 192 +++++++++++++++++- .../typeagentchat/AlarmActionParserTest.kt | 15 +- .../typeagentchat/AndroidDeviceAgentTest.kt | 109 ++++++++++ 8 files changed, 501 insertions(+), 29 deletions(-) create mode 100644 android/samples/mobile-2/app/src/main/assets/typeagent/androidDeviceSchema.ts create mode 100644 android/samples/mobile-2/app/src/main/java/com/example/typeagentchat/AndroidDeviceAgent.kt create mode 100644 android/samples/mobile-2/app/src/test/java/com/example/typeagentchat/AndroidDeviceAgentTest.kt diff --git a/android/samples/mobile-2/README.md b/android/samples/mobile-2/README.md index 27464b84af..0b845dfa6e 100644 --- a/android/samples/mobile-2/README.md +++ b/android/samples/mobile-2/README.md @@ -8,6 +8,8 @@ An Android Jetpack Compose chat client that connects to a TypeAgent agent-server - OkHttp WebSocket usage on Android - TypeAgent agent-server RPC protocol: - `joinConversation` / `submitCommand` + - `registerClientAgent` with an inline action schema + - Client-hosted `executeAction` callbacks - Inbound `appendDisplay`, `setDisplay`, `setDisplayInfo`, and command completion events - Inbound `takeAction` client actions - Incremental assistant response streaming into a single bubble per `requestId`, honouring @@ -16,11 +18,15 @@ An Android Jetpack Compose chat client that connects to a TypeAgent agent-server - DevTunnel authentication via `X-Tunnel-Authorization` header - Build-time configuration via environment variables and `BuildConfig` -## Device actions +## Client-hosted Android agent -The app implements the `takeAction` client actions emitted by the `androidMobile` -agent. Unknown actions are logged and ignored, so the app stays compatible with -servers that emit actions this sample does not support. +After joining a conversation, the app registers `androidDevice` as a +client-hosted agent. Its alarm and timer schema is packaged in the APK and sent +inline to TypeAgent. TypeAgent translates or directly invokes the typed action, +then calls `executeAction` on the app over the existing WebSocket connection. + +The app also retains its existing `takeAction` handlers for compatibility with +the static `androidMobile` agent. | Client action | Android intent | Notes | |---|---|---| @@ -35,8 +41,16 @@ Both require the `com.android.alarm.permission.SET_ALARM` permission (declared i the manifest, install-time only) and matching `` entries so `resolveActivity` works under Android 11+ package visibility rules. -> `set-timer` requires the server-side `androidMobile` `setTimer` action. Install -> the agent with `@package install androidMobile` in the TypeAgent CLI/shell. +The registered client agent does not require installing the server-side +`androidMobile` package. Use `@action` for a deterministic registration test: + +```text +@action --parameters {"originalRequest":"timer","durationInSeconds":30} androidDevice setTimer +``` + +```text +@action --parameters {"originalRequest":"alarm","time":"12:00"} androidDevice setAlarm +``` ## Prerequisites @@ -83,4 +97,3 @@ The app connects automatically on launch. Tap **Retry** in the status bar if the [devtunnel]: https://learn.microsoft.com/en-us/azure/developer/dev-tunnels/ [devtunnel-cli]: https://learn.microsoft.com/en-us/azure/developer/dev-tunnels/get-started - diff --git a/android/samples/mobile-2/app/src/main/assets/typeagent/androidDeviceSchema.ts b/android/samples/mobile-2/app/src/main/assets/typeagent/androidDeviceSchema.ts new file mode 100644 index 0000000000..8e7965550e --- /dev/null +++ b/android/samples/mobile-2/app/src/main/assets/typeagent/androidDeviceSchema.ts @@ -0,0 +1,24 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +export type AndroidDeviceAction = SetAlarmAction | SetTimerAction; + +export type SetAlarmAction = { + actionName: "setAlarm"; + parameters: { + // The original user request, used as the alarm label. + originalRequest: string; + // Local time of day in HH:mm format. The device schedules the next occurrence. + time: string; + }; +}; + +export type SetTimerAction = { + actionName: "setTimer"; + parameters: { + // The original user request, used as the timer label. + originalRequest: string; + // Positive timer duration in seconds. + durationInSeconds: number; + }; +}; diff --git a/android/samples/mobile-2/app/src/main/java/com/example/typeagentchat/AlarmActionParser.kt b/android/samples/mobile-2/app/src/main/java/com/example/typeagentchat/AlarmActionParser.kt index ff8fef13f4..d4dad07492 100644 --- a/android/samples/mobile-2/app/src/main/java/com/example/typeagentchat/AlarmActionParser.kt +++ b/android/samples/mobile-2/app/src/main/java/com/example/typeagentchat/AlarmActionParser.kt @@ -8,7 +8,8 @@ internal data class SetAlarmAction( val minute: Int ) -private val alarmTimeRegex = Regex("""^\d{4}-\d{2}-\d{2}T(\d{2}):(\d{2})(?::\d{2})?$""") +private val alarmTimeRegex = + Regex("""^(?:\d{4}-\d{2}-\d{2}T)?(\d{2}):(\d{2})(?::\d{2})?$""") internal fun parseSetAlarmActionPayload(data: Any?): SetAlarmAction? { val payload = data as? JSONObject ?: return null diff --git a/android/samples/mobile-2/app/src/main/java/com/example/typeagentchat/AndroidDeviceAgent.kt b/android/samples/mobile-2/app/src/main/java/com/example/typeagentchat/AndroidDeviceAgent.kt new file mode 100644 index 0000000000..741438c911 --- /dev/null +++ b/android/samples/mobile-2/app/src/main/java/com/example/typeagentchat/AndroidDeviceAgent.kt @@ -0,0 +1,106 @@ +package com.example.typeagentchat + +import org.json.JSONArray +import org.json.JSONObject + +internal object AndroidDeviceAgent { + const val NAME = "androidDevice" + const val CHANNEL_NAME = "agent:$NAME" + const val SCHEMA_ASSET = "typeagent/androidDeviceSchema.ts" + + fun createRegistrationParams( + conversationId: String, + schemaContent: String + ): JSONObject { + val schemaFile = JSONObject() + .put("format", "ts") + .put("content", schemaContent) + val schema = JSONObject() + .put("description", "Sets alarms and countdown timers on this Android device.") + .put("schemaType", "AndroidDeviceAction") + .put("schemaFile", schemaFile) + val manifest = JSONObject() + .put("emojiChar", "\u23F0") + .put("description", "Sets alarms and countdown timers on this Android device.") + .put("defaultEnabled", true) + .put("schemaDefaultEnabled", true) + .put("actionDefaultEnabled", true) + .put("schema", schema) + + return JSONObject() + .put("name", NAME) + .put("conversationId", conversationId) + .put("manifest", manifest) + .put("agentInterface", JSONArray().put("executeAction")) + } + + fun parseExecuteAction(args: JSONArray): AndroidDeviceActionParseResult { + val invocation = args.optJSONObject(0) + ?: return AndroidDeviceActionParseResult.ProtocolError( + "executeAction requires an invocation object." + ) + val action = invocation.optJSONObject("action") + ?: return AndroidDeviceActionParseResult.ProtocolError( + "executeAction invocation is missing action." + ) + val actionName = action.optString("actionName").trim() + if (actionName.isEmpty()) { + return AndroidDeviceActionParseResult.ProtocolError( + "executeAction action is missing actionName." + ) + } + val parameters = action.optJSONObject("parameters") + ?: return AndroidDeviceActionParseResult.ActionError( + "Action '$actionName' is missing parameters." + ) + + return when (actionName) { + "setAlarm" -> { + val parsed = parseSetAlarmActionPayload(parameters) + ?: return AndroidDeviceActionParseResult.ActionError( + "Invalid setAlarm parameters." + ) + AndroidDeviceActionParseResult.Success(AndroidDeviceAction.Alarm(parsed)) + } + + "setTimer" -> { + val parsed = parseSetTimerActionPayload(parameters) + ?: return AndroidDeviceActionParseResult.ActionError( + "Invalid setTimer parameters." + ) + AndroidDeviceActionParseResult.Success(AndroidDeviceAction.Timer(parsed)) + } + + else -> AndroidDeviceActionParseResult.ActionError( + "Unsupported Android agent action: $actionName" + ) + } + } + + fun createSuccessResult(message: String): JSONObject { + return JSONObject() + .put("historyText", message) + .put("displayContent", message) + .put("entities", JSONArray()) + } + + fun createErrorResult(message: String): JSONObject { + return JSONObject().put("error", message) + } +} + +internal sealed interface AndroidDeviceAction { + data class Alarm(val action: SetAlarmAction) : AndroidDeviceAction + data class Timer(val action: SetTimerAction) : AndroidDeviceAction +} + +internal sealed interface AndroidDeviceActionParseResult { + data class Success(val action: AndroidDeviceAction) : AndroidDeviceActionParseResult + data class ActionError(val message: String) : AndroidDeviceActionParseResult + data class ProtocolError(val message: String) : AndroidDeviceActionParseResult +} + +internal sealed interface AndroidDeviceExecutionResult { + data class Success(val message: String) : AndroidDeviceExecutionResult + data class Failure(val message: String) : AndroidDeviceExecutionResult +} diff --git a/android/samples/mobile-2/app/src/main/java/com/example/typeagentchat/MainActivity.kt b/android/samples/mobile-2/app/src/main/java/com/example/typeagentchat/MainActivity.kt index b90b854dae..b81b2dcb37 100644 --- a/android/samples/mobile-2/app/src/main/java/com/example/typeagentchat/MainActivity.kt +++ b/android/samples/mobile-2/app/src/main/java/com/example/typeagentchat/MainActivity.kt @@ -75,17 +75,28 @@ class MainActivity : ComponentActivity() { private val webSocketManager = WebSocketManager() private val tunnelUrl = BuildConfig.TYPEAGENT_SERVER_URL.trim() private val tunnelToken = BuildConfig.TYPEAGENT_TUNNEL_TOKEN.trim().ifBlank { null } + private val agentSchemaContent by lazy { + assets.open(AndroidDeviceAgent.SCHEMA_ASSET) + .bufferedReader() + .use { it.readText() } + } override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) enableEdgeToEdge() webSocketManager.setClientActionHandler(object : WebSocketManager.ClientActionHandler { - override fun onSetAlarm(action: SetAlarmAction) { - runOnUiThread { launchSetAlarmIntent(action) } + override fun onSetAlarm( + action: SetAlarmAction, + completion: (AndroidDeviceExecutionResult) -> Unit + ) { + runOnUiThread { launchSetAlarmIntent(action, completion) } } - override fun onSetTimer(action: SetTimerAction) { - runOnUiThread { launchSetTimerIntent(action) } + override fun onSetTimer( + action: SetTimerAction, + completion: (AndroidDeviceExecutionResult) -> Unit + ) { + runOnUiThread { launchSetTimerIntent(action, completion) } } override fun onSearchNearby(action: SearchNearbyAction) { @@ -94,7 +105,8 @@ class MainActivity : ComponentActivity() { }) webSocketManager.connect( url = tunnelUrl, - tunnelToken = tunnelToken + tunnelToken = tunnelToken, + schemaContent = agentSchemaContent ) setContent { @@ -114,7 +126,10 @@ class MainActivity : ComponentActivity() { super.onDestroy() } - private fun launchSetAlarmIntent(action: SetAlarmAction) { + private fun launchSetAlarmIntent( + action: SetAlarmAction, + completion: (AndroidDeviceExecutionResult) -> Unit + ) { val intent = Intent(AlarmClock.ACTION_SET_ALARM).apply { putExtra(AlarmClock.EXTRA_HOUR, action.hour) putExtra(AlarmClock.EXTRA_MINUTES, action.minute) @@ -127,10 +142,14 @@ class MainActivity : ComponentActivity() { intent = intent, actionName = "set-alarm", detail = "hour=${action.hour} minute=${action.minute}", - successMessage = "Alarm set for %02d:%02d".format(action.hour, action.minute), + successMessage = "Alarm request sent for %02d:%02d".format( + action.hour, + action.minute + ), missingAppMessage = "No alarm app is available on this device.", deniedMessage = "This app is not allowed to set alarms.", - backgroundMessage = "Could not set the alarm while the app was in the background." + backgroundMessage = "Could not set the alarm while the app was in the background.", + completion = completion ) } @@ -145,7 +164,10 @@ class MainActivity : ComponentActivity() { * the only in-app feedback, so it is not optional - and it must not claim * success when the launch was refused. See [launchExternalIntent]. */ - private fun launchSetTimerIntent(action: SetTimerAction) { + private fun launchSetTimerIntent( + action: SetTimerAction, + completion: (AndroidDeviceExecutionResult) -> Unit + ) { val intent = Intent(AlarmClock.ACTION_SET_TIMER).apply { putExtra(AlarmClock.EXTRA_LENGTH, action.durationInSeconds) putExtra(AlarmClock.EXTRA_SKIP_UI, true) @@ -157,10 +179,12 @@ class MainActivity : ComponentActivity() { intent = intent, actionName = "set-timer", detail = "durationInSeconds=${action.durationInSeconds}", - successMessage = "Timer set for ${formatTimerDuration(action.durationInSeconds)}", + successMessage = + "Timer request sent for ${formatTimerDuration(action.durationInSeconds)}", missingAppMessage = "No timer app is available on this device.", deniedMessage = "This app is not allowed to set timers.", - backgroundMessage = "Could not set the timer while the app was in the background." + backgroundMessage = "Could not set the timer while the app was in the background.", + completion = completion ) } @@ -208,7 +232,8 @@ class MainActivity : ComponentActivity() { successMessage: String, missingAppMessage: String, deniedMessage: String, - backgroundMessage: String + backgroundMessage: String, + completion: (AndroidDeviceExecutionResult) -> Unit = {} ) { val target = intent.resolveActivity(packageManager) Log.d( @@ -218,6 +243,7 @@ class MainActivity : ComponentActivity() { if (target == null) { Log.e(TAG, "No app available to handle $actionName intent") Toast.makeText(this, missingAppMessage, Toast.LENGTH_SHORT).show() + completion(AndroidDeviceExecutionResult.Failure(missingAppMessage)) return } if (!lifecycle.currentState.isAtLeast(Lifecycle.State.RESUMED)) { @@ -227,18 +253,22 @@ class MainActivity : ComponentActivity() { "background activity starts are refused without an exception" ) Toast.makeText(this, backgroundMessage, Toast.LENGTH_LONG).show() + completion(AndroidDeviceExecutionResult.Failure(backgroundMessage)) return } try { startActivity(intent) Log.d(TAG, "$actionName intent dispatched") Toast.makeText(this, successMessage, Toast.LENGTH_SHORT).show() + completion(AndroidDeviceExecutionResult.Success(successMessage)) } catch (_: ActivityNotFoundException) { Log.e(TAG, "No app available to handle $actionName intent") Toast.makeText(this, missingAppMessage, Toast.LENGTH_SHORT).show() + completion(AndroidDeviceExecutionResult.Failure(missingAppMessage)) } catch (error: SecurityException) { Log.e(TAG, "Missing permission for $actionName", error) Toast.makeText(this, deniedMessage, Toast.LENGTH_SHORT).show() + completion(AndroidDeviceExecutionResult.Failure(deniedMessage)) } } diff --git a/android/samples/mobile-2/app/src/main/java/com/example/typeagentchat/WebSocketManager.kt b/android/samples/mobile-2/app/src/main/java/com/example/typeagentchat/WebSocketManager.kt index 77da47c242..9c688f3c2a 100644 --- a/android/samples/mobile-2/app/src/main/java/com/example/typeagentchat/WebSocketManager.kt +++ b/android/samples/mobile-2/app/src/main/java/com/example/typeagentchat/WebSocketManager.kt @@ -11,6 +11,7 @@ import okhttp3.WebSocketListener import org.json.JSONArray import org.json.JSONObject import java.util.concurrent.TimeUnit +import java.util.concurrent.atomic.AtomicBoolean import java.util.concurrent.atomic.AtomicInteger class WebSocketManager { @@ -28,6 +29,8 @@ class WebSocketManager { private var webSocket: WebSocket? = null private var conversationId: String? = null private var connectionId: String? = null + private var agentSchemaContent: String? = null + private var isClientAgentRegistered = false private var pendingUserInteraction: PendingUserInteraction? = null private var clientActionHandler: ClientActionHandler? = null @@ -52,7 +55,8 @@ class WebSocketManager { fun connect( url: String, - tunnelToken: String? = null + tunnelToken: String? = null, + schemaContent: String? = null ) { val targetUrl = url.trim() if (targetUrl.isBlank()) { @@ -64,12 +68,27 @@ class WebSocketManager { ) return } + val resolvedSchemaContent = schemaContent + ?.trim() + ?.takeIf { it.isNotEmpty() } + ?: synchronized(lock) { agentSchemaContent } + if (resolvedSchemaContent.isNullOrBlank()) { + val errorMessage = "The Android alarm and timer schema is unavailable." + Log.e(TAG, errorMessage) + _connectionStatus.value = ConnectionStatus( + text = errorMessage, + state = ConnectionStatus.State.ERROR + ) + return + } synchronized(lock) { pendingInvokes.clear() pendingUserInteraction = null conversationId = null connectionId = null + agentSchemaContent = resolvedSchemaContent + isClientAgentRegistered = false displayThreads.clear() displayMessageIds.clear() } @@ -112,6 +131,7 @@ class WebSocketManager { pendingUserInteraction = null conversationId = null connectionId = null + isClientAgentRegistered = false finalizeOpenDisplayThreads() } _pendingYesNoPrompt.value = null @@ -132,6 +152,7 @@ class WebSocketManager { failPendingInvokes(errorMessage) synchronized(lock) { pendingUserInteraction = null + isClientAgentRegistered = false finalizeOpenDisplayThreads() } _pendingYesNoPrompt.value = null @@ -213,6 +234,7 @@ class WebSocketManager { webSocket = null synchronized(lock) { pendingUserInteraction = null + isClientAgentRegistered = false finalizeOpenDisplayThreads() } _pendingYesNoPrompt.value = null @@ -291,15 +313,64 @@ class WebSocketManager { TAG, "TypeAgent conversation joined: connectionId=$joinedConnectionId conversationId=$joinedConversationId" ) + registerClientAgent(joinedConversationId) + }, + onError = { error -> + Log.e(TAG, "joinConversation error: $error") + _connectionStatus.value = ConnectionStatus( + text = "Error: $error", + state = ConnectionStatus.State.ERROR + ) + } + ) + } + + private fun registerClientAgent(joinedConversationId: String) { + val schemaContent = synchronized(lock) { agentSchemaContent } + if (schemaContent.isNullOrBlank()) { + val errorMessage = "The Android alarm and timer schema is unavailable." + Log.e(TAG, errorMessage) + _connectionStatus.value = ConnectionStatus( + text = errorMessage, + state = ConnectionStatus.State.ERROR + ) + return + } + + _connectionStatus.value = ConnectionStatus( + text = "Registering Android actions...", + state = ConnectionStatus.State.CONNECTING + ) + sendInvoke( + channelName = AGENT_SERVER_CHANNEL, + methodName = "registerClientAgent", + args = listOf( + AndroidDeviceAgent.createRegistrationParams( + conversationId = joinedConversationId, + schemaContent = schemaContent + ) + ), + onResult = { + synchronized(lock) { + isClientAgentRegistered = true + } + Log.d( + TAG, + "Registered client agent ${AndroidDeviceAgent.NAME} " + + "for conversation $joinedConversationId" + ) _connectionStatus.value = ConnectionStatus( - text = "Connected", + text = "Connected - Android actions registered", state = ConnectionStatus.State.CONNECTED ) }, onError = { error -> - Log.e(TAG, "joinConversation error: $error") + synchronized(lock) { + isClientAgentRegistered = false + } + Log.e(TAG, "registerClientAgent error: $error") _connectionStatus.value = ConnectionStatus( - text = "Error: $error", + text = "Agent registration failed: $error", state = ConnectionStatus.State.ERROR ) } @@ -386,6 +457,16 @@ class WebSocketManager { TAG, "RPC invoke channel=$channelName method=$methodName callId=$callId argCount=${args.length()}" ) + if (channelName == AndroidDeviceAgent.CHANNEL_NAME) { + handleAndroidDeviceInvoke( + channelName = channelName, + methodName = methodName, + callId = callId, + args = args + ) + return + } + val result = when (methodName) { "getUserContext" -> JSONObject.NULL "question" -> handleQuestionInvoke(args) @@ -399,6 +480,93 @@ class WebSocketManager { } } + private fun handleAndroidDeviceInvoke( + channelName: String, + methodName: String, + callId: Int, + args: JSONArray + ) { + if (callId < 0) { + Log.e(TAG, "Android agent invocation is missing callId.") + return + } + if (methodName != "executeAction") { + sendRpcError( + channelName, + callId, + "Unsupported Android agent RPC method: $methodName" + ) + return + } + if (!synchronized(lock) { isClientAgentRegistered }) { + sendRpcError(channelName, callId, "Android client agent is not registered.") + return + } + + when (val parsed = AndroidDeviceAgent.parseExecuteAction(args)) { + is AndroidDeviceActionParseResult.ProtocolError -> { + sendRpcError(channelName, callId, parsed.message) + } + + is AndroidDeviceActionParseResult.ActionError -> { + sendRpcResult( + channelName, + callId, + AndroidDeviceAgent.createErrorResult(parsed.message) + ) + } + + is AndroidDeviceActionParseResult.Success -> { + executeAndroidDeviceAction( + channelName = channelName, + callId = callId, + action = parsed.action + ) + } + } + } + + private fun executeAndroidDeviceAction( + channelName: String, + callId: Int, + action: AndroidDeviceAction + ) { + val handler = synchronized(lock) { clientActionHandler } + if (handler == null) { + sendRpcResult( + channelName, + callId, + AndroidDeviceAgent.createErrorResult( + "The Android activity is not ready to execute actions." + ) + ) + return + } + + val completed = AtomicBoolean(false) + val generation = connectionGeneration.get() + val completion: (AndroidDeviceExecutionResult) -> Unit = { result -> + if (!completed.compareAndSet(false, true)) { + Log.w(TAG, "Ignoring duplicate completion for agent callId=$callId") + } else if (connectionGeneration.get() != generation) { + Log.w(TAG, "Ignoring completion for stale agent callId=$callId") + } else { + val actionResult = when (result) { + is AndroidDeviceExecutionResult.Success -> + AndroidDeviceAgent.createSuccessResult(result.message) + is AndroidDeviceExecutionResult.Failure -> + AndroidDeviceAgent.createErrorResult(result.message) + } + sendRpcResult(channelName, callId, actionResult) + } + } + + when (action) { + is AndroidDeviceAction.Alarm -> handler.onSetAlarm(action.action, completion) + is AndroidDeviceAction.Timer -> handler.onSetTimer(action.action, completion) + } + } + private fun handleClientIoCall(methodName: String, args: JSONArray) { when (methodName) { "appendDisplay" -> { @@ -576,7 +744,7 @@ class WebSocketManager { TAG, "Dispatching set-alarm to client handler hour=${alarm.hour} minute=${alarm.minute}" ) - handler.onSetAlarm(alarm) + handler.onSetAlarm(alarm) {} } private fun handleSetTimerAction(actionData: Any?) { @@ -596,7 +764,7 @@ class WebSocketManager { TAG, "Dispatching set-timer to client handler durationInSeconds=${timer.durationInSeconds}" ) - handler.onSetTimer(timer) + handler.onSetTimer(timer) {} } private fun handleSearchNearbyAction(actionData: Any?) { @@ -1296,8 +1464,16 @@ class WebSocketManager { } internal interface ClientActionHandler { - fun onSetAlarm(action: SetAlarmAction) - fun onSetTimer(action: SetTimerAction) + fun onSetAlarm( + action: SetAlarmAction, + completion: (AndroidDeviceExecutionResult) -> Unit + ) + + fun onSetTimer( + action: SetTimerAction, + completion: (AndroidDeviceExecutionResult) -> Unit + ) + fun onSearchNearby(action: SearchNearbyAction) } } diff --git a/android/samples/mobile-2/app/src/test/java/com/example/typeagentchat/AlarmActionParserTest.kt b/android/samples/mobile-2/app/src/test/java/com/example/typeagentchat/AlarmActionParserTest.kt index 726ca814af..7ba9e4ef02 100644 --- a/android/samples/mobile-2/app/src/test/java/com/example/typeagentchat/AlarmActionParserTest.kt +++ b/android/samples/mobile-2/app/src/test/java/com/example/typeagentchat/AlarmActionParserTest.kt @@ -21,13 +21,26 @@ class AlarmActionParserTest { } @Test - fun `rejects invalid set-alarm payload format`() { + fun `parses time-of-day set-alarm payload`() { val alarm = parseSetAlarmActionPayload( JSONObject() .put("originalRequest", "Set alarm") .put("time", "06:30") ) + requireNotNull(alarm) + assertEquals(6, alarm.hour) + assertEquals(30, alarm.minute) + } + + @Test + fun `rejects invalid set-alarm payload format`() { + val alarm = parseSetAlarmActionPayload( + JSONObject() + .put("originalRequest", "Set alarm") + .put("time", "6:30 PM") + ) + assertNull(alarm) } diff --git a/android/samples/mobile-2/app/src/test/java/com/example/typeagentchat/AndroidDeviceAgentTest.kt b/android/samples/mobile-2/app/src/test/java/com/example/typeagentchat/AndroidDeviceAgentTest.kt new file mode 100644 index 0000000000..653f61d7ef --- /dev/null +++ b/android/samples/mobile-2/app/src/test/java/com/example/typeagentchat/AndroidDeviceAgentTest.kt @@ -0,0 +1,109 @@ +package com.example.typeagentchat + +import org.json.JSONArray +import org.json.JSONObject +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test + +class AndroidDeviceAgentTest { + @Test + fun registrationIncludesInlineSchemaAndExecuteAction() { + val registration = AndroidDeviceAgent.createRegistrationParams( + conversationId = "conversation-1", + schemaContent = "export type AndroidDeviceAction = never;" + ) + + assertEquals(AndroidDeviceAgent.NAME, registration.getString("name")) + assertEquals("conversation-1", registration.getString("conversationId")) + assertEquals( + "executeAction", + registration.getJSONArray("agentInterface").getString(0) + ) + assertEquals( + "export type AndroidDeviceAction = never;", + registration + .getJSONObject("manifest") + .getJSONObject("schema") + .getJSONObject("schemaFile") + .getString("content") + ) + } + + @Test + fun parsesSetTimerExecuteAction() { + val parameters = JSONObject() + .put("originalRequest", "Set a timer for 30 seconds") + .put("durationInSeconds", 30) + val action = JSONObject() + .put("actionName", "setTimer") + .put("parameters", parameters) + val args = JSONArray().put(JSONObject().put("action", action)) + + val parsed = AndroidDeviceAgent.parseExecuteAction(args) + + assertTrue(parsed is AndroidDeviceActionParseResult.Success) + val timer = (parsed as AndroidDeviceActionParseResult.Success).action + as AndroidDeviceAction.Timer + assertEquals(30, timer.action.durationInSeconds) + } + + @Test + fun parsesSetAlarmExecuteAction() { + val parameters = JSONObject() + .put("originalRequest", "Set an alarm for 6:30") + .put("time", "06:30") + val action = JSONObject() + .put("actionName", "setAlarm") + .put("parameters", parameters) + val args = JSONArray().put(JSONObject().put("action", action)) + + val parsed = AndroidDeviceAgent.parseExecuteAction(args) + + assertTrue(parsed is AndroidDeviceActionParseResult.Success) + val alarm = (parsed as AndroidDeviceActionParseResult.Success).action + as AndroidDeviceAction.Alarm + assertEquals(6, alarm.action.hour) + assertEquals(30, alarm.action.minute) + } + + @Test + fun classifiesInvalidParametersAsActionError() { + val action = JSONObject() + .put("actionName", "setTimer") + .put( + "parameters", + JSONObject() + .put("originalRequest", "Set an invalid timer") + .put("durationInSeconds", 0) + ) + val args = JSONArray().put(JSONObject().put("action", action)) + + val parsed = AndroidDeviceAgent.parseExecuteAction(args) + + assertTrue(parsed is AndroidDeviceActionParseResult.ActionError) + } + + @Test + fun classifiesUnsupportedActionAsActionError() { + val action = JSONObject() + .put("actionName", "openMaps") + .put("parameters", JSONObject()) + val args = JSONArray().put(JSONObject().put("action", action)) + + val parsed = AndroidDeviceAgent.parseExecuteAction(args) + + assertTrue(parsed is AndroidDeviceActionParseResult.ActionError) + } + + @Test + fun serializesActionResults() { + val success = AndroidDeviceAgent.createSuccessResult("Timer request sent for 30 seconds") + val failure = AndroidDeviceAgent.createErrorResult("No timer app") + + assertEquals("Timer request sent for 30 seconds", success.getString("historyText")) + assertEquals("Timer request sent for 30 seconds", success.getString("displayContent")) + assertEquals(0, success.getJSONArray("entities").length()) + assertEquals("No timer app", failure.getString("error")) + } +} From 54522f8336a7095bbd8def764bb594eea411c32a Mon Sep 17 00:00:00 2001 From: Jebran Syed Date: Wed, 12 Aug 2026 11:15:23 -0700 Subject: [PATCH 2/2] Remove legacy takeAction path from Android sample The mobile-2 sample had two parallel mechanisms for receiving device commands: the fire-and-forget `takeAction` calls pushed by the server-side `androidMobile` agent over the `clientio:` channel, and the client-registered `androidDevice` agent that receives `executeAction` invokes and returns a result. The former is strictly worse - it duplicated the alarm and timer handlers but discarded the outcome, so the agent never learned whether the intent actually launched. Delete the `takeAction` path and make `registerClientAgent` the sole onboarding and command mechanism. `searchNearby` existed only on the legacy path, so it moves into the client-agent schema first; it now reports success or failure back like the other two actions instead of being fired blind. `clientio:` is retained - it remains the only transport for display and user-interaction traffic. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- android/samples/mobile-2/README.md | 30 ++--- .../assets/typeagent/androidDeviceSchema.ts | 21 +++- .../typeagentchat/AndroidDeviceAgent.kt | 15 ++- .../com/example/typeagentchat/MainActivity.kt | 31 +++-- .../typeagentchat/SearchNearbyActionParser.kt | 7 +- .../typeagentchat/TimerActionParser.kt | 10 +- .../example/typeagentchat/WebSocketManager.kt | 109 +----------------- .../typeagentchat/AndroidDeviceAgentTest.kt | 37 +++++- 8 files changed, 124 insertions(+), 136 deletions(-) diff --git a/android/samples/mobile-2/README.md b/android/samples/mobile-2/README.md index 0b845dfa6e..fcbec853cc 100644 --- a/android/samples/mobile-2/README.md +++ b/android/samples/mobile-2/README.md @@ -11,7 +11,6 @@ An Android Jetpack Compose chat client that connects to a TypeAgent agent-server - `registerClientAgent` with an inline action schema - Client-hosted `executeAction` callbacks - Inbound `appendDisplay`, `setDisplay`, `setDisplayInfo`, and command completion events - - Inbound `takeAction` client actions - Incremental assistant response streaming into a single bubble per `requestId`, honouring the SDK's `DisplayAppendMode` (`inline`, `block`, `temporary`, `step`) and `DisplayMessageKind` styling the same way the Electron shell does @@ -21,25 +20,30 @@ An Android Jetpack Compose chat client that connects to a TypeAgent agent-server ## Client-hosted Android agent After joining a conversation, the app registers `androidDevice` as a -client-hosted agent. Its alarm and timer schema is packaged in the APK and sent -inline to TypeAgent. TypeAgent translates or directly invokes the typed action, -then calls `executeAction` on the app over the existing WebSocket connection. +client-hosted agent. Its action schema is packaged in the APK and sent inline to +TypeAgent. TypeAgent translates or directly invokes the typed action, then calls +`executeAction` on the app over the existing WebSocket connection, and the app +reports success or failure back as the action result. -The app also retains its existing `takeAction` handlers for compatibility with -the static `androidMobile` agent. +This is the only path for device actions. The legacy fire-and-forget +`takeAction` path served by the server-side `androidMobile` agent has been +removed; the `clientio:` channel is still used, but only for display and user +interaction traffic. -| Client action | Android intent | Notes | +| Schema action | Android intent | Notes | |---|---|---| -| `set-alarm` | `AlarmClock.ACTION_SET_ALARM` | Opens the clock app so the user can confirm the alarm. | -| `set-timer` | `AlarmClock.ACTION_SET_TIMER` | Starts the countdown in the background (`EXTRA_SKIP_UI = true`) and confirms with a toast, so a chat request never yanks the user out of the conversation. Durations outside the documented 1..86400 second range are rejected rather than clamped. | +| `setAlarm` | `AlarmClock.ACTION_SET_ALARM` | Opens the clock app so the user can confirm the alarm. | +| `setTimer` | `AlarmClock.ACTION_SET_TIMER` | Starts the countdown in the background (`EXTRA_SKIP_UI = true`) and confirms with a toast, so a chat request never yanks the user out of the conversation. Durations outside the documented 1..86400 second range are rejected rather than clamped. | +| `searchNearby` | `Intent.ACTION_VIEW` with a `geo:0,0?q=` URI | Opens the device's maps app on a local search. The intent is implicit rather than pinned to Google Maps, so it resolves on any device with a maps app. | -Both actions require the app to be in the foreground: Android 10+ silently refuses +All actions require the app to be in the foreground: Android 10+ silently refuses background activity starts (no exception is thrown), so the app checks its own lifecycle state first and reports a failure rather than a false confirmation. -Both require the `com.android.alarm.permission.SET_ALARM` permission (declared in -the manifest, install-time only) and matching `` entries so -`resolveActivity` works under Android 11+ package visibility rules. +The clock actions require the `com.android.alarm.permission.SET_ALARM` permission +(declared in the manifest, install-time only). Every action needs a matching +`` entry so `resolveActivity` works under Android 11+ package +visibility rules. The registered client agent does not require installing the server-side `androidMobile` package. Use `@action` for a deterministic registration test: diff --git a/android/samples/mobile-2/app/src/main/assets/typeagent/androidDeviceSchema.ts b/android/samples/mobile-2/app/src/main/assets/typeagent/androidDeviceSchema.ts index 8e7965550e..f88ccac76b 100644 --- a/android/samples/mobile-2/app/src/main/assets/typeagent/androidDeviceSchema.ts +++ b/android/samples/mobile-2/app/src/main/assets/typeagent/androidDeviceSchema.ts @@ -1,8 +1,13 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. -export type AndroidDeviceAction = SetAlarmAction | SetTimerAction; +export type AndroidDeviceAction = + | SetAlarmAction + | SetTimerAction + | SearchNearbyAction; +// Sets an alarm on the Android device. +// Use when the user asks to create or schedule an alarm for a time of day. export type SetAlarmAction = { actionName: "setAlarm"; parameters: { @@ -13,6 +18,8 @@ export type SetAlarmAction = { }; }; +// Starts a countdown timer on the Android device. +// Use when the user asks for a timer or countdown lasting a specified duration. export type SetTimerAction = { actionName: "setTimer"; parameters: { @@ -22,3 +29,15 @@ export type SetTimerAction = { durationInSeconds: number; }; }; + +// Opens the Android device's maps app on a search for places near the user. +// Use when the user asks to find, locate or show places nearby. +export type SearchNearbyAction = { + actionName: "searchNearby"; + parameters: { + // The original user request. + originalRequest: string; + // The kind of place to look for, for example "coffee shops". + searchTerm: string; + }; +}; diff --git a/android/samples/mobile-2/app/src/main/java/com/example/typeagentchat/AndroidDeviceAgent.kt b/android/samples/mobile-2/app/src/main/java/com/example/typeagentchat/AndroidDeviceAgent.kt index 741438c911..35e01c46ab 100644 --- a/android/samples/mobile-2/app/src/main/java/com/example/typeagentchat/AndroidDeviceAgent.kt +++ b/android/samples/mobile-2/app/src/main/java/com/example/typeagentchat/AndroidDeviceAgent.kt @@ -7,6 +7,8 @@ internal object AndroidDeviceAgent { const val NAME = "androidDevice" const val CHANNEL_NAME = "agent:$NAME" const val SCHEMA_ASSET = "typeagent/androidDeviceSchema.ts" + private const val AGENT_DESCRIPTION = + "Sets alarms and countdown timers, and searches for nearby places, on this Android device." fun createRegistrationParams( conversationId: String, @@ -16,12 +18,12 @@ internal object AndroidDeviceAgent { .put("format", "ts") .put("content", schemaContent) val schema = JSONObject() - .put("description", "Sets alarms and countdown timers on this Android device.") + .put("description", AGENT_DESCRIPTION) .put("schemaType", "AndroidDeviceAction") .put("schemaFile", schemaFile) val manifest = JSONObject() .put("emojiChar", "\u23F0") - .put("description", "Sets alarms and countdown timers on this Android device.") + .put("description", AGENT_DESCRIPTION) .put("defaultEnabled", true) .put("schemaDefaultEnabled", true) .put("actionDefaultEnabled", true) @@ -71,6 +73,14 @@ internal object AndroidDeviceAgent { AndroidDeviceActionParseResult.Success(AndroidDeviceAction.Timer(parsed)) } + "searchNearby" -> { + val parsed = parseSearchNearbyActionPayload(parameters) + ?: return AndroidDeviceActionParseResult.ActionError( + "Invalid searchNearby parameters." + ) + AndroidDeviceActionParseResult.Success(AndroidDeviceAction.SearchNearby(parsed)) + } + else -> AndroidDeviceActionParseResult.ActionError( "Unsupported Android agent action: $actionName" ) @@ -92,6 +102,7 @@ internal object AndroidDeviceAgent { internal sealed interface AndroidDeviceAction { data class Alarm(val action: SetAlarmAction) : AndroidDeviceAction data class Timer(val action: SetTimerAction) : AndroidDeviceAction + data class SearchNearby(val action: SearchNearbyAction) : AndroidDeviceAction } internal sealed interface AndroidDeviceActionParseResult { diff --git a/android/samples/mobile-2/app/src/main/java/com/example/typeagentchat/MainActivity.kt b/android/samples/mobile-2/app/src/main/java/com/example/typeagentchat/MainActivity.kt index b81b2dcb37..2645d65e0a 100644 --- a/android/samples/mobile-2/app/src/main/java/com/example/typeagentchat/MainActivity.kt +++ b/android/samples/mobile-2/app/src/main/java/com/example/typeagentchat/MainActivity.kt @@ -99,8 +99,11 @@ class MainActivity : ComponentActivity() { runOnUiThread { launchSetTimerIntent(action, completion) } } - override fun onSearchNearby(action: SearchNearbyAction) { - runOnUiThread { launchSearchNearbyIntent(action) } + override fun onSearchNearby( + action: SearchNearbyAction, + completion: (AndroidDeviceExecutionResult) -> Unit + ) { + runOnUiThread { launchSearchNearbyIntent(action, completion) } } }) webSocketManager.connect( @@ -154,7 +157,8 @@ class MainActivity : ComponentActivity() { } /** - * Handles `takeAction("set-timer", ...)` from the androidMobile agent. + * Handles the `setTimer` action of the registered `androidDevice` client + * agent. * * `EXTRA_SKIP_UI` is true so the clock app starts the countdown in the * background instead of coming to the foreground. The reference @@ -189,12 +193,22 @@ class MainActivity : ComponentActivity() { } /** - * Opens the device maps app on a local search. The intent is left implicit - * rather than pinned to `com.google.android.apps.maps` as TypeAgent's + * Handles the `searchNearby` action of the registered `androidDevice` + * client agent by opening the device maps app on a local search. + * + * Unlike the clock intents there is no `EXTRA_SKIP_UI` equivalent, so maps + * necessarily comes to the foreground; that makes the RESUMED guard in + * [launchExternalIntent] load-bearing. + * + * The intent is left implicit rather than pinned to + * `com.google.android.apps.maps` as TypeAgent's * `JavaScriptInterface.searchNearby` does, so it still resolves on devices * without Google Maps. */ - private fun launchSearchNearbyIntent(action: SearchNearbyAction) { + private fun launchSearchNearbyIntent( + action: SearchNearbyAction, + completion: (AndroidDeviceExecutionResult) -> Unit + ) { val intent = Intent(Intent.ACTION_VIEW, Uri.parse(buildGeoSearchUri(action.searchTerm))) launchExternalIntent( intent = intent, @@ -203,7 +217,8 @@ class MainActivity : ComponentActivity() { successMessage = "Searching nearby for ${action.searchTerm}", missingAppMessage = "No maps app is available on this device.", deniedMessage = "This app is not allowed to open the maps app.", - backgroundMessage = "Could not open maps while the app was in the background." + backgroundMessage = "Could not open maps while the app was in the background.", + completion = completion ) } @@ -233,7 +248,7 @@ class MainActivity : ComponentActivity() { missingAppMessage: String, deniedMessage: String, backgroundMessage: String, - completion: (AndroidDeviceExecutionResult) -> Unit = {} + completion: (AndroidDeviceExecutionResult) -> Unit ) { val target = intent.resolveActivity(packageManager) Log.d( diff --git a/android/samples/mobile-2/app/src/main/java/com/example/typeagentchat/SearchNearbyActionParser.kt b/android/samples/mobile-2/app/src/main/java/com/example/typeagentchat/SearchNearbyActionParser.kt index 55b8e15522..ad8325383c 100644 --- a/android/samples/mobile-2/app/src/main/java/com/example/typeagentchat/SearchNearbyActionParser.kt +++ b/android/samples/mobile-2/app/src/main/java/com/example/typeagentchat/SearchNearbyActionParser.kt @@ -19,9 +19,10 @@ private val controlCharRegex = Regex("""\p{Cntrl}""") private val whitespaceRunRegex = Regex("""\s+""") /** - * Parses `takeAction("search-nearby", ...)` from the androidMobile agent: - * `{ originalRequest: string; searchTerm: string }`. Re-validated here because - * `takeAction` is fire-and-forget and carries no schema guarantee over the wire. + * Parses the `parameters` of the `searchNearby` action declared by + * `androidDeviceSchema.ts`: `{ originalRequest: string; searchTerm: string }`. + * Re-validated here because the values are shaped by an LLM and reach + * `startActivity` unmodified. */ internal fun parseSearchNearbyActionPayload(data: Any?): SearchNearbyAction? { val payload = data as? JSONObject ?: return null diff --git a/android/samples/mobile-2/app/src/main/java/com/example/typeagentchat/TimerActionParser.kt b/android/samples/mobile-2/app/src/main/java/com/example/typeagentchat/TimerActionParser.kt index b4b3cedffb..20814fb8fd 100644 --- a/android/samples/mobile-2/app/src/main/java/com/example/typeagentchat/TimerActionParser.kt +++ b/android/samples/mobile-2/app/src/main/java/com/example/typeagentchat/TimerActionParser.kt @@ -26,16 +26,16 @@ private const val MAX_TIMER_SECONDS = 86_400L private const val MAX_ORIGINAL_REQUEST_CHARS = 256 /** - * Parses the payload of `takeAction("set-timer", ...)` emitted by the - * androidMobile agent's `SetTimerAction` (TypeAgent PR #2780): + * Parses the `parameters` of the `setTimer` action declared by + * `androidDeviceSchema.ts`: * * ```ts * parameters: { originalRequest: string; durationInSeconds: number } * ``` * - * The agent already floors the value and rejects non-positive durations, but - * this client re-validates because `takeAction` is fire-and-forget and carries - * no schema guarantee over the wire. + * The server-side dispatcher already validates against the schema, but this + * client re-validates because the value is shaped by an LLM and reaches + * `startActivity` unmodified. */ internal fun parseSetTimerActionPayload(data: Any?): SetTimerAction? { val payload = data as? JSONObject ?: return null diff --git a/android/samples/mobile-2/app/src/main/java/com/example/typeagentchat/WebSocketManager.kt b/android/samples/mobile-2/app/src/main/java/com/example/typeagentchat/WebSocketManager.kt index 9c688f3c2a..8c3e03c051 100644 --- a/android/samples/mobile-2/app/src/main/java/com/example/typeagentchat/WebSocketManager.kt +++ b/android/samples/mobile-2/app/src/main/java/com/example/typeagentchat/WebSocketManager.kt @@ -440,9 +440,6 @@ class WebSocketManager { TAG, "RPC call channel=$channelName method=$methodName argCount=${args.length()}" ) - if (methodName == "takeAction") { - Log.d(TAG, "RPC call raw takeAction args=$args") - } when { channelName.startsWith(CLIENT_IO_CHANNEL_PREFIX) -> handleClientIoCall(methodName, args) else -> Log.d(TAG, "Unhandled RPC call channel=$channelName method=$methodName") @@ -564,6 +561,8 @@ class WebSocketManager { when (action) { is AndroidDeviceAction.Alarm -> handler.onSetAlarm(action.action, completion) is AndroidDeviceAction.Timer -> handler.onSetTimer(action.action, completion) + is AndroidDeviceAction.SearchNearby -> + handler.onSearchNearby(action.action, completion) } } @@ -691,10 +690,6 @@ class WebSocketManager { } } - "takeAction" -> { - handleTakeActionCall(args) - } - else -> { val requestId = extractRequestId(args.opt(0)) logInboundEvent( @@ -706,101 +701,6 @@ class WebSocketManager { } } - private fun handleTakeActionCall(args: JSONArray) { - val requestId = extractRequestId(args.opt(0)) - val actionName = args.optString(1).orEmpty() - val actionData = args.optNullable(2) - logInboundEvent( - type = "take-action:$actionName", - requestId = requestId, - content = stringifyDisplayValue(actionData) - ) - Log.d( - TAG, - "takeAction received action=$actionName requestId=${requestId.orEmpty()} data=${stringifyDisplayValue(actionData)}" - ) - when (actionName) { - "set-alarm" -> handleSetAlarmAction(actionData) - "set-timer" -> handleSetTimerAction(actionData) - "search-nearby" -> handleSearchNearbyAction(actionData) - else -> Log.d(TAG, "takeAction ignored: unsupported action=$actionName") - } - } - - private fun handleSetAlarmAction(actionData: Any?) { - val alarm = parseSetAlarmActionPayload(actionData) - if (alarm == null) { - Log.e( - TAG, - "Invalid set-alarm payload: ${stringifyDisplayValue(actionData)}" - ) - return - } - val handler = requireClientActionHandler( - "set-alarm", - "hour=${alarm.hour} minute=${alarm.minute}" - ) ?: return - Log.d( - TAG, - "Dispatching set-alarm to client handler hour=${alarm.hour} minute=${alarm.minute}" - ) - handler.onSetAlarm(alarm) {} - } - - private fun handleSetTimerAction(actionData: Any?) { - val timer = parseSetTimerActionPayload(actionData) - if (timer == null) { - Log.e( - TAG, - "Invalid set-timer payload: ${stringifyDisplayValue(actionData)}" - ) - return - } - val handler = requireClientActionHandler( - "set-timer", - "durationInSeconds=${timer.durationInSeconds}" - ) ?: return - Log.d( - TAG, - "Dispatching set-timer to client handler durationInSeconds=${timer.durationInSeconds}" - ) - handler.onSetTimer(timer) {} - } - - private fun handleSearchNearbyAction(actionData: Any?) { - val search = parseSearchNearbyActionPayload(actionData) - if (search == null) { - Log.e( - TAG, - "Invalid search-nearby payload: ${stringifyDisplayValue(actionData)}" - ) - return - } - val handler = requireClientActionHandler( - "search-nearby", - "searchTerm=${search.searchTerm}" - ) ?: return - Log.d( - TAG, - "Dispatching search-nearby to client handler searchTerm=${search.searchTerm}" - ) - handler.onSearchNearby(search) - } - - private fun requireClientActionHandler( - actionName: String, - detail: String - ): ClientActionHandler? { - val handler = synchronized(lock) { clientActionHandler } - if (handler == null) { - Log.e( - TAG, - "$actionName parsed ($detail) but no client action handler is registered" - ) - } - return handler - } - private fun handleDisplayLogEvent(event: JSONObject) { val eventType = event.optString("type") when (eventType) { @@ -1474,7 +1374,10 @@ class WebSocketManager { completion: (AndroidDeviceExecutionResult) -> Unit ) - fun onSearchNearby(action: SearchNearbyAction) + fun onSearchNearby( + action: SearchNearbyAction, + completion: (AndroidDeviceExecutionResult) -> Unit + ) } } diff --git a/android/samples/mobile-2/app/src/test/java/com/example/typeagentchat/AndroidDeviceAgentTest.kt b/android/samples/mobile-2/app/src/test/java/com/example/typeagentchat/AndroidDeviceAgentTest.kt index 653f61d7ef..2900d4357b 100644 --- a/android/samples/mobile-2/app/src/test/java/com/example/typeagentchat/AndroidDeviceAgentTest.kt +++ b/android/samples/mobile-2/app/src/test/java/com/example/typeagentchat/AndroidDeviceAgentTest.kt @@ -67,6 +67,41 @@ class AndroidDeviceAgentTest { assertEquals(30, alarm.action.minute) } + @Test + fun parsesSearchNearbyExecuteAction() { + val parameters = JSONObject() + .put("originalRequest", "Find coffee shops near me") + .put("searchTerm", "coffee shops") + val action = JSONObject() + .put("actionName", "searchNearby") + .put("parameters", parameters) + val args = JSONArray().put(JSONObject().put("action", action)) + + val parsed = AndroidDeviceAgent.parseExecuteAction(args) + + assertTrue(parsed is AndroidDeviceActionParseResult.Success) + val search = (parsed as AndroidDeviceActionParseResult.Success).action + as AndroidDeviceAction.SearchNearby + assertEquals("coffee shops", search.action.searchTerm) + } + + @Test + fun classifiesInvalidSearchNearbyParametersAsActionError() { + val action = JSONObject() + .put("actionName", "searchNearby") + .put( + "parameters", + JSONObject() + .put("originalRequest", "Find something") + .put("searchTerm", " ") + ) + val args = JSONArray().put(JSONObject().put("action", action)) + + val parsed = AndroidDeviceAgent.parseExecuteAction(args) + + assertTrue(parsed is AndroidDeviceActionParseResult.ActionError) + } + @Test fun classifiesInvalidParametersAsActionError() { val action = JSONObject() @@ -87,7 +122,7 @@ class AndroidDeviceAgentTest { @Test fun classifiesUnsupportedActionAsActionError() { val action = JSONObject() - .put("actionName", "openMaps") + .put("actionName", "sendSms") .put("parameters", JSONObject()) val args = JSONArray().put(JSONObject().put("action", action))