This commit is contained in:
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"))
|
||||
|
||||
@@ -980,7 +980,12 @@ class EventParserTest {
|
||||
mapOf(
|
||||
"type" to "tool.progress",
|
||||
"session_id" to "sess-123",
|
||||
"payload" to mapOf("name" to "web_search", "preview" to "downloading content..."),
|
||||
"payload" to
|
||||
mapOf(
|
||||
"tool_id" to "call-progress",
|
||||
"name" to "web_search",
|
||||
"preview" to "downloading content...",
|
||||
),
|
||||
),
|
||||
)
|
||||
val event = EventParser.parse(response)
|
||||
@@ -989,6 +994,7 @@ class EventParserTest {
|
||||
assertEquals("web_search", toolProgress.name)
|
||||
assertEquals("downloading content...", toolProgress.preview)
|
||||
assertEquals("sess-123", toolProgress.sessionId)
|
||||
assertEquals("call-progress", toolProgress.toolId)
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -1004,7 +1010,7 @@ class EventParserTest {
|
||||
mapOf(
|
||||
"type" to "tool.generating",
|
||||
"session_id" to "sess-123",
|
||||
"payload" to mapOf("name" to "code_writer"),
|
||||
"payload" to mapOf("tool_id" to "call-generating", "name" to "code_writer"),
|
||||
),
|
||||
)
|
||||
val event = EventParser.parse(response)
|
||||
@@ -1012,6 +1018,7 @@ class EventParserTest {
|
||||
val toolGenerating = event as WsEvent.ToolGenerating
|
||||
assertEquals("code_writer", toolGenerating.name)
|
||||
assertEquals("sess-123", toolGenerating.sessionId)
|
||||
assertEquals("call-generating", toolGenerating.toolId)
|
||||
}
|
||||
|
||||
@Test
|
||||
|
||||
@@ -509,6 +509,103 @@ class ChatWsEventReducerTest {
|
||||
assertTrue(result.state.todos[1].isInProgress)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testToolLifecycle_sameNameUsesToolIdForProgressAndCompletion() {
|
||||
val state = ChatUiState(currentSessionId = "session-1")
|
||||
val first =
|
||||
ChatWsEventReducer.reduce(
|
||||
state,
|
||||
StreamingState(),
|
||||
WsEvent.ToolStart(
|
||||
name = "process_manage",
|
||||
data = mapOf("tool_id" to "call-1", "action" to "wait"),
|
||||
sessionId = "session-1",
|
||||
),
|
||||
"session-1",
|
||||
)
|
||||
val second =
|
||||
ChatWsEventReducer.reduce(
|
||||
first.state,
|
||||
first.streamingState,
|
||||
WsEvent.ToolStart(
|
||||
name = "process_manage",
|
||||
data = mapOf("tool_id" to "call-2", "action" to "wait"),
|
||||
sessionId = "session-1",
|
||||
),
|
||||
"session-1",
|
||||
)
|
||||
|
||||
val progress =
|
||||
ChatWsEventReducer.reduce(
|
||||
second.state,
|
||||
second.streamingState,
|
||||
WsEvent.ToolProgress(
|
||||
name = "process_manage",
|
||||
preview = "call-1 is still waiting",
|
||||
sessionId = "session-1",
|
||||
toolId = "call-1",
|
||||
),
|
||||
"session-1",
|
||||
)
|
||||
assertEquals("call-1 is still waiting", progress.state.messages[0].progressPreview)
|
||||
assertNull(progress.state.messages[1].progressPreview)
|
||||
|
||||
val completed =
|
||||
ChatWsEventReducer.reduce(
|
||||
progress.state,
|
||||
progress.streamingState,
|
||||
WsEvent.ToolComplete(
|
||||
name = "process_manage",
|
||||
data = mapOf("tool_id" to "call-1", "output" to "finished"),
|
||||
sessionId = "session-1",
|
||||
),
|
||||
"session-1",
|
||||
)
|
||||
assertEquals(ToolStatus.COMPLETED, completed.state.messages[0].toolStatus)
|
||||
assertEquals(ToolStatus.RUNNING, completed.state.messages[1].toolStatus)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testToolOutputRisk_sameNameUsesToolId() {
|
||||
val state = ChatUiState(currentSessionId = "session-1")
|
||||
val first =
|
||||
ChatWsEventReducer.reduce(
|
||||
state,
|
||||
StreamingState(),
|
||||
WsEvent.ToolStart("terminal", mapOf("tool_id" to "call-1"), "session-1"),
|
||||
"session-1",
|
||||
)
|
||||
val second =
|
||||
ChatWsEventReducer.reduce(
|
||||
first.state,
|
||||
first.streamingState,
|
||||
WsEvent.ToolStart("terminal", mapOf("tool_id" to "call-2"), "session-1"),
|
||||
"session-1",
|
||||
)
|
||||
|
||||
val result =
|
||||
ChatWsEventReducer.reduce(
|
||||
second.state,
|
||||
second.streamingState,
|
||||
WsEvent.ToolOutputRisk(
|
||||
toolId = "call-1",
|
||||
name = "terminal",
|
||||
risk = "high",
|
||||
findings = listOf("secret"),
|
||||
sessionId = "session-1",
|
||||
),
|
||||
"session-1",
|
||||
)
|
||||
|
||||
assertEquals(
|
||||
"high",
|
||||
result.state.messages[0]
|
||||
.toolOutputRiskData
|
||||
?.risk,
|
||||
)
|
||||
assertNull(result.state.messages[1].toolOutputRiskData)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testHydrateTodosFromMessages_parsesStoredToolMessage() {
|
||||
val todoMessage =
|
||||
|
||||
@@ -281,6 +281,21 @@ class ToolViewBuilderTest {
|
||||
assertTrue(view.detail.contains("sleep 100"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `running process_manage shows pending detail instead of completion`() {
|
||||
val view =
|
||||
build(
|
||||
"process_manage",
|
||||
"""{"action":"wait","session_id":"proc-1"}""",
|
||||
"""{"tool_id":"call-1","name":"process_manage","args":{"action":"wait"}}""",
|
||||
running = true,
|
||||
)
|
||||
|
||||
assertEquals(ToolViewStatus.RUNNING, view.status)
|
||||
assertEquals("⏳ wait in progress", view.detail)
|
||||
assertFalse(view.detail.contains("done"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `todo_list alias activates TodoRenderer`() {
|
||||
val view =
|
||||
|
||||
Reference in new issue
Block a user