fix(#1174): preserve pending process state and match tool events by ID (#1180)

This commit is contained in:
M57 authored and GitHub committed 2026-09-17 19:51:04 +03:00
1 parent 880adbef4f
commit 1ab30f1e90
9 files changed
+179 -42

No files matched your search

@@ -128,14 +128,16 @@ object EventParser {
}
"tool.progress" -> {
val toolId = payload?.get("tool_id") as? String
val name = payload?.get("name") as? String
val preview = payload?.get("preview") as? String
WsEvent.ToolProgress(name, preview, sessionId)
WsEvent.ToolProgress(name, preview, sessionId, toolId)
}
"tool.generating" -> {
val toolId = payload?.get("tool_id") as? String
val name = payload?.get("name") as? String
WsEvent.ToolGenerating(name, sessionId)
WsEvent.ToolGenerating(name, sessionId, toolId)
}
"subagent.spawn_requested", "subagent.start", "subagent.progress", "subagent.complete" -> {
@@ -104,23 +104,25 @@ sealed class WsEvent {
* Live tool execution progress with optional preview content.
*
* Events: `tool.progress`
* Payload: `{ name?: string, preview?: string }`
* Payload: `{ tool_id?: string, name?: string, preview?: string }`
*/
data class ToolProgress(
val name: String? = null,
val preview: String? = null,
val sessionId: String? = null,
val toolId: String? = null,
) : WsEvent()
/**
* Tool generation active state.
*
* Events: `tool.generating`
* Payload: `{ name?: string }`
* Payload: `{ tool_id?: string, name?: string }`
*/
data class ToolGenerating(
val name: String? = null,
val sessionId: String? = null,
val toolId: String? = null,
) : WsEvent()
/**
@@ -639,12 +639,7 @@ object ChatWsEventReducer {
OkHttpProvider.json.encodeToString(it.toJsonElement())
} ?: ""
val messages = state.messages.toMutableList()
val toolIdx =
messages.indexOfLast {
it.role == MessageRole.TOOL &&
it.toolName == event.name &&
it.toolStatus == ToolStatus.RUNNING
}
val toolIdx = findToolIndex(messages, event.name, event.data?.get("tool_id") as? String, ToolStatus.RUNNING)
if (toolIdx < 0) return ReducerResult(state = state, streamingState = streamingState)
val updated =
@@ -675,10 +670,10 @@ object ChatWsEventReducer {
/**
* Attaches [ToolOutputRiskData] to the matching tool message.
*
* Finds the last RUNNING tool message with matching [WsEvent.ToolOutputRisk.name].
* If no RUNNING message exists (tool already completed), falls back to the last
* COMPLETED tool with that name. This covers the case where the risk event arrives
* after tool.complete.
* Finds the matching stable tool ID first. If the event has no ID or no ID
* match exists, it falls back to the tool name for legacy payloads. It
* prefers RUNNING tools and then falls back to COMPLETED tools because the
* risk event may arrive after tool.complete.
*/
private fun onToolOutputRisk(
state: ChatUiState,
@@ -694,20 +689,11 @@ object ChatWsEventReducer {
val messages = state.messages.toMutableList()
// Prefer RUNNING tool, fall back to COMPLETED
var toolIdx =
messages.indexOfLast {
it.role == MessageRole.TOOL &&
it.toolName == event.name &&
it.toolStatus == ToolStatus.RUNNING
}
// Prefer RUNNING tool, fall back to COMPLETED. Stable tool IDs win over
// name matching so overlapping calls to the same tool stay isolated.
var toolIdx = findToolIndex(messages, event.name, event.toolId, ToolStatus.RUNNING)
if (toolIdx < 0) {
toolIdx =
messages.indexOfLast {
it.role == MessageRole.TOOL &&
it.toolName == event.name &&
it.toolStatus == ToolStatus.COMPLETED
}
toolIdx = findToolIndex(messages, event.name, event.toolId, ToolStatus.COMPLETED)
}
if (toolIdx < 0) return ReducerResult(state = state, streamingState = streamingState)
@@ -904,12 +890,7 @@ object ChatWsEventReducer {
event: WsEvent.ToolProgress,
): ReducerResult {
val messages = state.messages.toMutableList()
val toolIdx =
messages.indexOfLast {
it.role == MessageRole.TOOL &&
it.toolName == event.name &&
it.toolStatus == ToolStatus.RUNNING
}
val toolIdx = findToolIndex(messages, event.name, event.toolId, ToolStatus.RUNNING)
if (toolIdx < 0) return ReducerResult(state = state, streamingState = streamingState)
messages[toolIdx] = messages[toolIdx].copy(progressPreview = event.preview ?: "")
@@ -925,12 +906,7 @@ object ChatWsEventReducer {
event: WsEvent.ToolGenerating,
): ReducerResult {
val messages = state.messages.toMutableList()
val toolIdx =
messages.indexOfLast {
it.role == MessageRole.TOOL &&
it.toolName == event.name &&
it.toolStatus == ToolStatus.RUNNING
}
val toolIdx = findToolIndex(messages, event.name, event.toolId, ToolStatus.RUNNING)
if (toolIdx < 0) return ReducerResult(state = state, streamingState = streamingState)
messages[toolIdx] = messages[toolIdx].copy(progressPreview = "")
@@ -940,6 +916,30 @@ object ChatWsEventReducer {
)
}
/** Match stable tool IDs first, with name matching for legacy events. */
private fun findToolIndex(
messages: List<ChatMessage>,
name: String?,
toolId: String?,
status: ToolStatus,
): Int {
if (!toolId.isNullOrBlank()) {
val byId =
messages.indexOfLast {
it.role == MessageRole.TOOL &&
it.toolCallId == toolId &&
it.toolStatus == status
}
if (byId >= 0) return byId
}
return messages.indexOfLast {
it.role == MessageRole.TOOL &&
it.toolName == name &&
it.toolStatus == status
}
}
private fun onSubagentEvent(
state: ChatUiState,
streamingState: StreamingState,
@@ -25,6 +25,9 @@ internal interface ToolRenderer {
/** Expanded body text. */
fun detail(call: ToolCall): String? = null
/** Expanded body text while the call is still running. */
fun pendingDetail(call: ToolCall): String? = null
/** Structured extras (streams, diffs, search hits) for special row layouts. */
fun extras(
call: ToolCall,
@@ -64,7 +64,13 @@ object ToolViewBuilder {
val detailBody =
ToolJson.stripDividerLines(
renderer.detail(call) ?: ToolJson.fallbackDetailText(call.rawArgs, call.rawResult),
if (running) {
renderer.pendingDetail(call)
?: renderer.detail(call)
?: ToolJson.fallbackDetailText(call.rawArgs, call.rawResult)
} else {
renderer.detail(call) ?: ToolJson.fallbackDetailText(call.rawArgs, call.rawResult)
},
)
val detail =
if (error.isNotEmpty()) {
@@ -221,6 +221,11 @@ internal object ProcessRenderer : ToolRenderer {
return if (procId.isNotEmpty()) "$action: $procId" else action
}
override fun pendingDetail(call: ToolCall): String {
val action = ToolJson.firstString(call.args, listOf("action"))
return "⏳ ${action.ifEmpty { "Process operation" }} in progress"
}
override fun detail(call: ToolCall): String {
val result = call.result
val error = ToolJson.firstString(result, listOf("error"))