mirror of
https://github.com/openclaw/openclaw.git
synced 2026-10-03 01:29:56 +00:00
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:
parent
b0edd26d91
commit
f389bd5883
32 changed files with 312 additions and 94 deletions
8
apps/.i18n/native-source.json
generated
8
apps/.i18n/native-source.json
generated
|
|
@ -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"}]},
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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 }
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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()) {
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
|
|
|
|||
|
|
@ -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"))
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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")],
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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: [] },
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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()),
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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") {
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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 },
|
||||
|
|
|
|||
|
|
@ -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}`,
|
||||
|
|
|
|||
|
|
@ -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) => {
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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") });
|
||||
|
|
|
|||
|
|
@ -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", () => {
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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) &&
|
||||
|
|
|
|||
|
|
@ -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/);
|
||||
}
|
||||
}
|
||||
},
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue