Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
package com.slopus.happy.fixture

import androidx.activity.ComponentActivity
import androidx.activity.compose.BackHandler
import androidx.activity.enableEdgeToEdge
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.test.assertIsDisplayed
import androidx.compose.ui.test.assertCountEquals
import androidx.compose.ui.test.junit4.createAndroidComposeRule
import androidx.compose.ui.test.onNodeWithTag
import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.test.onAllNodesWithText
import androidx.compose.ui.test.performClick
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.platform.app.InstrumentationRegistry
import androidx.test.uiautomator.UiDevice
import com.slopus.happy.conversation.AgentInfo
import com.slopus.happy.conversation.Block
import com.slopus.happy.conversation.ConversationRowBuilder
import com.slopus.happy.conversation.ConversationState
import com.slopus.happy.conversation.Message
import com.slopus.happy.ui.conversation.ConversationScreen
import com.slopus.happy.ui.conversation.ConversationScreenActions
import com.slopus.happy.ui.conversation.ToolCallActions
import com.slopus.happy.ui.conversation.ToolCallScreen
import com.slopus.happy.ui.conversation.ToolCallScreenState
import com.slopus.happy.ui.files.MessageDetailTags
import com.slopus.happy.ui.theme.HappyTheme
import kotlinx.serialization.json.Json
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
import java.io.File

/** Synthetic Cloud call arguments through the real row projection and conversation/detail UI. No network or execution. */
@RunWith(AndroidJUnit4::class)
class ToolLabelsDeviceTest {
@get:Rule val rule = createAndroidComposeRule<ComponentActivity>()

@Test fun descriptionsNameCallsAndDetailsStillShowTheCommand() {
val calls = listOf(
"""{"type":"tool_call","id":"python","name":"python","status":"completed","arguments":{"code":"print(42)","description":"Calculate the answer"}}""",
"""{"type":"tool_call","id":"shell","name":"exec_command","status":"completed","arguments":{"cmd":"printf label-check","description":"Inspect the shell environment"},"presentation":{"type":"exec_command","command":"printf label-check","output":"label-check"}}""",
"""{"type":"tool_call","id":"patch","name":"apply_patch","status":"completed","arguments":{"description":"Update account settings"},"presentation":{"type":"file_diff","files":[{"path":"a.kt","kind":"update","added":3,"deleted":1},{"path":"b.kt","kind":"add","added":2,"deleted":0}]}}""",
"""{"type":"tool_call","id":"terminal","name":"write_stdin","status":"running","arguments":{"description":"Read test results"}}""",
"""{"type":"tool_call","id":"stop","name":"stop_shell","status":"failed","arguments":{"description":"Stop the test worker"}}""",
"""{"type":"tool_call","id":"mcp","name":"mcp__github__create_issue","status":"completed","arguments":{"description":"Semantic issue body"}}""",
).map { Block.parse(Json.parseToJsonElement(it)) as Block.ToolCall }
val message = Message("fixture", "agent", 1, calls)
val rows = ConversationRowBuilder.build(message, "agent", null, false).asReversed()
val agent = AgentInfo("agent", "workspace", null, "Tool label fixture", "idle", null, null, 1, 2, 0, 0)
rule.runOnUiThread { rule.activity.enableEdgeToEdge() }
rule.setContent {
HappyTheme {
var selected by remember { mutableStateOf<Block.ToolCall?>(null) }
BackHandler(selected != null) { selected = null }
val call = selected
if (call == null) ConversationScreen(
ConversationState(created = true, loaded = true, rows = rows, agent = agent),
ConversationScreenActions(onOpenTool = { id -> selected = calls.first { it.id == id } }),
) else ToolCallScreen(ToolCallScreenState(call, false, false, false), ToolCallActions(onBack = { selected = null }))
}
}
rule.onNodeWithText("Calculate the answer", useUnmergedTree = true).assertIsDisplayed()
rule.onNodeWithText("Inspect the shell environment", useUnmergedTree = true).assertIsDisplayed()
rule.onNodeWithText("Update account settings", useUnmergedTree = true).assertIsDisplayed()
rule.onNodeWithText("Read test results", useUnmergedTree = true).assertIsDisplayed()
rule.onNodeWithText("Stop the test worker", useUnmergedTree = true).assertIsDisplayed()
rule.onNodeWithText("github · create_issue", useUnmergedTree = true).assertIsDisplayed()
capture("tool-labels-fixture")
rule.onNodeWithText("Inspect the shell environment", useUnmergedTree = true).performClick()
rule.onNodeWithTag(MessageDetailTags.COMMAND, useUnmergedTree = true).assertIsDisplayed()
rule.onAllNodesWithText("printf label-check", useUnmergedTree = true).assertCountEquals(2)
capture("tool-labels-command-detail-fixture")
}

private fun capture(name: String) {
rule.waitForIdle()
val instrumentation = InstrumentationRegistry.getInstrumentation()
val directory = File(instrumentation.targetContext.getExternalFilesDir(null), "tool-labels").apply { mkdirs() }
UiDevice.getInstance(instrumentation).takeScreenshot(File(directory, "$name.png"))
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ data class MediaRef(val toolCallId: String, val index: Int, val item: MediaItem)

/**
* A tool call as its line reads it (iOS `ChatItem.Tool`): its name, where it
* stands (`running`, `completed` or `failed`) and what it presented.
* stands (`running`, `completed` or `failed`), its short label and what it presented.
*/
@Immutable
data class ToolUse(
Expand All @@ -41,6 +41,8 @@ data class ToolUse(
val id: String? = null,
/** What its arguments say for its line where no presentation does (a plan's progress, a page's address). */
val hint: ToolHint? = null,
/** Short builtin label, projected before raw arguments leave the row state; never MCP input. */
val summary: String? = null,
) {
val running: Boolean get() = status == "running"
val failed: Boolean get() = status == "failed"
Expand All @@ -53,7 +55,7 @@ data class ToolUse(
* One row of a conversation as the phone draws it. A message becomes one
* row per block it shows, keyed by message and block, so a streaming block
* recomposes alone. Tool data is never shown: a tool call is the one line
* its name, status and presentation give.
* its bounded label, name, status and presentation give.
*/
@Immutable
sealed interface ConversationRow {
Expand Down Expand Up @@ -381,7 +383,7 @@ class ConversationRowBuilder(
if (answered != null) return listOf(ConversationRow.Ask(askKey(call.id), answered, AskPhase.Answered))
}
val skill = if (call.name == Conversation.READ_SKILL) (call.arguments as? JsonObject)?.get("name").str else null
val tool = ToolUse(call.name, call.status, call.presentation, skill, call.id, ToolHint.of(call))
val tool = ToolUse(call.name, call.status, call.presentation, skill, call.id, ToolHint.of(call), ToolSummary.of(call.name, call.arguments))
return when (val p = call.presentation) {
is Presentation.Media -> if (call.id == null || p.items.isEmpty()) {
listOf(ConversationRow.Tool(key, call.id, tool))
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
package com.slopus.happy.conversation

import kotlinx.serialization.json.JsonElement
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.JsonPrimitive

/** Builtin display metadata only. MCP descriptions belong to the remote tool's input schema. */
object ToolSummary {
fun of(name: String, arguments: JsonElement?): String? {
if (name.startsWith("mcp_")) return null
val args = arguments as? JsonObject ?: return null
val fields = listOf("description") + when (name) {
"send_agent_message", "send_bot_message", "send_task_message" -> listOf("explanation")
"create_agent", "create_subtask", "create_artifact", "create_calendar_event", "codex_imagegen" -> listOf("title")
"create_task", "set_title", "set_bot_name", "set_node_name" -> listOf("name")
"create_bot" -> listOf("name", "username")
"read_file", "edit_file", "write_file", "view_image", "Read", "Edit", "Write",
"read_vault_file", "edit_vault_file", "write_vault_file", "read_artifact_file", "edit_artifact_file", "write_artifact_file" -> listOf("path", "file_path")
"glob_vault", "grep_vault" -> listOf("pattern")
"web_search", "tool_search" -> listOf("query")
"web_fetch" -> listOf("url")
else -> emptyList()
}
return fields.firstNotNullOfOrNull { field ->
(args[field] as? JsonPrimitive)?.takeIf { it.isString }?.content?.let(::normalize)
}
}

/** One short line, capped by Unicode code points rather than splitting UTF-16 surrogate pairs. */
internal fun normalize(value: String): String? {
val text = value.replace(Regex("[\\s\\p{Z}\u0085\uFEFF]+"), " ").trim()
if (text.isEmpty()) return null
return text.substring(0, text.offsetByCodePoints(0, minOf(120, text.codePointCount(0, text.length))))
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -103,7 +103,9 @@ internal class ToolCallModel(

companion object {
/** A finished call whose raw data the chat doesn't have whole: left out by history, or cut. */
fun wants(call: Block.ToolCall): Boolean = !call.running && (call.cut || (call.arguments == null && call.result == null))
fun wants(call: Block.ToolCall): Boolean = !call.running && (
call.cut || (call.result == null && (call.arguments == null || call.presentation != null))
) // Compact presented history may retain only arguments.description, not the whole input.
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,8 @@ sealed interface ToolGlyph {
* A tool call's words and mark as desktop's single-line row reads them
* (AgentActivityRow.tsx `AgentToolActivity`, `toolVerb`, `toolGlyph`; iOS
* ChatTurnPlans.swift `ToolWords`): "Read README.md", "Edited x.ts +3 −1",
* "Shell ls -la", in the present tense while it runs.
* "Shell ls -la", in the present tense while it runs. A builtin summary is already
* a complete label and takes the line without an extra verb; rich details stay unchanged.
*/
@Immutable
data class ToolWords(
Expand All @@ -40,6 +41,14 @@ data class ToolWords(
val lower = name.lowercase()
val p = tool.presentation
val glyph = glyph(name, p, tool.failed)
tool.summary?.takeIf { !name.startsWith("mcp_") }?.let { summary ->
val diff = p as? Presentation.FileDiff
return ToolWords(
verb = "", subject = summary, mono = false, glyph = glyph,
added = diff?.files?.sumOf { it.added ?: 0 }?.takeIf { it > 0 },
deleted = diff?.files?.sumOf { it.deleted ?: 0 }?.takeIf { it > 0 },
)
}
// A skill the agent reads is named: "Read skill: release-notes".
if (p == null && tool.skill != null) {
return ToolWords(res.getString(if (tool.running) R.string.turn_reading_skill else R.string.turn_read_skill), tool.skill, false, glyph)
Expand Down Expand Up @@ -197,8 +206,8 @@ data class ToolWords(
)

private fun mcp(name: String): String? {
val parts = name.split("__")
return if (parts.size >= 3) "${parts[1]} · ${parts.drop(2).joinToString(" ")}" else null
val parts = name.removePrefix("mcp__").split("__", limit = 2)
return if (parts.size == 2) "${parts[0]} · ${parts[1]}" else null
}

/** `humanizeToolName`: its own name where it has one, else its words spaced and capitalised. */
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -137,7 +137,7 @@ object TurnRowSpec {

/**
* A tool call's one line: its mark, or a spinner while it runs, its verb
* and its subject. [bottom] is what it keeps under itself: its own 6, or
* and its subject (a builtin summary starts directly after the glyph lane). [bottom] is what it keeps under itself: its own 6, or
* less where what it showed follows. A tap opens the call ([onOpen], its
* own screen), running or done; a long press copies the subject.
*/
Expand All @@ -150,6 +150,8 @@ fun ToolLine(tool: ToolUse, modifier: Modifier = Modifier, bottom: Dp = TurnRowS
val ink = if (tool.failed) TurnRowSpec.failedInk(colors) else colors.textSecondary
val spoken = if (tool.running) {
stringResource(R.string.turn_is_running, ToolWords.humanize(tool.name, res)) + ", " + words.subject
} else if (words.verb.isEmpty()) {
if (tool.failed) stringResource(R.string.turn_failed_call) + ", " + words.subject else words.subject
} else {
"${words.verb} ${words.subject}"
}
Expand Down Expand Up @@ -201,8 +203,10 @@ fun ToolLine(tool: ToolUse, modifier: Modifier = Modifier, bottom: Dp = TurnRowS
}
}
Spacer(Modifier.width(TurnRowSpec.gap))
Text(words.verb, style = TurnRowSpec.verb, color = ink, maxLines = 1, overflow = TextOverflow.Ellipsis, modifier = Modifier.testTag(TurnRowTags.TOOL_VERB))
Spacer(Modifier.width(TurnRowSpec.gap))
if (words.verb.isNotEmpty()) {
Text(words.verb, style = TurnRowSpec.verb, color = ink, maxLines = 1, overflow = TextOverflow.Ellipsis, modifier = Modifier.testTag(TurnRowTags.TOOL_VERB))
Spacer(Modifier.width(TurnRowSpec.gap))
}
val counted = words.added != null || words.deleted != null
Text(
words.subject,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.cancel
import kotlinx.coroutines.launch
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.test.StandardTestDispatcher
import kotlinx.coroutines.test.advanceUntilIdle
Expand Down Expand Up @@ -172,10 +173,10 @@ class ToolCallsTest {
assertEquals(ToolValue.Text("k1", mono = true), parsed.fields[0].value)
}

@Test fun `a held call is found in its run and read whole from the page just before the next run`() = runTest {
@Test fun `a compact labeled call is found in its run and detail reads the whole call once`() = runTest {
val requests = ArrayList<String>()
val history = """{"runs":[
{"id":"r1","status":"completed","messages":[{"id":"m1","role":"agent","createdAt":1,"content":[{"type":"tool_call","id":"c1","name":"Bash","status":"completed","presentation":{"type":"exec_command","command":"ls","output":"a"}}]}]},
{"id":"r1","status":"completed","messages":[{"id":"m1","role":"agent","createdAt":1,"content":[{"type":"tool_call","id":"c1","name":"Bash","status":"completed","arguments":{"description":"List files"},"presentation":{"type":"exec_command","command":"ls","output":"a"}}]}]},
{"id":"r2","status":"completed","messages":[{"id":"m2","role":"agent","createdAt":2,"content":[{"type":"text","text":"done"}]}]}
],"hasMore":false}"""
val whole = """{"runs":[{"id":"r1","status":"completed","messages":[{"id":"m1","role":"agent","createdAt":1,"content":[{"type":"tool_call","id":"c1","name":"Bash","status":"completed","arguments":{"cmd":"ls"},"result":{"output":"a"},"presentation":{"type":"exec_command","command":"ls","output":"a"}}]}]}],"hasMore":true}"""
Expand Down Expand Up @@ -205,10 +206,14 @@ class ToolCallsTest {
assertNotNull(located)
assertEquals("r1", located!!.runId)
assertEquals("r2", located.nextRunId)
assertNull(located.call.arguments)
assertEquals(Json.parseToJsonElement("""{"description":"List files"}"""), located.call.arguments)

val full = conversation.fullToolCall("c1", located)
val model = com.slopus.happy.ui.conversation.ToolCallModel(ToolCallReader(ConversationApi(request), "a1"), "c1", conversation)
scope.launch { model.observe() }
advanceUntilIdle()
val full = model.state.value.call
assertEquals(Json.parseToJsonElement("""{"cmd":"ls"}"""), full!!.arguments)
assertEquals(1, requests.count { it.contains("omitToolData=false") })
val asked = requests.last()
assertTrue(asked, asked.contains("before=r2") && asked.contains("limit=1") && asked.contains("omitToolData=false"))
assertNull(conversation.toolCall("missing"))
Expand Down Expand Up @@ -236,7 +241,11 @@ class ToolCallsTest {
val presented = block("""{"type":"tool_call","id":"c","name":"Bash","status":"completed","presentation":{"type":"exec_command","command":"ls"}}""")
assertTrue(com.slopus.happy.ui.conversation.ToolCallModel.wants(presented))
assertFalse(com.slopus.happy.ui.conversation.ToolCallModel.wants(presented.copy(status = "running")))
assertFalse(com.slopus.happy.ui.conversation.ToolCallModel.wants(presented.copy(arguments = Json.parseToJsonElement("""{"cmd":"ls"}""").jsonObject)))
val labeled = presented.copy(arguments = Json.parseToJsonElement("""{"description":"List files"}""").jsonObject)
assertTrue(com.slopus.happy.ui.conversation.ToolCallModel.wants(labeled))
assertFalse(com.slopus.happy.ui.conversation.ToolCallModel.wants(labeled.copy(status = "running")))
assertFalse(com.slopus.happy.ui.conversation.ToolCallModel.wants(labeled.copy(result = JsonObject(emptyMap()))))
assertFalse(com.slopus.happy.ui.conversation.ToolCallModel.wants(presented.copy(presentation = null, arguments = Json.parseToJsonElement("""{"cmd":"ls"}""").jsonObject)))
assertTrue(com.slopus.happy.ui.conversation.ToolCallModel.wants(presented.copy(arguments = JsonObject(emptyMap()), cut = true)))
}
}
Loading
Loading