` CSS class so routing
+ * annotations such as "routed to list - recent topic" render de-emphasised
+ * next to the actual answer.
+ */
+enum class MessageKind {
+ NONE,
+ INFO,
+ STATUS,
+ WARNING,
+ ERROR,
+ SUCCESS;
+
+ val isSecondary: Boolean
+ get() = this == INFO || this == STATUS
+
+ companion object {
+ fun parse(raw: String?): MessageKind {
+ return when (raw?.trim()?.lowercase()) {
+ "info" -> INFO
+ "status" -> STATUS
+ "warning" -> WARNING
+ "error" -> ERROR
+ "success" -> SUCCESS
+ else -> NONE
+ }
+ }
+ }
+}
+
+/**
+ * One rendered block inside a chat bubble, equivalent to a single content
+ * `` the shell appends to `.chat-message-content`.
+ */
+data class MessageSegment(
+ val text: String,
+ val format: MessageFormat = MessageFormat.TEXT,
+ val kind: MessageKind = MessageKind.NONE
)
enum class MessageFormat {
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
new file mode 100644
index 000000000..b4b3cedff
--- /dev/null
+++ b/android/samples/mobile-2/app/src/main/java/com/example/typeagentchat/TimerActionParser.kt
@@ -0,0 +1,84 @@
+package com.example.typeagentchat
+
+import org.json.JSONObject
+import kotlin.math.floor
+
+internal data class SetTimerAction(
+ val originalRequest: String,
+ val durationInSeconds: Int
+)
+
+/**
+ * `AlarmClock.EXTRA_LENGTH` is documented as accepting 1..86400 seconds
+ * (24 hours). Anything outside that is rejected rather than clamped so a
+ * mistranslated duration surfaces in the log instead of silently setting a
+ * timer the user did not ask for.
+ */
+private const val MAX_TIMER_SECONDS = 86_400L
+
+/**
+ * `originalRequest` is echoed into `AlarmClock.EXTRA_MESSAGE`. Intent extras
+ * travel through a binder transaction with a ~1 MB budget, and an oversized
+ * extra makes `startActivity` throw `TransactionTooLargeException` - a
+ * `RuntimeException` no caller expects. Cap the label so a hostile or buggy
+ * server cannot crash the app from the network.
+ */
+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):
+ *
+ * ```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.
+ */
+internal fun parseSetTimerActionPayload(data: Any?): SetTimerAction? {
+ val payload = data as? JSONObject ?: return null
+ // Not `optString`: Android's org.json renders a JSON null as the literal
+ // string "null", which would end up as the timer label.
+ val originalRequest = (payload.opt("originalRequest") as? String)
+ .orEmpty()
+ .trim()
+ .take(MAX_ORIGINAL_REQUEST_CHARS)
+ val duration = readDurationSeconds(payload) ?: return null
+ if (duration <= 0L || duration > MAX_TIMER_SECONDS) {
+ return null
+ }
+
+ return SetTimerAction(
+ originalRequest = originalRequest,
+ durationInSeconds = duration.toInt()
+ )
+}
+
+private fun readDurationSeconds(payload: JSONObject): Long? {
+ val raw = payload.opt("durationInSeconds")
+ val value = when (raw) {
+ is Number -> raw.toDouble()
+ is String -> raw.trim().toDoubleOrNull()
+ else -> null
+ } ?: return null
+
+ if (value.isNaN() || value.isInfinite()) {
+ return null
+ }
+ return floor(value).toLong()
+}
+
+/** Human-readable duration for the confirmation toast, e.g. "1 h 5 min 30 s". */
+internal fun formatTimerDuration(totalSeconds: Int): String {
+ val hours = totalSeconds / 3600
+ val minutes = (totalSeconds % 3600) / 60
+ val seconds = totalSeconds % 60
+ val parts = buildList {
+ if (hours > 0) add("$hours h")
+ if (minutes > 0) add("$minutes min")
+ if (seconds > 0 || isEmpty()) add("$seconds s")
+ }
+ return parts.joinToString(" ")
+}
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 af690dcd0..0222e47ca 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
@@ -22,6 +22,8 @@ class WebSocketManager {
private val nextCallId = AtomicInteger(0)
private val connectionGeneration = AtomicInteger(0)
private val pendingInvokes = mutableMapOf()
+ private val displayThreads = mutableMapOf()
+ private val displayMessageIds = mutableMapOf()
private var webSocket: WebSocket? = null
private var conversationId: String? = null
@@ -68,6 +70,8 @@ class WebSocketManager {
pendingUserInteraction = null
conversationId = null
connectionId = null
+ displayThreads.clear()
+ displayMessageIds.clear()
}
_pendingYesNoPrompt.value = null
webSocket?.cancel()
@@ -108,6 +112,7 @@ class WebSocketManager {
pendingUserInteraction = null
conversationId = null
connectionId = null
+ finalizeOpenDisplayThreads()
}
_pendingYesNoPrompt.value = null
_connectionStatus.value = ConnectionStatus(
@@ -127,6 +132,7 @@ class WebSocketManager {
failPendingInvokes(errorMessage)
synchronized(lock) {
pendingUserInteraction = null
+ finalizeOpenDisplayThreads()
}
_pendingYesNoPrompt.value = null
_connectionStatus.value = ConnectionStatus(
@@ -207,6 +213,7 @@ class WebSocketManager {
webSocket = null
synchronized(lock) {
pendingUserInteraction = null
+ finalizeOpenDisplayThreads()
}
_pendingYesNoPrompt.value = null
failPendingInvokes("App closed")
@@ -328,9 +335,14 @@ class WebSocketManager {
requestId = null,
content = text
)
- appendAssistantContent(
+ applyDisplay(
requestId = null,
- content = ParsedDisplayContent(text = text, format = MessageFormat.TEXT)
+ content = ParsedDisplayContent(text = text, format = MessageFormat.TEXT),
+ // STEP seals the bubble immediately. Without it every
+ // unparseable frame in the session would accumulate into the
+ // same no-requestId bubble, which would never be finalized and
+ // would show "Responding..." forever.
+ mode = DisplayAppendMode.STEP
)
}
}
@@ -391,15 +403,17 @@ class WebSocketManager {
when (methodName) {
"appendDisplay" -> {
val requestId = extractRequestId(args.opt(0))
+ val kind = extractAgentMessageKind(args.opt(0))
val content = extractAgentMessageContent(args.opt(0))
- val mode = args.optString(1)
+ val mode = parseDisplayAppendMode(args.optString(1))
logInboundEvent(
type = "append-display",
requestId = requestId,
- content = content.text
+ content = content.text,
+ detail = "mode=$mode kind=${kind.orEmpty()}"
)
- if (shouldAppendToAssistantBubble(content, mode)) {
- appendAssistantContent(requestId = requestId, content = content)
+ if (!isEphemeralAgentMessageKind(kind)) {
+ applyDisplay(requestId = requestId, content = content, mode = mode)
}
}
@@ -425,13 +439,21 @@ class WebSocketManager {
"setDisplay" -> {
val requestId = extractRequestId(args.opt(0))
+ val kind = extractAgentMessageKind(args.opt(0))
val content = extractAgentMessageContent(args.opt(0))
logInboundEvent(
type = "set-display",
requestId = requestId,
- content = content.text
+ content = content.text,
+ detail = "kind=${kind.orEmpty()}"
)
- replaceAssistantContent(requestId = requestId, content = content)
+ if (!isEphemeralAgentMessageKind(kind)) {
+ applyDisplay(
+ requestId = requestId,
+ content = content,
+ mode = DisplayAppendMode.REPLACE
+ )
+ }
}
"notify" -> {
@@ -529,11 +551,14 @@ class WebSocketManager {
TAG,
"takeAction received action=$actionName requestId=${requestId.orEmpty()} data=${stringifyDisplayValue(actionData)}"
)
- if (actionName != "set-alarm") {
- Log.d(TAG, "takeAction ignored: unsupported action=$actionName")
- return
+ when (actionName) {
+ "set-alarm" -> handleSetAlarmAction(actionData)
+ "set-timer" -> handleSetTimerAction(actionData)
+ else -> Log.d(TAG, "takeAction ignored: unsupported action=$actionName")
}
+ }
+ private fun handleSetAlarmAction(actionData: Any?) {
val alarm = parseSetAlarmActionPayload(actionData)
if (alarm == null) {
Log.e(
@@ -542,19 +567,49 @@ class WebSocketManager {
)
return
}
- val handler = synchronized(lock) { clientActionHandler }
- if (handler == null) {
+ 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,
- "set-alarm parsed (hour=${alarm.hour} minute=${alarm.minute}) but no client action handler is registered"
+ "Invalid set-timer payload: ${stringifyDisplayValue(actionData)}"
)
return
}
+ val handler = requireClientActionHandler(
+ "set-timer",
+ "durationInSeconds=${timer.durationInSeconds}"
+ ) ?: return
Log.d(
TAG,
- "Dispatching set-alarm to client handler hour=${alarm.hour} minute=${alarm.minute}"
+ "Dispatching set-timer to client handler durationInSeconds=${timer.durationInSeconds}"
)
- handler.onSetAlarm(alarm)
+ handler.onSetTimer(timer)
+ }
+
+ 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) {
@@ -562,9 +617,23 @@ class WebSocketManager {
when (eventType) {
"append-display" -> {
val requestId = extractRequestId(event.opt("requestId")) ?: extractRequestId(event.optJSONObject("message"))
+ val kind = extractAgentMessageKind(event.opt("message"))
+ val content = extractAgentMessageContent(event.opt("message"))
+ val mode = parseDisplayAppendMode(event.optString("mode"))
+ logInboundEvent(eventType, requestId, content.text, "mode=$mode")
+ if (!isEphemeralAgentMessageKind(kind)) {
+ applyDisplay(requestId, content, mode)
+ }
+ }
+
+ "set-display" -> {
+ val requestId = extractRequestId(event.opt("requestId")) ?: extractRequestId(event.optJSONObject("message"))
+ val kind = extractAgentMessageKind(event.opt("message"))
val content = extractAgentMessageContent(event.opt("message"))
logInboundEvent(eventType, requestId, content.text)
- appendAssistantContent(requestId, content)
+ if (!isEphemeralAgentMessageKind(kind)) {
+ applyDisplay(requestId, content, DisplayAppendMode.REPLACE)
+ }
}
"set-display-info" -> {
@@ -604,66 +673,111 @@ class WebSocketManager {
}
}
- private fun appendAssistantContent(requestId: String?, content: ParsedDisplayContent) {
- val normalizedText = normalizeAssistantContentText(content)
- if (normalizedText.isEmpty()) {
- return
- }
-
+ /**
+ * Port of the shell's `ChatPanel.addAgentMessage` / `replaceAgentMessage`:
+ * all display content for one request accumulates in a single bubble, and a
+ * trailing `temporary` status chunk is discarded as soon as the next update
+ * arrives.
+ */
+ private fun applyDisplay(
+ requestId: String?,
+ content: ParsedDisplayContent,
+ mode: DisplayAppendMode
+ ) {
synchronized(lock) {
- val updated = _messages.value.toMutableList()
- val existingIndex = updated.indexOfLast {
- !it.isUser && it.requestId == requestId && !it.isFinal
- }
- if (existingIndex >= 0) {
- val existing = updated[existingIndex]
- updated[existingIndex] = existing.copy(
- text = existing.text + normalizedText,
- format = existing.format.mergeWith(content.format)
- )
- } else {
- updated += Message(
- text = normalizedText,
- format = content.format,
- isUser = false,
- requestId = requestId
- )
+ val key = threadKey(requestId)
+ val thread = displayThreads.getOrPut(key) { AgentDisplayThread() }
+ thread.setMessage(content, mode)
+ syncThreadMessage(key, requestId, thread)
+ if (mode == DisplayAppendMode.STEP) {
+ commitThread(key, requestId, thread)
}
- _messages.value = updated
}
}
- private fun replaceAssistantContent(requestId: String?, content: ParsedDisplayContent) {
- val normalizedText = normalizeAssistantContentText(content)
- if (normalizedText.isEmpty()) {
- return
- }
+ private fun threadKey(requestId: String?): String {
+ return requestId ?: DEFAULT_THREAD_KEY
+ }
- synchronized(lock) {
- val updated = _messages.value.toMutableList()
- val existingIndex = updated.indexOfLast {
- !it.isUser && it.requestId == requestId && !it.isFinal
- }
- if (existingIndex >= 0) {
- val existing = updated[existingIndex]
- updated[existingIndex] = existing.copy(
- text = normalizedText,
- format = content.format
- )
+ private fun syncThreadMessage(
+ key: String,
+ requestId: String?,
+ thread: AgentDisplayThread
+ ) {
+ val rendered = thread.render()
+ val updated = _messages.value.toMutableList()
+ val messageId = displayMessageIds[key]
+ val index = if (messageId == null) -1 else updated.indexOfFirst { it.id == messageId }
+
+ if (index >= 0) {
+ if (rendered.isEmpty) {
+ updated.removeAt(index)
+ displayMessageIds.remove(key)
} else {
- updated += Message(
- text = normalizedText,
- format = content.format,
- isUser = false,
- requestId = requestId
- )
+ updated[index] = updated[index].copy(segments = rendered.segments)
+ }
+ } else {
+ if (rendered.isEmpty) {
+ return
}
+ val message = Message(
+ segments = rendered.segments,
+ isUser = false,
+ requestId = requestId
+ )
+ displayMessageIds[key] = message.id
+ updated += message
+ }
+ _messages.value = updated
+ }
+
+ /**
+ * Equivalent of the shell's `completeRequest`: drop any lingering temporary
+ * status text, seal the bubble, and forget the thread so the next display
+ * update starts a fresh bubble.
+ */
+ private fun commitThread(
+ key: String,
+ requestId: String?,
+ thread: AgentDisplayThread
+ ) {
+ thread.flushTemporary()
+ syncThreadMessage(key, requestId, thread)
+ displayThreads.remove(key)
+ val messageId = displayMessageIds.remove(key) ?: return
+ val updated = _messages.value.toMutableList()
+ val index = updated.indexOfFirst { it.id == messageId }
+ if (index >= 0) {
+ updated[index] = updated[index].copy(isFinal = true)
_messages.value = updated
}
}
+ /**
+ * Seals every bubble that still has an open display thread. Used when the
+ * socket goes away mid-request so a bubble is not stranded showing
+ * "Responding..." forever, and so the per-request state is not retained
+ * across a reconnect.
+ */
+ private fun finalizeOpenDisplayThreads() {
+ for (key in displayThreads.keys.toList()) {
+ val thread = displayThreads[key] ?: continue
+ val requestId = if (key == DEFAULT_THREAD_KEY) null else key
+ commitThread(key, requestId, thread)
+ }
+ displayThreads.clear()
+ displayMessageIds.clear()
+ }
+
private fun finalizeAssistantMessage(requestId: String?) {
synchronized(lock) {
+ val key = threadKey(requestId)
+ val thread = displayThreads[key]
+ if (thread != null) {
+ commitThread(key, requestId, thread)
+ return
+ }
+
val updated = _messages.value.toMutableList()
val existingIndex = if (requestId == null) {
updated.indexOfLast { !it.isUser && !it.isFinal }
@@ -761,10 +875,15 @@ class WebSocketManager {
pending.forEach { it.onError(reason) }
}
- private fun logInboundEvent(type: String, requestId: String?, content: String) {
+ private fun logInboundEvent(
+ type: String,
+ requestId: String?,
+ content: String,
+ detail: String? = null
+ ) {
Log.d(
TAG,
- "Inbound event type=$type requestId=${requestId.orEmpty()} connectionId=${connectionId.orEmpty()} contentLength=${content.length}"
+ "Inbound event type=$type requestId=${requestId.orEmpty()} connectionId=${connectionId.orEmpty()} contentLength=${content.length}${detail?.let { " $it" }.orEmpty()}"
)
}
@@ -791,23 +910,6 @@ class WebSocketManager {
}
}
- private fun shouldAppendToAssistantBubble(content: ParsedDisplayContent, mode: String): Boolean {
- if (content.text.isEmpty()) {
- return false
- }
- if (mode == "temporary") {
- return false
- }
- if (content.format == MessageFormat.MARKDOWN) {
- return true
- }
- return !content.text.startsWith("[")
- }
-
- private fun normalizeAssistantContentText(content: ParsedDisplayContent): String {
- return content.text
- }
-
private fun handleRequestChoiceCall(args: JSONArray) {
val requestId = extractRequestId(args.opt(0))
val choiceId = args.optString(1).orEmpty()
@@ -1095,12 +1197,13 @@ class WebSocketManager {
"${choiceLines.joinToString("\n")}\nType the option number."
}
}
- appendAssistantContent(
+ applyDisplay(
requestId = requestId,
content = ParsedDisplayContent(
text = "$displayPrompt\n$instructions",
format = MessageFormat.TEXT
- )
+ ),
+ mode = DisplayAppendMode.BLOCK
)
}
@@ -1168,10 +1271,12 @@ class WebSocketManager {
private const val NORMAL_CLOSURE_STATUS = 1000
private const val AGENT_SERVER_CHANNEL = "agent-server"
private const val CLIENT_IO_CHANNEL_PREFIX = "clientio:"
+ private const val DEFAULT_THREAD_KEY = "__no_request__"
}
- internal fun interface ClientActionHandler {
+ internal interface ClientActionHandler {
fun onSetAlarm(action: SetAlarmAction)
+ fun onSetTimer(action: SetTimerAction)
}
}
diff --git a/android/samples/mobile-2/app/src/test/java/com/example/typeagentchat/AgentDisplayThreadTest.kt b/android/samples/mobile-2/app/src/test/java/com/example/typeagentchat/AgentDisplayThreadTest.kt
new file mode 100644
index 000000000..65b0cd097
--- /dev/null
+++ b/android/samples/mobile-2/app/src/test/java/com/example/typeagentchat/AgentDisplayThreadTest.kt
@@ -0,0 +1,244 @@
+package com.example.typeagentchat
+
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertTrue
+import org.junit.Test
+
+class AgentDisplayThreadTest {
+
+ private fun text(value: String) = ParsedDisplayContent(value, MessageFormat.TEXT)
+
+ private fun markdown(value: String) = ParsedDisplayContent(value, MessageFormat.MARKDOWN)
+
+ private fun info(value: String) =
+ ParsedDisplayContent(value, MessageFormat.TEXT, MessageKind.INFO)
+
+ @Test
+ fun `temporary status updates are flushed by the final result`() {
+ val thread = AgentDisplayThread()
+
+ thread.setMessage(text("Translating 'create grocery list'"), DisplayAppendMode.TEMPORARY)
+ thread.setMessage(text("routed to list"), DisplayAppendMode.TEMPORARY)
+ thread.setMessage(text("recent topic"), DisplayAppendMode.TEMPORARY)
+ thread.setMessage(text("Created list: grocery"), DisplayAppendMode.BLOCK)
+
+ assertEquals("Created list: grocery", thread.render().text)
+ }
+
+ @Test
+ fun `set display replaces everything already rendered`() {
+ val thread = AgentDisplayThread()
+
+ thread.setMessage(text("partial"), DisplayAppendMode.BLOCK)
+ thread.setMessage(text("more"), DisplayAppendMode.BLOCK)
+ thread.setMessage(text("Created list: grocery"), DisplayAppendMode.REPLACE)
+
+ assertEquals("Created list: grocery", thread.render().text)
+ }
+
+ @Test
+ fun `temporary content is visible until the next update`() {
+ val thread = AgentDisplayThread()
+
+ thread.setMessage(text("Thinking..."), DisplayAppendMode.TEMPORARY)
+
+ assertEquals("Thinking...", thread.render().text)
+ }
+
+ @Test
+ fun `flush temporary removes a trailing status`() {
+ val thread = AgentDisplayThread()
+
+ thread.setMessage(text("Answer"), DisplayAppendMode.BLOCK)
+ thread.setMessage(text("Thinking..."), DisplayAppendMode.TEMPORARY)
+ assertTrue(thread.flushTemporary())
+
+ assertEquals("Answer", thread.render().text)
+ }
+
+ @Test
+ fun `flush temporary is a no-op after a block append`() {
+ val thread = AgentDisplayThread()
+
+ thread.setMessage(text("Answer"), DisplayAppendMode.BLOCK)
+
+ assertEquals(false, thread.flushTemporary())
+ assertEquals("Answer", thread.render().text)
+ }
+
+ @Test
+ fun `empty temporary content does not consume a real chunk`() {
+ val thread = AgentDisplayThread()
+
+ thread.setMessage(text("Answer"), DisplayAppendMode.BLOCK)
+ thread.setMessage(text(""), DisplayAppendMode.TEMPORARY)
+ thread.setMessage(text("More"), DisplayAppendMode.BLOCK)
+
+ assertEquals("Answer\n\nMore", thread.render().text)
+ }
+
+ @Test
+ fun `consecutive inline appends merge into one chunk`() {
+ val thread = AgentDisplayThread()
+
+ thread.setMessage(text("Hello"), DisplayAppendMode.INLINE)
+ thread.setMessage(text(" world"), DisplayAppendMode.INLINE)
+
+ assertEquals("Hello world", thread.render().text)
+ }
+
+ @Test
+ fun `first inline after a block starts a new chunk`() {
+ val thread = AgentDisplayThread()
+
+ thread.setMessage(text("Block"), DisplayAppendMode.BLOCK)
+ thread.setMessage(text("Inline"), DisplayAppendMode.INLINE)
+
+ assertEquals("Block\n\nInline", thread.render().text)
+ }
+
+ @Test
+ fun `blocks are separated so markdown stays valid`() {
+ val thread = AgentDisplayThread()
+
+ thread.setMessage(markdown("### Lists"), DisplayAppendMode.BLOCK)
+ thread.setMessage(markdown("1. grocery"), DisplayAppendMode.BLOCK)
+
+ val rendered = thread.render()
+ assertEquals("### Lists\n\n1. grocery", rendered.text)
+ assertEquals(MessageFormat.MARKDOWN, rendered.format)
+ }
+
+ @Test
+ fun `append mode parsing matches the agent sdk values`() {
+ assertEquals(DisplayAppendMode.INLINE, parseDisplayAppendMode("inline"))
+ assertEquals(DisplayAppendMode.TEMPORARY, parseDisplayAppendMode("temporary"))
+ assertEquals(DisplayAppendMode.STEP, parseDisplayAppendMode("step"))
+ assertEquals(DisplayAppendMode.BLOCK, parseDisplayAppendMode("block"))
+ assertEquals(DisplayAppendMode.BLOCK, parseDisplayAppendMode(null))
+ assertEquals(DisplayAppendMode.BLOCK, parseDisplayAppendMode(""))
+ }
+
+ @Test
+ fun `toast and inline kinds stay out of the bubble`() {
+ assertTrue(isEphemeralAgentMessageKind("toast"))
+ assertTrue(isEphemeralAgentMessageKind("inline"))
+ assertEquals(false, isEphemeralAgentMessageKind(null))
+ assertEquals(false, isEphemeralAgentMessageKind("notification"))
+ }
+
+ @Test
+ fun `routing note keeps its info kind alongside the answer`() {
+ val thread = AgentDisplayThread()
+
+ thread.setMessage(info("\u21aa routed to list \u2014 recent topic"), DisplayAppendMode.BLOCK)
+ thread.setMessage(text("Created list: to-do"), DisplayAppendMode.BLOCK)
+
+ val segments = thread.render().segments
+ assertEquals(2, segments.size)
+ assertEquals(MessageKind.INFO, segments[0].kind)
+ assertEquals("\u21aa routed to list \u2014 recent topic", segments[0].text)
+ assertEquals(MessageKind.NONE, segments[1].kind)
+ assertEquals("Created list: to-do", segments[1].text)
+ }
+
+ @Test
+ fun `inline appends do not merge across different kinds`() {
+ val thread = AgentDisplayThread()
+
+ thread.setMessage(info("note"), DisplayAppendMode.INLINE)
+ thread.setMessage(text(" answer"), DisplayAppendMode.INLINE)
+
+ val segments = thread.render().segments
+ assertEquals(2, segments.size)
+ assertEquals(MessageKind.INFO, segments[0].kind)
+ assertEquals(MessageKind.NONE, segments[1].kind)
+ }
+
+ @Test
+ fun `empty block content leaves an existing bubble intact`() {
+ val thread = AgentDisplayThread()
+
+ thread.setMessage(text("Answer"), DisplayAppendMode.BLOCK)
+ thread.setMessage(text("Thinking..."), DisplayAppendMode.TEMPORARY)
+ // A suppressed reasoning trace parses to empty text. It must neither
+ // flush the temporary tail nor empty the thread, because an empty
+ // thread makes WebSocketManager drop and re-append the bubble.
+ thread.setMessage(text(""), DisplayAppendMode.BLOCK)
+
+ assertEquals("Answer\n\nThinking...", thread.render().text)
+ assertEquals(false, thread.render().isEmpty)
+ }
+
+ @Test
+ fun `empty replace clears the thread`() {
+ val thread = AgentDisplayThread()
+
+ thread.setMessage(text("Answer"), DisplayAppendMode.BLOCK)
+ thread.setMessage(text(""), DisplayAppendMode.REPLACE)
+
+ assertTrue(thread.render().isEmpty)
+ }
+
+ @Test
+ fun `step content survives the temporary flush`() {
+ val thread = AgentDisplayThread()
+
+ thread.setMessage(text("Thinking..."), DisplayAppendMode.TEMPORARY)
+ thread.setMessage(text("Phase 1 done"), DisplayAppendMode.STEP)
+
+ assertEquals("Phase 1 done", thread.render().text)
+ }
+
+ @Test
+ fun `inline after a temporary flush starts a new chunk`() {
+ val thread = AgentDisplayThread()
+
+ thread.setMessage(text("Hello"), DisplayAppendMode.INLINE)
+ thread.setMessage(text("Thinking..."), DisplayAppendMode.TEMPORARY)
+ thread.setMessage(text(" world"), DisplayAppendMode.INLINE)
+
+ assertEquals("Hello\n\n world", thread.render().text)
+ }
+
+ @Test
+ fun `replace after a temporary flush keeps only the new content`() {
+ val thread = AgentDisplayThread()
+
+ thread.setMessage(text("Answer"), DisplayAppendMode.BLOCK)
+ thread.setMessage(text("Thinking..."), DisplayAppendMode.TEMPORARY)
+ thread.setMessage(text("Final"), DisplayAppendMode.REPLACE)
+
+ assertEquals("Final", thread.render().text)
+ }
+
+ @Test
+ fun `newline only content renders nothing`() {
+ val thread = AgentDisplayThread()
+
+ thread.setMessage(text("\n\n"), DisplayAppendMode.BLOCK)
+
+ assertTrue(thread.render().isEmpty)
+ }
+
+ @Test
+ fun `one markdown segment makes the whole message markdown`() {
+ val thread = AgentDisplayThread()
+
+ thread.setMessage(text("plain"), DisplayAppendMode.BLOCK)
+ thread.setMessage(markdown("**bold**"), DisplayAppendMode.BLOCK)
+
+ assertEquals(MessageFormat.MARKDOWN, thread.render().format)
+ }
+
+ @Test
+ fun `display message kind parsing`() {
+ assertEquals(MessageKind.INFO, MessageKind.parse("info"))
+ assertEquals(MessageKind.STATUS, MessageKind.parse("status"))
+ assertEquals(MessageKind.WARNING, MessageKind.parse("warning"))
+ assertEquals(MessageKind.ERROR, MessageKind.parse("error"))
+ assertEquals(MessageKind.SUCCESS, MessageKind.parse("success"))
+ assertEquals(MessageKind.NONE, MessageKind.parse(null))
+ assertEquals(MessageKind.NONE, MessageKind.parse(""))
+ }
+}
diff --git a/android/samples/mobile-2/app/src/test/java/com/example/typeagentchat/DisplayContentParserTest.kt b/android/samples/mobile-2/app/src/test/java/com/example/typeagentchat/DisplayContentParserTest.kt
index 0c8599a61..a8dc2efb8 100644
--- a/android/samples/mobile-2/app/src/test/java/com/example/typeagentchat/DisplayContentParserTest.kt
+++ b/android/samples/mobile-2/app/src/test/java/com/example/typeagentchat/DisplayContentParserTest.kt
@@ -118,4 +118,46 @@ class DisplayContentParserTest {
display.text
)
}
+
+ @Test
+ fun `carries the display message kind`() {
+ val display = extractAgentMessageContent(
+ JSONObject()
+ .put(
+ "message",
+ JSONObject()
+ .put("type", "text")
+ .put("content", "\u21aa routed to list \u2014 recent topic")
+ .put("kind", "info")
+ )
+ )
+
+ assertEquals(MessageKind.INFO, display.kind)
+ assertEquals("\u21aa routed to list \u2014 recent topic", display.text)
+ }
+
+ @Test
+ fun `defaults to no kind when absent`() {
+ val display = extractAgentMessageContent(
+ JSONObject()
+ .put(
+ "message",
+ JSONObject()
+ .put("type", "text")
+ .put("content", "Created list: to-do")
+ )
+ )
+
+ assertEquals(MessageKind.NONE, display.kind)
+ }
+
+ @Test
+ fun `reads the agent message kind for ephemeral routing`() {
+ val agentMessage = JSONObject()
+ .put("kind", "toast")
+ .put("message", "hi")
+
+ assertEquals("toast", extractAgentMessageKind(agentMessage))
+ assertEquals(null, extractAgentMessageKind(JSONObject().put("message", "hi")))
+ }
}
diff --git a/android/samples/mobile-2/app/src/test/java/com/example/typeagentchat/TimerActionParserTest.kt b/android/samples/mobile-2/app/src/test/java/com/example/typeagentchat/TimerActionParserTest.kt
new file mode 100644
index 000000000..cbbac73b8
--- /dev/null
+++ b/android/samples/mobile-2/app/src/test/java/com/example/typeagentchat/TimerActionParserTest.kt
@@ -0,0 +1,136 @@
+package com.example.typeagentchat
+
+import org.json.JSONObject
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertNull
+import org.junit.Test
+
+class TimerActionParserTest {
+
+ private fun payload(duration: Any, request: String = "Set a timer for 30 seconds") =
+ JSONObject()
+ .put("originalRequest", request)
+ .put("durationInSeconds", duration)
+
+ @Test
+ fun `parses set-timer payload`() {
+ val timer = parseSetTimerActionPayload(payload(30))
+
+ requireNotNull(timer)
+ assertEquals("Set a timer for 30 seconds", timer.originalRequest)
+ assertEquals(30, timer.durationInSeconds)
+ }
+
+ @Test
+ fun `floors fractional durations`() {
+ val timer = parseSetTimerActionPayload(payload(30.9))
+
+ requireNotNull(timer)
+ assertEquals(30, timer.durationInSeconds)
+ }
+
+ @Test
+ fun `accepts a numeric string duration`() {
+ val timer = parseSetTimerActionPayload(payload("300"))
+
+ requireNotNull(timer)
+ assertEquals(300, timer.durationInSeconds)
+ }
+
+ @Test
+ fun `rejects zero and negative durations`() {
+ assertNull(parseSetTimerActionPayload(payload(0)))
+ assertNull(parseSetTimerActionPayload(payload(-5)))
+ // Floors to 0 rather than rounding up to 1.
+ assertNull(parseSetTimerActionPayload(payload(0.4)))
+ }
+
+ @Test
+ fun `rejects durations beyond the AlarmClock 24 hour limit`() {
+ assertEquals(86_400, parseSetTimerActionPayload(payload(86_400))?.durationInSeconds)
+ assertNull(parseSetTimerActionPayload(payload(86_401)))
+ }
+
+ @Test
+ fun `rejects missing or non-numeric durations`() {
+ assertNull(
+ parseSetTimerActionPayload(JSONObject().put("originalRequest", "Set a timer"))
+ )
+ assertNull(parseSetTimerActionPayload(payload("half an hour")))
+ // org.json forbids NaN/Infinity as JSON numbers, but String.toDoubleOrNull
+ // happily parses these spellings, so the guard is reachable via a string.
+ assertNull(parseSetTimerActionPayload(payload("NaN")))
+ assertNull(parseSetTimerActionPayload(payload("Infinity")))
+ }
+
+ @Test
+ fun `rejects a non-object payload`() {
+ assertNull(parseSetTimerActionPayload(null))
+ assertNull(parseSetTimerActionPayload("30"))
+ }
+
+ @Test
+ fun `tolerates a missing originalRequest`() {
+ val timer = parseSetTimerActionPayload(JSONObject().put("durationInSeconds", 60))
+
+ requireNotNull(timer)
+ assertEquals("", timer.originalRequest)
+ assertEquals(60, timer.durationInSeconds)
+ }
+
+ @Test
+ fun `treats a JSON null originalRequest as absent`() {
+ val timer = parseSetTimerActionPayload(
+ JSONObject()
+ .put("originalRequest", JSONObject.NULL)
+ .put("durationInSeconds", 45)
+ )
+
+ requireNotNull(timer)
+ assertEquals("", timer.originalRequest)
+ }
+
+ @Test
+ fun `rejects a JSON null duration`() {
+ assertNull(
+ parseSetTimerActionPayload(
+ JSONObject()
+ .put("originalRequest", "Set a timer")
+ .put("durationInSeconds", JSONObject.NULL)
+ )
+ )
+ }
+
+ @Test
+ fun `accepts a long duration value`() {
+ val timer = parseSetTimerActionPayload(payload(120L))
+
+ requireNotNull(timer)
+ assertEquals(120, timer.durationInSeconds)
+ }
+
+ @Test
+ fun `caps an oversized originalRequest`() {
+ val timer = parseSetTimerActionPayload(payload(60, "a".repeat(10_000)))
+
+ requireNotNull(timer)
+ assertEquals(256, timer.originalRequest.length)
+ }
+
+ @Test
+ fun `blank originalRequest normalizes to empty`() {
+ val timer = parseSetTimerActionPayload(payload(60, " "))
+
+ requireNotNull(timer)
+ assertEquals("", timer.originalRequest)
+ }
+
+ @Test
+ fun `formats durations for the confirmation toast`() {
+ assertEquals("30 s", formatTimerDuration(30))
+ assertEquals("5 min", formatTimerDuration(300))
+ assertEquals("1 h 5 min 30 s", formatTimerDuration(3930))
+ assertEquals("24 h", formatTimerDuration(86_400))
+ assertEquals("0 s", formatTimerDuration(0))
+ }
+}