From f389bd5883552febbb1940ad4e6a541d621b8f95 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sun, 27 Sep 2026 09:08:05 -0700 Subject: [PATCH] fix: show skipped tools separately from blocked activity (#159502) * fix: show skipped tools separately from blocked activity * fix(sdk): preserve non-success events for skipped tools * fix(ci): preboot simulators before SimSlim reconfiguration * fix(android): fold late skipped results into completed work --- apps/.i18n/native-source.json | 8 +- .../ai/openclaw/app/ui/chat/ChatScreen.kt | 19 ++-- .../ai/openclaw/app/ui/chat/ChatTimeline.kt | 17 +++- .../app/ui/chat/ChatToolActivityProjection.kt | 4 +- .../openclaw/app/ui/chat/ChatWorkedSummary.kt | 4 +- .../app/ui/chat/ToolActivityPresentation.kt | 24 +++-- .../ui/chat/ChatToolResultProjectionTest.kt | 90 +++++++++++++++---- .../ui/chat/ChatUnifiedToolActivityTest.kt | 24 ++--- .../app/ui/chat/ChatWorkedSummaryTest.kt | 1 + .../ui/chat/ToolActivityPresentationTest.kt | 35 +++++++- .../ChatToolActivityViews.swift | 3 + .../ChatToolActivityTests.swift | 21 +++++ docs/web/control-ui.md | 4 + .../src/schema/logs-chat.test.ts | 2 +- .../gateway-protocol/src/schema/logs-chat.ts | 1 + packages/sdk/src/normalize.test.ts | 5 +- packages/sdk/src/normalize.ts | 4 +- scripts/ios-simulator-prepare.sh | 2 + src/agents/agent-activity-presentation.ts | 4 +- src/infra/agent-activity-events.test.ts | 28 ++++++ src/infra/agent-activity-events.ts | 18 ++-- test/scripts/ios-lifecycle-workflow.test.ts | 2 +- test/scripts/simslim.test.ts | 19 ++-- .../e2e/chat-tool-steering-skip.e2e.test.ts | 22 ++++- ui/src/lib/chat/tool-call-grouping.test.ts | 5 +- ui/src/lib/chat/tool-call-grouping.ts | 14 ++- ui/src/lib/chat/tool-cards.ts | 3 + .../chat-activity-disclosure.test.ts | 8 +- .../chat/components/chat-message-group.ts | 2 +- .../chat/components/chat-message-stream.ts | 4 +- .../chat-tool-cards.outcome.test.ts | 2 + .../components/chat-tool-outcome-summary.ts | 7 +- 32 files changed, 312 insertions(+), 94 deletions(-) diff --git a/apps/.i18n/native-source.json b/apps/.i18n/native-source.json index 43ab737c5154..9d1526414532 100644 --- a/apps/.i18n/native-source.json +++ b/apps/.i18n/native-source.json @@ -24,6 +24,7 @@ {"id":"native.android.e5ebd0771fa3eb9c","source":"$count tool outcomes unknown","surface":"android","sites":[{"kind":"ui-call","path":"apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatWorkedSummary.kt"}]}, {"id":"native.android.1c5f68da1eca833a","source":"$count tools blocked","surface":"android","sites":[{"kind":"ui-call","path":"apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatWorkedSummary.kt"}]}, {"id":"native.android.a0b2a6afcd1aea7b","source":"$count tools failed","surface":"android","sites":[{"kind":"ui-call","path":"apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatWorkedSummary.kt"}]}, + {"id":"native.android.918e9ba9b787fbaf","source":"$count tools skipped","surface":"android","sites":[{"kind":"ui-call","path":"apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatWorkedSummary.kt"}]}, {"id":"native.android.5d1467f5c6c11bb0","source":"$count · $updated","surface":"android","sites":[{"kind":"ui-call","path":"apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatScreen.kt"}]}, {"id":"native.android.9c7b88bf8ce1ff2d","source":"$currentPosition/${steps.size}","surface":"android","sites":[{"kind":"ui-call","path":"apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatScreen.kt"}]}, {"id":"native.android.b7a4be285acd953d","source":"$devices paired","surface":"android","sites":[{"kind":"ui-call","path":"apps/android/app/src/main/java/ai/openclaw/app/ui/ShellScreen.kt"}]}, @@ -111,6 +112,7 @@ {"id":"native.android.3eaa4ebe47e48e77","source":"1 tool blocked","surface":"android","sites":[{"kind":"ui-call","path":"apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatWorkedSummary.kt"}]}, {"id":"native.android.0eb5a3e526ea83f5","source":"1 tool failed","surface":"android","sites":[{"kind":"ui-call","path":"apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatWorkedSummary.kt"}]}, {"id":"native.android.0d803902ab606a63","source":"1 tool outcome unknown","surface":"android","sites":[{"kind":"ui-call","path":"apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatWorkedSummary.kt"}]}, + {"id":"native.android.000452ffd65ee11a","source":"1 tool skipped","surface":"android","sites":[{"kind":"ui-call","path":"apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatWorkedSummary.kt"}]}, {"id":"native.android.f76d0051ab20df02","source":"14 messages · Android","surface":"android","sites":[{"kind":"ui-named-argument","path":"apps/android/app/src/main/java/ai/openclaw/app/ui/design/ClawComponents.kt"}]}, {"id":"native.android.79c7ec20584f0196","source":"","surface":"android","sites":[{"kind":"ui-call","path":"apps/android/app/src/main/java/ai/openclaw/app/systemagent/SystemAgentChatController.kt"}]}, {"id":"native.android.2a3552759589623b","source":"A Gateway update is available. Run the update from the Web UI or CLI when you are ready.","surface":"android","sites":[{"kind":"ui-call","path":"apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt"}]}, @@ -260,7 +262,7 @@ {"id":"native.android.b6b59c587196816e","source":"Before","surface":"android","sites":[{"kind":"conditional-branch","path":"apps/android/app/src/main/java/ai/openclaw/app/ui/chat/SessionDiffSelection.kt"}]}, {"id":"native.android.7ce626e41f24ba22","source":"Before you start","surface":"android","sites":[{"kind":"ui-call","path":"apps/android/app/src/main/java/ai/openclaw/app/ui/OnboardingFlow.kt"}]}, {"id":"native.android.1073342a697bfa08","source":"Binary file changed","surface":"android","sites":[{"kind":"ui-call","path":"apps/android/app/src/main/java/ai/openclaw/app/ui/chat/SessionDiffSheet.kt"}]}, - {"id":"native.android.67df9e481e35b48c","source":"Blocked","surface":"android","sites":[{"kind":"ui-call","path":"apps/android/app/src/main/java/ai/openclaw/app/ui/SkillsSettingsScreen.kt"},{"kind":"ui-call","path":"apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatScreen.kt"}]}, + {"id":"native.android.67df9e481e35b48c","source":"Blocked","surface":"android","sites":[{"kind":"ui-call","path":"apps/android/app/src/main/java/ai/openclaw/app/ui/SkillsSettingsScreen.kt"},{"kind":"ui-call","path":"apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatScreen.kt"},{"kind":"ui-call","path":"apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ToolActivityPresentation.kt"}]}, {"id":"native.android.a771f5bda69b0fbb","source":"Blocklist","surface":"android","sites":[{"kind":"ui-call","path":"apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt"}]}, {"id":"native.android.8e93ef0cf9666425","source":"Blue","surface":"android","sites":[{"kind":"ui-call","path":"apps/android/app/src/main/java/ai/openclaw/app/ui/SessionsScreen.kt"}]}, {"id":"native.android.b5fc9cf815ef9c8e","source":"Bluetooth LE microphone","surface":"android","sites":[{"kind":"ui-call","path":"apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt"}]}, @@ -618,7 +620,7 @@ {"id":"native.android.1da7b923f621303f","source":"Expression","surface":"android","sites":[{"kind":"ui-call","path":"apps/android/app/src/main/java/ai/openclaw/app/ui/CronJobManagementPanel.kt"}]}, {"id":"native.android.ed5287fb41f86942","source":"External microphone","surface":"android","sites":[{"kind":"ui-call","path":"apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt"}]}, {"id":"native.android.59bd43eaace47496","source":"Extra","surface":"android","sites":[{"kind":"ui-call","path":"apps/android/app/src/main/java/ai/openclaw/app/ui/SkillsSettingsScreen.kt"}]}, - {"id":"native.android.9779e0a84c0060fa","source":"Failed","surface":"android","sites":[{"kind":"ui-call","path":"apps/android/app/src/main/java/ai/openclaw/app/ui/chat/BackgroundTasksSheet.kt"},{"kind":"ui-call","path":"apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatMessageViews.kt"},{"kind":"ui-call","path":"apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatScreen.kt"},{"kind":"ui-call","path":"apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ToolActivityPresentation.kt"},{"kind":"resource-string","path":"apps/android/wear/src/main/res/values/strings.xml"}]}, + {"id":"native.android.9779e0a84c0060fa","source":"Failed","surface":"android","sites":[{"kind":"ui-call","path":"apps/android/app/src/main/java/ai/openclaw/app/ui/chat/BackgroundTasksSheet.kt"},{"kind":"ui-call","path":"apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatMessageViews.kt"},{"kind":"ui-call","path":"apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ToolActivityPresentation.kt"},{"kind":"resource-string","path":"apps/android/wear/src/main/res/values/strings.xml"}]}, {"id":"native.android.1062364401943984","source":"Failed — $it","surface":"android","sites":[{"kind":"ui-call","path":"apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatMessageViews.kt"}]}, {"id":"native.android.f87fc5abdcb522d4","source":"Failed: couldn't reach the secure gateway endpoint for this host.","surface":"android","sites":[{"kind":"ui-call","path":"apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt"}]}, {"id":"native.android.e13a53fa013472a0","source":"Failed: no secure gateway endpoint was detected. Enable gateway TLS or Tailscale Serve, or use a trusted private LAN address with Unencrypted selected.","surface":"android","sites":[{"kind":"ui-call","path":"apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt"}]}, @@ -1467,7 +1469,7 @@ {"id":"native.android.608683170c703837","source":"Skills installed on the gateway will appear here.","surface":"android","sites":[{"kind":"ui-call","path":"apps/android/app/src/main/java/ai/openclaw/app/ui/SkillsSettingsScreen.kt"}]}, {"id":"native.android.bbe4702c3c2dceab","source":"Skip","surface":"android","sites":[{"kind":"ui-call","path":"apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatQuestionCard.kt"}]}, {"id":"native.android.b0d49c3b10e0e131","source":"Skip for now","surface":"android","sites":[{"kind":"ui-call","path":"apps/android/app/src/main/java/ai/openclaw/app/systemagent/SystemAgentChatController.kt"},{"kind":"ui-call","path":"apps/android/app/src/main/java/ai/openclaw/app/ui/SystemAgentSettingsScreen.kt"}]}, - {"id":"native.android.5e5cad5bf929acf3","source":"Skipped","surface":"android","sites":[{"kind":"ui-call","path":"apps/android/app/src/main/java/ai/openclaw/app/ui/CronJobManagementPanel.kt"},{"kind":"ui-call","path":"apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt"},{"kind":"ui-call","path":"apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatQuestionCard.kt"}]}, + {"id":"native.android.5e5cad5bf929acf3","source":"Skipped","surface":"android","sites":[{"kind":"ui-call","path":"apps/android/app/src/main/java/ai/openclaw/app/ui/CronJobManagementPanel.kt"},{"kind":"ui-call","path":"apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt"},{"kind":"ui-call","path":"apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatQuestionCard.kt"},{"kind":"ui-call","path":"apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ToolActivityPresentation.kt"}]}, {"id":"native.android.163fc2968b999934","source":"Smart delivery","surface":"android","sites":[{"kind":"ui-call","path":"apps/android/app/src/main/java/ai/openclaw/app/ui/ShellScreen.kt"}]}, {"id":"native.android.d7de605b01a333fa","source":"Smarter","surface":"android","sites":[{"kind":"ui-call","path":"apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatScreen.kt"}]}, {"id":"native.android.656b2de8cc0cb37e","source":"Snapping","surface":"android","sites":[{"kind":"ui-call","path":"apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatWorkingIndicator.kt"}]}, diff --git a/apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatScreen.kt b/apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatScreen.kt index c02ad5b10d8f..68379df36821 100644 --- a/apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatScreen.kt +++ b/apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatScreen.kt @@ -2500,7 +2500,7 @@ private fun ToolActivityDisclosure( var expanded by rememberSaveable(stableKey) { mutableStateOf(false) } var showAll by rememberSaveable(stableKey) { mutableStateOf(false) } val summary = completedToolGroupSummary(tools) - val hasError = tools.any { it.isError || it.activity?.status == "failed" } + val hasError = tools.any { it.hasFailedOutcome } val hasBlocked = tools.any { it.activity?.status == "blocked" } val running = item.liveTools.values.any { !it.isComplete } val state = if (expanded) nativeString("Expanded") else nativeString("Collapsed") @@ -2640,13 +2640,8 @@ private fun ToolActivityItem( var expanded by rememberSaveable(parentStableKey, saveableKey) { mutableStateOf(false) } val kind = completedToolKind(tool.name) val resultPresentation = completedToolResultPresentation(tool) - val liveStatus = - when { - tool.isError || tool.activity?.status == "failed" -> nativeString("Failed") - tool.activity?.status == "blocked" -> nativeString("Blocked") - live?.isComplete == false -> nativeString("OpenClaw is working") - else -> null - } + val isError = tool.hasFailedOutcome + val outcome = resultPresentation.outcome ?: if (live?.isComplete == false) nativeString("OpenClaw is working") else null val preview = tool.detail ?.lineSequence() @@ -2685,7 +2680,7 @@ private fun ToolActivityItem( ) { Icon( imageVector = - if (tool.isError) { + if (isError) { Icons.Default.Close } else { when (kind) { @@ -2698,10 +2693,10 @@ private fun ToolActivityItem( }, contentDescription = null, modifier = Modifier.size(16.dp), - tint = if (tool.isError) ClawTheme.colors.danger else ClawTheme.colors.textMuted, + tint = if (isError) ClawTheme.colors.danger else ClawTheme.colors.textMuted, ) - (liveStatus ?: resultPresentation.outcome)?.let { outcome -> - Text(text = outcome, style = ClawTheme.type.caption, color = if (tool.isError || tool.activity?.status in setOf("failed", "blocked")) ClawTheme.colors.danger else ClawTheme.colors.textMuted) + outcome?.let { + Text(text = it, style = ClawTheme.type.caption, color = if (isError || tool.activity?.status == "blocked") ClawTheme.colors.danger else ClawTheme.colors.textMuted) } Row( modifier = Modifier.weight(1f), diff --git a/apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatTimeline.kt b/apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatTimeline.kt index 94da96491401..055edf108826 100644 --- a/apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatTimeline.kt +++ b/apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatTimeline.kt @@ -288,7 +288,7 @@ private fun buildTranscriptTimeline( val projection = toolsByMessage[index] val tools = projection.displayedTools val knownRunIds = tools.mapNotNull { it.runId }.toSet() - val unresolvedTools = tools.any { it.pending || it.activity.isError } + val unresolvedTools = tools.any { it.pending || it.activity.requiresReply } val lastFailure = tools.maxOfOrNull { it.lastFailureMessageIndex } ?: -1 val hasVisibleContent = message.content.any { it.toolActivity == null } // Empty or consumed result envelopes must not erase a pending turn boundary. @@ -312,7 +312,7 @@ private fun buildTranscriptTimeline( add( classified.copy( turnBoundary = pendingTurnBoundary || classified.turnBoundary, - hasUnresolvedTools = projection.relatedTools.any { it.pending || it.activity.isError }, + hasUnresolvedTools = projection.relatedTools.any { it.pending || it.activity.requiresReply }, knownRunIds = projection.relatedTools.mapNotNull { it.runId }.toSet() + listOfNotNull(message.runId), ), ) @@ -610,6 +610,9 @@ private class TranscriptMessageTools { val relatedTools = mutableListOf() } +private val ChatToolActivity.requiresReply: Boolean + get() = hasFailedOutcome || activity?.status == "blocked" + private fun projectTranscriptToolActivity(messages: List): List { val projected = messages.map { TranscriptMessageTools() } val calls = mutableMapOf>() @@ -656,7 +659,11 @@ private fun projectTranscriptToolActivity(messages: List): List): List WorkedToolOutcome.Failed "blocked" -> WorkedToolOutcome.Blocked + "skipped" -> WorkedToolOutcome.Skipped null -> WorkedToolOutcome.Unknown else -> null } @@ -295,6 +296,7 @@ private fun workedToolOutcomeLabel( when (outcome) { WorkedToolOutcome.Failed -> if (count == 1) nativeString("1 tool failed") else nativeString("\$count tools failed", count) WorkedToolOutcome.Blocked -> if (count == 1) nativeString("1 tool blocked") else nativeString("\$count tools blocked", count) + WorkedToolOutcome.Skipped -> if (count == 1) nativeString("1 tool skipped") else nativeString("\$count tools skipped", count) WorkedToolOutcome.Unknown -> if (count == 1) nativeString("1 tool outcome unknown") else nativeString("\$count tool outcomes unknown", count) } diff --git a/apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ToolActivityPresentation.kt b/apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ToolActivityPresentation.kt index f5876b520ee5..02f9e413cecd 100644 --- a/apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ToolActivityPresentation.kt +++ b/apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ToolActivityPresentation.kt @@ -36,7 +36,7 @@ internal fun completedToolGroupSummary(tools: List): String { .values .filter { it.isVisible } if (items.isEmpty()) return nativeString("Tool details") - return items.groupingBy { if (it.status == "failed" || it.status == "blocked") nativeString("\${it.title} (\${it.status})", it.title, it.status) else it.title }.eachCount().entries.joinToString(", ") { (title, count) -> + return items.groupingBy { if (it.status == "failed" || it.status == "blocked" || it.status == "skipped") nativeString("\${it.title} (\${it.status})", it.title, it.status) else it.title }.eachCount().entries.joinToString(", ") { (title, count) -> if (count == 1) title else nativeString("\$title ×\$count", title, count) } } @@ -72,8 +72,12 @@ internal data class CompletedToolResultPresentation( val outcome: String?, ) +internal val ChatToolActivity.hasFailedOutcome: Boolean + get() = if (activity != null || activityPrepared) activity?.status == "failed" else isError + internal fun completedToolResultPresentation(tool: ChatToolActivity): CompletedToolResultPresentation { val result = tool.result?.takeIf { it.isNotBlank() } + val isError = tool.hasFailedOutcome val hasDetail = if (completedToolKind(tool.name) == CompletedToolKind.Command) { completedCommandText(tool, singleLine = false)?.isNotBlank() == true @@ -81,15 +85,23 @@ internal fun completedToolResultPresentation(tool: ChatToolActivity): CompletedT tool.detail?.isNotBlank() == true } return CompletedToolResultPresentation( - expandable = result != null || hasDetail || tool.isError, - output = result ?: if (tool.isError) nativeString("No output — tool failed.") else null, - outputLabel = if (tool.isError) nativeString("Tool error") else null, - outcome = if (tool.isError) nativeString("Failed") else null, + expandable = result != null || hasDetail || isError, + output = result ?: if (isError) nativeString("No output — tool failed.") else null, + outputLabel = if (isError) nativeString("Tool error") else null, + outcome = + when { + tool.activity?.status == "skipped" -> nativeString("Skipped") + tool.activity?.status == "blocked" -> nativeString("Blocked") + isError -> nativeString("Failed") + else -> null + }, ) } internal fun progressReceiptLabel(tool: ChatToolActivity): String { - if (tool.isError) return nativeString("Progress update failed") + if (tool.activity?.status == "skipped") return nativeString("Skipped") + if (tool.activity?.status == "blocked") return nativeString("Blocked") + if (tool.hasFailedOutcome) return nativeString("Progress update failed") val args = tool.arguments val steps = (args?.get("plan") as? JsonArray)?.mapNotNull { it as? JsonObject }.orEmpty() if (steps.isNotEmpty()) { diff --git a/apps/android/app/src/test/java/ai/openclaw/app/ui/chat/ChatToolResultProjectionTest.kt b/apps/android/app/src/test/java/ai/openclaw/app/ui/chat/ChatToolResultProjectionTest.kt index eff623a3888e..50be07bf1d6f 100644 --- a/apps/android/app/src/test/java/ai/openclaw/app/ui/chat/ChatToolResultProjectionTest.kt +++ b/apps/android/app/src/test/java/ai/openclaw/app/ui/chat/ChatToolResultProjectionTest.kt @@ -1,5 +1,6 @@ package ai.openclaw.app.ui.chat +import ai.openclaw.app.chat.ChatAgentActivity import ai.openclaw.app.chat.ChatMessage import ai.openclaw.app.chat.ChatMessageContent import ai.openclaw.app.chat.ChatMessageProvenance @@ -39,33 +40,84 @@ class ChatToolResultProjectionTest { } @Test - fun matchedFailuresOnlyCollapseWhenALaterAnswerExists() { + fun matchedOutcomesOnlyKeepLateFailuresOutsideCompletedWork() { val call = ChatToolActivity("call-1", "bash", "command: check draft", null, false) - val failure = call.copy(name = "tool", detail = null, result = "Draft check failed", isError = true) val earlier = ChatToolActivity("earlier", "read", "path: draft.md", null, false) val earlierResult = earlier.copy(result = "Draft read") - for (mixed in listOf(false, true)) { - val invocation = - activity("call", "toolCall", call).let { - if (mixed) it.copy(content = listOf(ChatMessageContent(text = "Checking the draft")) + it.content) else it + val descriptor = ChatAgentActivity("tool:call-1", "tool", "end", "Check draft", toolCallId = "call-1") + val outcomes = + listOf( + Triple(null, true, WorkedToolOutcome.Failed), + Triple(listOf(descriptor.copy(status = "skipped")), false, WorkedToolOutcome.Skipped), + Triple(listOf(descriptor.copy(status = "failed")), true, WorkedToolOutcome.Failed), + Triple(listOf(descriptor.copy(status = "blocked")), true, WorkedToolOutcome.Blocked), + Triple(listOf(descriptor.copy(status = "completed")), false, null), + Triple(listOf(descriptor), false, WorkedToolOutcome.Unknown), + Triple(emptyList(), false, null), + ) + for ((prepared, needsReply, outcome) in outcomes) { + for (rawError in if (prepared == null) listOf(true) else listOf(true, false)) { + val rawResult = call.copy(name = "tool", detail = null, result = "Result detail", isError = rawError) + for (mixed in listOf(false, true)) { + val invocation = + activity("call", "toolCall", call).let { + if (mixed) it.copy(content = listOf(ChatMessageContent(text = "Checking the draft")) + it.content) else it + } + val answer = text("final").copy(phase = "final_answer") + val result = activity("result", "toolResult", rawResult).copy(activity = prepared) + for (late in listOf(false, true)) { + val history = listOf(text("prompt", "user"), text("commentary"), activity("earlier", "toolCall", earlier), activity("earlier-result", "toolResult", earlierResult), invocation) + if (late) listOf(answer, result) else listOf(result, answer) + val built = timeline(history) + assertEquals(2, built.items.filterIsInstance().sumOf { it.tools.size }) + val collapsed = prepareChatHistory(history, "main", "main").buildTimeline(0, emptyList(), null) + val exposed = late && needsReply + val projected = call.copy(result = rawResult.result, isError = rawError, activity = prepared?.singleOrNull(), activityPrepared = prepared != null) + assertEquals( + "prepared=$prepared rawError=$rawError mixed=$mixed late=$late", + if (exposed) (if (mixed) emptyList() else listOf(earlierResult)) + projected else emptyList(), + collapsed.items.filterIsInstance().flatMap { it.tools }, + ) + assertEquals( + if (!exposed && outcome != null) mapOf(outcome to 1) else emptyMap(), + collapsed.items + .filterIsInstance() + .single() + .outcomes, + ) + assertEquals(listOf("final", "prompt"), collapsed.items.filterIsInstance().map { it.message.id }) + } } - val answer = text("final").copy(phase = "final_answer") - val result = activity("result", "toolResult", failure) - for (late in listOf(false, true)) { - val history = listOf(text("prompt", "user"), text("commentary"), activity("earlier", "toolCall", earlier), activity("earlier-result", "toolResult", earlierResult), invocation) + if (late) listOf(answer, result) else listOf(result, answer) - val built = timeline(history) - assertEquals(2, built.items.filterIsInstance().sumOf { it.tools.size }) - val collapsed = prepareChatHistory(history, "main", "main").buildTimeline(0, emptyList(), null) - assertEquals( - "mixed=$mixed late=$late", - if (late) (if (mixed) emptyList() else listOf(earlierResult)) + call.copy(result = failure.result, isError = true) else emptyList(), - collapsed.items.filterIsInstance().flatMap { it.tools }, - ) - assertEquals(listOf("final", "prompt"), collapsed.items.filterIsInstance().map { it.message.id }) } } } + @Test + fun preparedTerminalCallsSettleWithoutRawResultsWhileUnknownCallsStayVisible() { + val call = ChatToolActivity("call-1", "read", "path: draft.md", null, false) + val descriptor = ChatAgentActivity("tool:call-1", "tool", "end", "Read draft", toolCallId = "call-1") + for ((prepared, visible) in listOf( + listOf(descriptor.copy(status = "skipped")) to false, + listOf(descriptor.copy(status = "completed")) to false, + listOf(descriptor.copy(status = "failed")) to true, + listOf(descriptor.copy(status = "blocked")) to true, + listOf(descriptor) to true, + listOf(descriptor.copy(phase = "start", status = "running")) to true, + null to true, + )) { + val history = listOf(text("prompt", "user"), text("commentary"), text("final").copy(phase = "final_answer", runId = "run"), activity("call", "toolCall", call).copy(activity = prepared, runId = "run")) + val timeline = prepareChatHistory(history, "main", "main").buildTimeline(0, emptyList(), null) + assertEquals("prepared=$prepared", if (visible) 1 else 0, timeline.items.filterIsInstance().size) + assertEquals( + if (prepared?.singleOrNull()?.status == "skipped") mapOf(WorkedToolOutcome.Skipped to 1) else emptyMap(), + timeline.items + .filterIsInstance() + .single() + .outcomes, + ) + assertEquals(listOf("final", "prompt"), timeline.items.filterIsInstance().map { it.message.id }) + } + } + @Test fun mixedMessagesUseResolvedToolCompletionWithoutChangingCanonicalContent() { val call = ChatToolActivity("mixed-call", "read", "path: draft.md", null, false) diff --git a/apps/android/app/src/test/java/ai/openclaw/app/ui/chat/ChatUnifiedToolActivityTest.kt b/apps/android/app/src/test/java/ai/openclaw/app/ui/chat/ChatUnifiedToolActivityTest.kt index 4ad7df0a0903..be2633f807c3 100644 --- a/apps/android/app/src/test/java/ai/openclaw/app/ui/chat/ChatUnifiedToolActivityTest.kt +++ b/apps/android/app/src/test/java/ai/openclaw/app/ui/chat/ChatUnifiedToolActivityTest.kt @@ -147,18 +147,18 @@ class ChatUnifiedToolActivityTest { assertEquals(listOf("output"), rows.single().tools.map { it.result }) } - @Test fun lateBlockedActivityRemainsVisibleOutsideFoldedCompletedWork() { + @Test fun latePreparedOutcomeControlsVisibilityOutsideFoldedCompletedWork() { val answer = ChatMessage("answer", "assistant", listOf(ChatMessageContent(text = "Done")), 4, runId = "run") - val blocked = ChatAgentActivity("tool:read", "tool", "end", "Read project", toolCallId = "read", status = "blocked") - val rows = timeline(listOf(user.copy(runId = "run"), result("read"), answer), listOf(first.copy(activity = blocked, isComplete = true)), runs = 0).items - assertEquals(1, rows.filterIsInstance().size) - val tool = - rows - .filterIsInstance() - .single() - .tools - .single() - assertEquals("blocked", tool.activity?.status) - assertEquals("output", tool.result) + for ((status, visible) in listOf("blocked" to true, "failed" to true, "skipped" to false, "completed" to false, null to false)) { + val activity = ChatAgentActivity("tool:read", "tool", "end", "Read project", toolCallId = "read", status = status) + val rows = timeline(listOf(user.copy(runId = "run"), result("read"), answer), listOf(first.copy(activity = activity, isError = true, isComplete = true)), runs = 0).items + assertEquals(1, rows.filterIsInstance().size) + val tools = rows.filterIsInstance().flatMap { it.tools } + assertEquals("status=$status", if (visible) 1 else 0, tools.size) + if (visible) { + assertEquals(status, tools.single().activity?.status) + assertEquals("output", tools.single().result) + } + } } } diff --git a/apps/android/app/src/test/java/ai/openclaw/app/ui/chat/ChatWorkedSummaryTest.kt b/apps/android/app/src/test/java/ai/openclaw/app/ui/chat/ChatWorkedSummaryTest.kt index 187c5e057cd4..255300184834 100644 --- a/apps/android/app/src/test/java/ai/openclaw/app/ui/chat/ChatWorkedSummaryTest.kt +++ b/apps/android/app/src/test/java/ai/openclaw/app/ui/chat/ChatWorkedSummaryTest.kt @@ -52,6 +52,7 @@ class ChatWorkedSummaryTest { emptyList() to emptyMap(), listOf(activity) to mapOf(WorkedToolOutcome.Failed to 1), listOf(activity.copy(status = "blocked")) to mapOf(WorkedToolOutcome.Blocked to 1), + listOf(activity.copy(status = "skipped")) to mapOf(WorkedToolOutcome.Skipped to 1), listOf(activity.copy(status = null)) to mapOf(WorkedToolOutcome.Unknown to 1), listOf(activity.copy(status = "completed")) to emptyMap(), listOf(activity.copy(hideFromChannelProgress = true)) to emptyMap(), diff --git a/apps/android/app/src/test/java/ai/openclaw/app/ui/chat/ToolActivityPresentationTest.kt b/apps/android/app/src/test/java/ai/openclaw/app/ui/chat/ToolActivityPresentationTest.kt index 9f2b2460e07e..b08e49cc52fe 100644 --- a/apps/android/app/src/test/java/ai/openclaw/app/ui/chat/ToolActivityPresentationTest.kt +++ b/apps/android/app/src/test/java/ai/openclaw/app/ui/chat/ToolActivityPresentationTest.kt @@ -1,5 +1,6 @@ package ai.openclaw.app.ui.chat +import ai.openclaw.app.chat.ChatAgentActivity import ai.openclaw.app.chat.ChatToolActivity import kotlinx.serialization.json.buildJsonArray import kotlinx.serialization.json.buildJsonObject @@ -19,13 +20,15 @@ class ToolActivityPresentationTest { val unknown = ai.openclaw.app.chat .ChatAgentActivity("unknown", "tool", "end", "Outcome unknown") + val skipped = ChatAgentActivity("skipped", "tool", "end", "Read draft", status = "skipped") val tools = listOf( tool("process").copy(activity = quiet, activityPrepared = true), tool("arbitrary_name").copy(activity = failed, activityPrepared = true), tool("read").copy(activity = unknown, activityPrepared = true), + tool("read").copy(activity = skipped, activityPrepared = true), ) - assertEquals("Check process (failed), Outcome unknown", completedToolGroupSummary(tools)) + assertEquals("Check process (failed), Outcome unknown, Read draft (skipped)", completedToolGroupSummary(tools)) assertEquals("Tool details", completedToolGroupSummary(listOf(tools.first()))) assertEquals("Tool details", completedToolGroupSummary(emptyList())) } @@ -116,6 +119,36 @@ class ToolActivityPresentationTest { assertEquals("Failed", presentation.outcome) } + @Test + fun `prepared outcomes override raw errors in result and progress presentation`() { + val outcomes = + listOf( + Triple("skipped", "Skipped", false), + Triple("blocked", "Blocked", false), + Triple("failed", "Failed", true), + Triple("completed", null, false), + Triple(null, null, false), + ) + for ((status, outcome, failed) in outcomes) { + for (rawError in listOf(false, true)) { + for (result in listOf(null, "Result detail")) { + val call = + tool("exec", result = result, isError = rawError).copy( + activity = ChatAgentActivity("exec-id", "tool", "end", "Check draft", status = status), + activityPrepared = true, + ) + val presentation = completedToolResultPresentation(call) + assertEquals(result != null || failed, presentation.expandable) + assertEquals(if (failed) "Tool error" else null, presentation.outputLabel) + assertEquals(result ?: if (failed) "No output — tool failed." else null, presentation.output) + assertEquals(outcome, presentation.outcome) + val receipt = if (failed) "Progress update failed" else outcome ?: "Progress cleared" + assertEquals(receipt, progressReceiptLabel(call.copy(name = "progress_card"))) + } + } + } + } + @Test fun `command without text or output remains nonexpandable and neutral`() { val presentation = completedToolResultPresentation(tool("exec")) diff --git a/apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatToolActivityViews.swift b/apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatToolActivityViews.swift index eefdfac8ce11..5e9f7815e122 100644 --- a/apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatToolActivityViews.swift +++ b/apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatToolActivityViews.swift @@ -8,6 +8,7 @@ struct ChatToolActivityItem: Identifiable, Equatable { case finished case failed case blocked + case skipped case unavailable var title: LocalizedStringResource { @@ -16,6 +17,7 @@ struct ChatToolActivityItem: Identifiable, Equatable { case .finished: "Finished" case .failed: "Failed" case .blocked: "Blocked" + case .skipped: "Skipped" case .unavailable: "No result" } } @@ -42,6 +44,7 @@ struct ChatToolActivityItem: Identifiable, Equatable { case "completed": return .finished case "failed": return .failed case "blocked": return .blocked + case "skipped": return .skipped default: return .unavailable } } diff --git a/apps/shared/OpenClawKit/Tests/OpenClawKitTests/ChatToolActivityTests.swift b/apps/shared/OpenClawKit/Tests/OpenClawKitTests/ChatToolActivityTests.swift index 3e1f67bee56a..b9945ad54960 100644 --- a/apps/shared/OpenClawKit/Tests/OpenClawKitTests/ChatToolActivityTests.swift +++ b/apps/shared/OpenClawKit/Tests/OpenClawKitTests/ChatToolActivityTests.swift @@ -1,3 +1,4 @@ +import Foundation import OpenClawKit import Testing @testable import OpenClawChatUI @@ -24,6 +25,26 @@ struct ChatToolActivityTests { #expect(!item.isPending) } + @Test func `prepared skipped outcome stays neutral despite raw result error`() throws { + let items = ChatToolActivity.items( + calls: [self.content(type: "toolCall", id: "call-1", name: "read")], + results: [self.content( + type: "toolResult", + text: "Skipped to process an incoming message.", + id: "call-1", + name: "read", + isError: true)], + activity: [OpenClawAgentActivityItem( + itemId: "tool:call-1", toolCallId: "call-1", kind: "tool", phase: "end", + title: "Read", name: "read", status: "skipped", + hideFromChannelProgress: nil, suppressChannelProgress: nil)]) + + let item = try #require(items.first) + #expect(String(localized: item.displayState.title) == "Skipped") + #expect(!item.isError) + #expect(!item.isPending) + } + @Test func `pairs call and result by ID`() { let items = ChatToolActivity.items( calls: [self.content(type: "toolCall", id: "call-1", name: "exec")], diff --git a/docs/web/control-ui.md b/docs/web/control-ui.md index b245a678cacc..4e2bcdaf198b 100644 --- a/docs/web/control-ui.md +++ b/docs/web/control-ui.md @@ -62,6 +62,10 @@ dashboard turns collapse their narration and tool activity under **Worked for above the answer. Expanding it restores the sequence with the existing tool-call groups. When no run duration is available, the heading reads **Worked**. +When an incoming message causes an unstarted tool call to be skipped, its card +and work summary show **Skipped**, including after reloading the conversation. +Approval blocks and tool failures keep their separate outcomes. + Subagent runs appear in inline transcript activity rows and the chat **Tasks** tab, outside sidebar navigation. Use the [Tasks CLI](/cli/tasks) to inspect work across conversations. Their activity rows lead with the child task's display title, using its configured diff --git a/packages/gateway-protocol/src/schema/logs-chat.test.ts b/packages/gateway-protocol/src/schema/logs-chat.test.ts index 3fd366688ac5..933c6d3ae0b9 100644 --- a/packages/gateway-protocol/src/schema/logs-chat.test.ts +++ b/packages/gateway-protocol/src/schema/logs-chat.test.ts @@ -85,7 +85,7 @@ describe("ChatHistoryCursorResultSchema", () => { inputConsumptions: [{ runId: "consumed-run", consumedByEventId: "event-1" }], }), ).toBe(true); - for (const status of [undefined, "running", "completed", "failed", "blocked"]) { + for (const status of [undefined, "running", "completed", "failed", "blocked", "skipped"]) { const activity = [ { messageId: "quiet", items: [] }, { diff --git a/packages/gateway-protocol/src/schema/logs-chat.ts b/packages/gateway-protocol/src/schema/logs-chat.ts index 58513dd1167a..349778a98957 100644 --- a/packages/gateway-protocol/src/schema/logs-chat.ts +++ b/packages/gateway-protocol/src/schema/logs-chat.ts @@ -123,6 +123,7 @@ export const AgentActivityItemSchema = closedObject({ Type.Literal("completed"), Type.Literal("failed"), Type.Literal("blocked"), + Type.Literal("skipped"), ]), ), name: Type.Optional(Type.String()), diff --git a/packages/sdk/src/normalize.test.ts b/packages/sdk/src/normalize.test.ts index 87d2c8a002c1..ac8c1339b3fc 100644 --- a/packages/sdk/src/normalize.test.ts +++ b/packages/sdk/src/normalize.test.ts @@ -34,8 +34,11 @@ describe("normalizeGatewayEvent terminal tool item status", () => { [{ phase: "end", status: "failed" }, "tool.call.failed"], [{ phase: "end", status: "blocked" }, "tool.call.failed"], [{ phase: "end", status: "completed" }, "tool.call.completed"], + [{ phase: "end", status: "skipped" }, "tool.call.failed"], [{ phase: "end" }, "tool.call.completed"], ])("classifies %j as %s", (data, expectedType) => { - expect(normalizeGatewayEvent(agentItemEvent(data)).type).toBe(expectedType); + const event = normalizeGatewayEvent(agentItemEvent(data)); + expect(event.type).toBe(expectedType); + expect(event.data).toEqual(data); }); }); diff --git a/packages/sdk/src/normalize.ts b/packages/sdk/src/normalize.ts index 775346755a1f..5577ceca5f26 100644 --- a/packages/sdk/src/normalize.ts +++ b/packages/sdk/src/normalize.ts @@ -34,9 +34,9 @@ function normalizeAgentEventType(payload: JsonObject): OpenClawEventType { return "tool.call.delta"; } // Terminal tool/item events carry phase:"end" together with the real status, so a failed or - // blocked tool must be classified before the end/completed branch — otherwise phase:"end" wins + // blocked or skipped tool must precede the end/completed branch — otherwise phase:"end" wins // and failures are reported as tool.call.completed. - if (status === "failed" || status === "blocked") { + if (status === "failed" || status === "blocked" || status === "skipped") { return "tool.call.failed"; } if (phase === "end" || status === "completed") { diff --git a/scripts/ios-simulator-prepare.sh b/scripts/ios-simulator-prepare.sh index 806f73f80c9e..7be67ac6ce78 100755 --- a/scripts/ios-simulator-prepare.sh +++ b/scripts/ios-simulator-prepare.sh @@ -22,6 +22,8 @@ fi simulator_id="$1" # Disable only Spotlight search and Family/Screen Time, retaining app capabilities. readonly kept_categories="widgets,siri,icloud,store,pim,web,health,photos,apps,messaging,connectivity,telemetry,other" +# Finish the initial cold boot before starting SimSlim's reconfiguration deadline. +xcrun simctl bootstatus "$simulator_id" -b "$simslim_binary" on "$simulator_id" --except "$kept_categories" xcrun simctl bootstatus "$simulator_id" -b "$simslim_binary" verify "$simulator_id" --except "$kept_categories" diff --git a/src/agents/agent-activity-presentation.ts b/src/agents/agent-activity-presentation.ts index 5c2992b761b9..1a5ea355cbb0 100644 --- a/src/agents/agent-activity-presentation.ts +++ b/src/agents/agent-activity-presentation.ts @@ -85,7 +85,7 @@ export function summarizeAgentActivity( .map((item) => [item.toolCallId ?? item.itemId, item]), ); const counts = { commands: 0, reads: 0, edits: 0, writes: 0, searches: 0, fetches: 0, other: 0 }; - const outcomes = { failed: 0, blocked: 0, unknown: 0 }; + const outcomes = { failed: 0, blocked: 0, skipped: 0, unknown: 0 }; let total = 0; for (const item of operations.values()) { if (item.hideFromChannelProgress || item.suppressChannelProgress) { @@ -97,7 +97,7 @@ export function summarizeAgentActivity( const category = item.commandBearing ? "commands" : (ACTIVITY_CATEGORIES.get(name) ?? "other"); counts[category] += 1; total += 1; - if (item.status === "failed" || item.status === "blocked") { + if (item.status === "failed" || item.status === "blocked" || item.status === "skipped") { outcomes[item.status] += 1; } else if (!item.status) { outcomes.unknown += 1; diff --git a/src/infra/agent-activity-events.test.ts b/src/infra/agent-activity-events.test.ts index 07e1b1716bfd..55dacd988644 100644 --- a/src/infra/agent-activity-events.test.ts +++ b/src/infra/agent-activity-events.test.ts @@ -20,6 +20,34 @@ describe("agent activity events", () => { resetAgentEventsForTest(); }); + test.each([ + { details: { status: "skipped", deniedReason: "steering" }, status: "skipped" }, + { details: { status: "skipped", deniedReason: "other" }, status: "blocked" }, + { details: { status: "approval-pending" }, status: "blocked" }, + { details: { status: "approval-unavailable" }, status: "blocked" }, + { details: { status: "error" }, status: "failed" }, + ])("preserves $details as $status in live and history activity", ({ details, status }) => { + const result = { + role: "toolResult", + toolCallId: "read-context", + toolName: "read", + content: [{ type: "text", text: "Tool did not execute." }], + details, + isError: true, + }; + const live = projectAgentToolActivity({ + toolCallId: result.toolCallId, + name: result.toolName, + phase: "result", + result, + isError: true, + }); + const history = projectAgentHistoryActivity([{ messageId: "result", message: result }]); + expect(live.status).toBe(status); + expect(history[0]?.items).toEqual([expect.objectContaining({ status })]); + expect(result.isError).toBe(true); + }); + test.each([ { outcome: "completed", hidden: true }, { outcome: "failed", hidden: false }, diff --git a/src/infra/agent-activity-events.ts b/src/infra/agent-activity-events.ts index 0289d9b6c1ae..2178ac8377d4 100644 --- a/src/infra/agent-activity-events.ts +++ b/src/infra/agent-activity-events.ts @@ -96,12 +96,18 @@ export function projectAgentToolActivity(tool: ToolActivityInput): AgentActivity const status = tool.phase !== "result" ? "running" - : approval || skipped - ? "blocked" - : tool.status === "unknown" - ? undefined - : (tool.status ?? - (tool.isError === true ? "failed" : tool.isError === false ? "completed" : undefined)); + : skipped && details.deniedReason === "steering" + ? "skipped" + : approval || skipped + ? "blocked" + : tool.status === "unknown" + ? undefined + : (tool.status ?? + (tool.isError === true + ? "failed" + : tool.isError === false + ? "completed" + : undefined)); return projectAgentActivityItem( { itemId: `tool:${tool.toolCallId}`, diff --git a/test/scripts/ios-lifecycle-workflow.test.ts b/test/scripts/ios-lifecycle-workflow.test.ts index 601b50b239f1..9d4ccd78fc57 100644 --- a/test/scripts/ios-lifecycle-workflow.test.ts +++ b/test/scripts/ios-lifecycle-workflow.test.ts @@ -183,7 +183,7 @@ describe.skipIf(process.platform === "win32")("SimSlim workflow admission", () = ); expect( commands.filter(({ tool, args }) => tool === "xcrun" && args[1] === "bootstatus"), - ).toHaveLength(2); + ).toHaveLength(3); }); it.each(["missing-installer", "missing-prepare"])("keeps %s targets stock", (mode) => { diff --git a/test/scripts/simslim.test.ts b/test/scripts/simslim.test.ts index 8bd491ff1142..479213565330 100644 --- a/test/scripts/simslim.test.ts +++ b/test/scripts/simslim.test.ts @@ -29,7 +29,7 @@ function runFixture( writeFileSync( runner, String.raw` -import { appendFileSync, copyFileSync, writeFileSync } from "node:fs"; +import { appendFileSync, copyFileSync, existsSync, writeFileSync } from "node:fs"; import path from "node:path"; const [tool, ...args] = process.argv.slice(2); const root = process.env.SIMSLIM_FIXTURE_ROOT; @@ -52,9 +52,12 @@ if (tool === "uname") { if (failure === "version-exit") process.exit(23); } else if (args[0] === failure) { process.exit(23); + } else if (args[0] === "on") { + writeFileSync(path.join(root, "simslim-applied"), ""); } -} else if (tool === "xcrun" && failure === "readiness") { - process.exit(23); +} else if (tool === "xcrun") { + const phase = existsSync(path.join(root, "simslim-applied")) ? "reboot-readiness" : "initial-readiness"; + if (failure === phase) process.exit(23); } `, ); @@ -163,10 +166,11 @@ describe.skipIf(process.platform === "win32")("simslim installer", () => { }); describe.skipIf(process.platform === "win32")("iOS simulator preparation", () => { - it("applies and verifies the same conservative profile on the explicit simulator", () => { + it("boots the explicit simulator before applying and verifying the conservative profile", () => { const { result, commands } = runFixture("ios-simulator-prepare.sh"); expect(result.status, result.stderr).toBe(0); expect(commands).toEqual([ + { tool: "xcrun", args: ["simctl", "bootstatus", simulatorId, "-b"] }, { tool: "simslim", args: ["on", simulatorId, "--except", keptCategories] }, { tool: "xcrun", args: ["simctl", "bootstatus", simulatorId, "-b"] }, { tool: "simslim", args: ["verify", simulatorId, "--except", keptCategories] }, @@ -197,9 +201,10 @@ describe.skipIf(process.platform === "win32")("iOS simulator preparation", () => }); it.each([ - ["on", 1], - ["readiness", 2], - ["verify", 3], + ["initial-readiness", 1], + ["on", 2], + ["reboot-readiness", 3], + ["verify", 4], ] as const)("preserves %s failure without subsequent calls", (failure, count) => { const { result, commands } = runFixture("ios-simulator-prepare.sh", { failure }); expect(result.status).toBe(23); diff --git a/ui/src/e2e/chat-tool-steering-skip.e2e.test.ts b/ui/src/e2e/chat-tool-steering-skip.e2e.test.ts index 2b17644144ef..39bd38749149 100644 --- a/ui/src/e2e/chat-tool-steering-skip.e2e.test.ts +++ b/ui/src/e2e/chat-tool-steering-skip.e2e.test.ts @@ -1,6 +1,7 @@ import path from "node:path"; import { asNullableRecord } from "@openclaw/normalization-core/record-coerce"; import { assert, expect, it } from "vitest"; +import { projectAgentToolActivity } from "../../../src/infra/agent-activity-events.js"; import { prepareChatHistoryFixture } from "../test-helpers/chat-activity-fixtures.ts"; import { createControlUiE2eArtifactDir } from "../test-helpers/control-ui-e2e-artifacts.ts"; import { installMockGateway } from "../test-helpers/control-ui-e2e.ts"; @@ -65,10 +66,26 @@ suite.define(() => { result: skipped, }, }); + await gateway.emitGatewayEvent("agent", { + runId, + sessionKey: "main", + seq: 3, + stream: "item", + ts: timestamp, + data: projectAgentToolActivity({ + name: "write", + toolCallId: skipped.toolCallId, + phase: "result", + args, + isError: true, + result: skipped, + }), + }); const row = page.locator(".chat-tool-msg-summary").filter({ hasText: "operation.json" }); await row.waitFor(); await row.click({ position: { x: 4, y: 4 } }); await page.locator(".chat-tool-card__outcome").waitFor(); + expect(await row.textContent()).not.toMatch(/blocked|failed/u); await page.locator(".chat-main").screenshot({ path: path.join(artifactDir, "live.png") }); expect(await page.locator(".chat-tool-card__outcome").textContent()).toBe("Skipped"); expect(await page.locator(".chat-tool-card--error").count()).toBe(0); @@ -113,7 +130,8 @@ suite.define(() => { .waitFor(); const group = historyPage.locator(".chat-activity-group__summary").first(); expect(await group.textContent()).toContain("1 failed"); - expect(await group.textContent()).toContain("1 skipped"); + expect((await group.textContent())?.match(/1 skipped/gu)).toHaveLength(1); + expect(await group.textContent()).not.toContain("blocked"); expect(await group.locator(".chat-activity-group__label").textContent()).not.toContain( "Write (failed)", ); @@ -124,6 +142,8 @@ suite.define(() => { expect(await historyRow.textContent()).toContain("Skipped"); await historyRow.click({ position: { x: 4, y: 4 } }); expect(await historyPage.locator(".chat-tool-card__outcome").textContent()).toBe("Skipped"); + expect((await group.textContent())?.match(/1 skipped/gu)).toHaveLength(1); + expect(await group.textContent()).not.toContain("blocked"); await historyPage .locator(".chat-main") .screenshot({ path: path.join(artifactDir, "history.png") }); diff --git a/ui/src/lib/chat/tool-call-grouping.test.ts b/ui/src/lib/chat/tool-call-grouping.test.ts index 5292f419a107..90874726d2ee 100644 --- a/ui/src/lib/chat/tool-call-grouping.test.ts +++ b/ui/src/lib/chat/tool-call-grouping.test.ts @@ -37,15 +37,16 @@ describe("summarizeToolGroup", () => { ).toBe("1 other operation · 1 failed"); }); - it("keeps failure, approval, and unknown outcomes while quiet work stays out", () => { + it("keeps failure, approval, skipped, and unknown outcomes while quiet work stays out", () => { expect( summarizeToolGroup([ prepared("quiet", "Wait", { hideFromChannelProgress: true }), prepared("failure", "Check process", { status: "failed" }), prepared("approval", "Write report", { status: "blocked" }), + prepared("steering", "Read context", { status: "skipped" }), prepared("unknown", "Outcome unknown", { status: undefined }), ]), - ).toBe("3 other operations · 1 failed · 1 blocked · 1 unknown"); + ).toBe("4 other operations · 1 failed · 1 blocked · 1 skipped · 1 unknown"); }); it("keeps the diagnostic disclosure label when all prepared work is quiet", () => { diff --git a/ui/src/lib/chat/tool-call-grouping.ts b/ui/src/lib/chat/tool-call-grouping.ts index ab937e76d141..9c7ea4d27bf1 100644 --- a/ui/src/lib/chat/tool-call-grouping.ts +++ b/ui/src/lib/chat/tool-call-grouping.ts @@ -33,21 +33,29 @@ export function describeToolGroup(items: readonly AgentActivityItem[]) { .filter(([, count]) => count > 0) .map(([kind, count]) => ({ kind, - label: t(`chat.toolCards.activity.${kind}`, { count: String(count) }), + label: t( + kind === "skipped" ? "chat.toolCards.skippedCount" : `chat.toolCards.activity.${kind}`, + { + count: String(count), + }, + ), })); return { total: summary.total, label, outcomes }; } export function summarizeToolGroup( items: readonly AgentActivityItem[], - options: { includeFailureCount?: boolean } = {}, + options: { includeInlineOutcomes?: boolean } = {}, ): string { const summary = describeToolGroup(items); return ( [ summary.label, ...summary.outcomes - .filter(({ kind }) => options.includeFailureCount !== false || kind !== "failed") + .filter( + ({ kind }) => + options.includeInlineOutcomes !== false || (kind !== "failed" && kind !== "skipped"), + ) .map(({ label }) => label), ] .filter(Boolean) diff --git a/ui/src/lib/chat/tool-cards.ts b/ui/src/lib/chat/tool-cards.ts index a8f5dc4f7e18..22bb84ed8483 100644 --- a/ui/src/lib/chat/tool-cards.ts +++ b/ui/src/lib/chat/tool-cards.ts @@ -118,6 +118,9 @@ function readToolExitCode(...values: unknown[]): number | undefined { } export function isToolCardSkipped(card: ToolCard): boolean { + if (card.activity) { + return card.activity.status === "skipped"; + } const details = readRecord(card.details); return ( (card.live !== true || card.completed === true) && diff --git a/ui/src/pages/chat/components/chat-activity-disclosure.test.ts b/ui/src/pages/chat/components/chat-activity-disclosure.test.ts index b5bfce94807b..a6441b1d3923 100644 --- a/ui/src/pages/chat/components/chat-activity-disclosure.test.ts +++ b/ui/src/pages/chat/components/chat-activity-disclosure.test.ts @@ -162,7 +162,7 @@ it.each(["activity", "work"] as const)("uses current prepared outcomes in %s sum expect(summary?.querySelector(".chat-tool-failure")).toBeNull(); }); -it.each(["blocked", undefined] as const)( +it.each(["blocked", "skipped", undefined] as const)( "retains %s outcomes when completed work is expanded", (status) => { const message = createAssistantMessage([], { @@ -191,7 +191,11 @@ it.each(["blocked", undefined] as const)( ); const summary = container.querySelector(".chat-activity-group__summary"); expect(summary?.textContent).toContain("Worked for 1s"); - expect(summary?.textContent).toContain(status ? "1 blocked" : "1 unknown"); + expect(summary?.textContent).toContain(`1 ${status ?? "unknown"}`); + if (status === "skipped") { + expect(summary?.textContent?.match(/1 skipped/g)).toHaveLength(1); + expect(summary?.textContent).not.toMatch(/blocked|failed/); + } } }, ); diff --git a/ui/src/pages/chat/components/chat-message-group.ts b/ui/src/pages/chat/components/chat-message-group.ts index 5abdaa9c280b..f55aa08667c9 100644 --- a/ui/src/pages/chat/components/chat-message-group.ts +++ b/ui/src/pages/chat/components/chat-message-group.ts @@ -256,7 +256,7 @@ export function renderActivityGroup( const activityExpanded = opts.isToolMessageExpanded?.(activityDisclosureId) ?? false; const groupSummaryLabel = runningOperation ? `${runningOperation.title}…` - : summarizeToolGroup(visibleActivity, { includeFailureCount: activityExpanded }); + : summarizeToolGroup(visibleActivity, { includeInlineOutcomes: activityExpanded }); const toolCardOverrides = new Map(); function renderOperation(group: ToolCallGroup): unknown { const { card, children } = group; diff --git a/ui/src/pages/chat/components/chat-message-stream.ts b/ui/src/pages/chat/components/chat-message-stream.ts index 732569c44781..0902b2c0b093 100644 --- a/ui/src/pages/chat/components/chat-message-stream.ts +++ b/ui/src/pages/chat/components/chat-message-stream.ts @@ -193,7 +193,9 @@ export function renderWorkGroupSummary( group.messages.flatMap(({ message }) => readPreparedActivity(message)), ); const label = duration ? t("chat.workRun.workedFor", { duration }) : t("chat.workRun.worked"); - const outcomes = describeToolGroup(activity).outcomes.filter(({ kind }) => kind !== "failed"); + const outcomes = describeToolGroup(activity).outcomes.filter( + ({ kind }) => kind !== "failed" && kind !== "skipped", + ); const content = html`