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
This commit is contained in:
Peter Steinberger 2026-09-27 09:08:05 -07:00 • committed by GitHub
parent b0edd26d91
commit f389bd5883
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
32 changed files with 312 additions and 94 deletions

View file

@ -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":"<redacted secret>","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"}]},

View file

@ -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),

View file

@ -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<TranscriptTool>()
}
private val ChatToolActivity.requiresReply: Boolean
get() = hasFailedOutcome || activity?.status == "blocked"
private fun projectTranscriptToolActivity(messages: List<ChatMessage>): List<TranscriptMessageTools> {
val projected = messages.map { TranscriptMessageTools() }
val calls = mutableMapOf<String, MutableMap<String?, TranscriptTool>>()
@ -656,7 +659,11 @@ private fun projectTranscriptToolActivity(messages: List<ChatMessage>): List<Tra
owner.activity = mergeToolActivity(owner.activity, tool)
// A received result settles the call even when its display text is empty.
owner.pending = false
if (tool.isError) owner.lastFailureMessageIndex = messageIndex
if (tool.activityPrepared) {
owner.lastFailureMessageIndex = if (owner.activity.requiresReply) messageIndex else -1
} else if (!owner.activity.activityPrepared && tool.isError) {
owner.lastFailureMessageIndex = messageIndex
}
projected[messageIndex].relatedTools.add(owner)
} else {
// ID-only result envelopes have no standalone UI. Keep meaningful unnamed
@ -665,7 +672,9 @@ private fun projectTranscriptToolActivity(messages: List<ChatMessage>): List<Tra
result && tool.name == "tool" && tool.detail.isNullOrBlank() &&
tool.result.isNullOrBlank() && !tool.isError && tool.arguments.isNullOrEmpty()
if (!emptyOrphan) {
val projection = TranscriptTool(tool, message.runId, !result && tool.result == null, if (tool.isError) messageIndex else -1)
val preparedStatus = tool.activity?.takeIf { it.phase == "end" }?.status
val settled = result || tool.result != null || preparedStatus in setOf("completed", "failed", "blocked", "skipped")
val projection = TranscriptTool(tool, message.runId, !settled, if (tool.requiresReply) messageIndex else -1)
projected[messageIndex].displayedTools.add(projection)
projected[messageIndex].relatedTools.add(projection)
if (!result) tool.toolCallId?.let { calls.getOrPut(it) { mutableMapOf() }[message.runId] = projection }

View file

@ -75,7 +75,7 @@ internal fun projectToolActivity(
// A row folded under Worked belongs there, not in a duplicate live disclosure.
val visible = groups[scope]
val folded = matches.size == 1 && visible?.toolKeys?.contains(key) != true
val alert = call.isError == true || call.activity?.status in setOf("blocked", "failed")
val alert = if (call.activity != null) call.activity.status in setOf("blocked", "failed") else call.isError == true
if (folded && !alert) return@forEach
val hiddenOwner = if (folded) durable.singleOrNull { it.disclosureKey == scope && key in it.toolKeys } else null
val group =
@ -98,7 +98,7 @@ internal fun projectToolActivity(
arguments = tool.arguments ?: call.args,
isError = tool.isError || call.isError == true,
activity =
if (call.activity?.status in setOf("blocked", "failed")) {
if (call.activity?.status in setOf("blocked", "failed", "skipped")) {
call.activity
} else if (tool.activityPrepared) {
tool.activity

View file

@ -25,7 +25,7 @@ import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.semantics.stateDescription
import androidx.compose.ui.unit.dp
internal enum class WorkedToolOutcome { Failed, Blocked, Unknown }
internal enum class WorkedToolOutcome { Failed, Blocked, Skipped, Unknown }
internal data class PreparedChatWorkSpan(
val start: Int,
@ -281,6 +281,7 @@ private fun workedToolOutcomes(
when (activity.status) {
"failed" -> 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)
}

View file

@ -36,7 +36,7 @@ internal fun completedToolGroupSummary(tools: List<ChatToolActivity>): 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()) {

View file

@ -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<ChatAgentActivity>(), 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<ChatTimelineItem.ToolActivity>().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<ChatTimelineItem.ToolActivity>().flatMap { it.tools },
)
assertEquals(
if (!exposed && outcome != null) mapOf(outcome to 1) else emptyMap(),
collapsed.items
.filterIsInstance<ChatTimelineItem.WorkedSummary>()
.single()
.outcomes,
)
assertEquals(listOf("final", "prompt"), collapsed.items.filterIsInstance<ChatTimelineItem.Message>().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<ChatTimelineItem.ToolActivity>().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<ChatTimelineItem.ToolActivity>().flatMap { it.tools },
)
assertEquals(listOf("final", "prompt"), collapsed.items.filterIsInstance<ChatTimelineItem.Message>().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<ChatTimelineItem.ToolActivity>().size)
assertEquals(
if (prepared?.singleOrNull()?.status == "skipped") mapOf(WorkedToolOutcome.Skipped to 1) else emptyMap(),
timeline.items
.filterIsInstance<ChatTimelineItem.WorkedSummary>()
.single()
.outcomes,
)
assertEquals(listOf("final", "prompt"), timeline.items.filterIsInstance<ChatTimelineItem.Message>().map { it.message.id })
}
}
@Test
fun mixedMessagesUseResolvedToolCompletionWithoutChangingCanonicalContent() {
val call = ChatToolActivity("mixed-call", "read", "path: draft.md", null, false)

View file

@ -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<ChatTimelineItem.WorkedSummary>().size)
val tool =
rows
.filterIsInstance<ChatTimelineItem.ToolActivity>()
.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<ChatTimelineItem.WorkedSummary>().size)
val tools = rows.filterIsInstance<ChatTimelineItem.ToolActivity>().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)
}
}
}
}

View file

@ -52,6 +52,7 @@ class ChatWorkedSummaryTest {
emptyList<ChatAgentActivity>() 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(),

View file

@ -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"))

View file

@ -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
}
}

View file

@ -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")],

View file

@ -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

View file

@ -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: [] },
{

View file

@ -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()),

View file

@ -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);
});
});

View file

@ -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") {

View file

@ -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"

View file

@ -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;

View file

@ -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 },

View file

@ -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}`,

View file

@ -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) => {

View file

@ -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);

View file

@ -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") });

View file

@ -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", () => {

View file

@ -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)

View file

@ -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) &&

View file

@ -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/);
}
}
},
);

View file

@ -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<ToolCard, unknown>();
function renderOperation(group: ToolCallGroup<ToolCard>): unknown {
const { card, children } = group;

View file

@ -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`
<div class="chat-activity-group chat-work-group ${opts.expanded ? "is-open" : ""}">
<button

View file

@ -20,6 +20,7 @@ describe("tool-card outcomes", () => {
it.each([
{ status: "failed", label: "failed" },
{ status: "blocked", label: "Blocked" },
{ status: "skipped", label: "Skipped" },
{ status: undefined, label: "Outcome unknown" },
{ status: "completed", label: "Completed" },
] as const)(
@ -37,6 +38,7 @@ describe("tool-card outcomes", () => {
const host = createHost({ chatRunId: "run-outcome" });
handleAgentEvent(host, agentEvent("run-outcome", 1, "item", item));
const live = host.chatToolMessages[0];
expect(live).toBeDefined();
const saved = {
role: "assistant",
messageId: "stored-call",

View file

@ -12,10 +12,9 @@ export function renderToolOutcomeSummary(
) {
const failures = cards.filter(isToolCardError);
// Prepared outcomes remain authoritative even when their raw card is absent.
const failureCount = activity
? summarizeAgentActivity(activity).outcomes.failed
: failures.length;
const skipped = cards.filter(isToolCardSkipped).length;
const outcomes = activity ? summarizeAgentActivity(activity).outcomes : undefined;
const failureCount = outcomes?.failed ?? failures.length;
const skipped = outcomes?.skipped ?? cards.filter(isToolCardSkipped).length;
const first = failures[0];
if (failureCount === 0 && skipped === 0) {
return nothing;