This commit is contained in:
9 files changed
+179
-42
No files matched your search
@@ -128,14 +128,16 @@ object EventParser {
|
|||||||
}
|
}
|
||||||
|
|
||||||
"tool.progress" -> {
|
"tool.progress" -> {
|
||||||
|
val toolId = payload?.get("tool_id") as? String
|
||||||
val name = payload?.get("name") as? String
|
val name = payload?.get("name") as? String
|
||||||
val preview = payload?.get("preview") as? String
|
val preview = payload?.get("preview") as? String
|
||||||
WsEvent.ToolProgress(name, preview, sessionId)
|
WsEvent.ToolProgress(name, preview, sessionId, toolId)
|
||||||
}
|
}
|
||||||
|
|
||||||
"tool.generating" -> {
|
"tool.generating" -> {
|
||||||
|
val toolId = payload?.get("tool_id") as? String
|
||||||
val name = payload?.get("name") 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" -> {
|
"subagent.spawn_requested", "subagent.start", "subagent.progress", "subagent.complete" -> {
|
||||||
|
|||||||
@@ -104,23 +104,25 @@ sealed class WsEvent {
|
|||||||
* Live tool execution progress with optional preview content.
|
* Live tool execution progress with optional preview content.
|
||||||
*
|
*
|
||||||
* Events: `tool.progress`
|
* Events: `tool.progress`
|
||||||
* Payload: `{ name?: string, preview?: string }`
|
* Payload: `{ tool_id?: string, name?: string, preview?: string }`
|
||||||
*/
|
*/
|
||||||
data class ToolProgress(
|
data class ToolProgress(
|
||||||
val name: String? = null,
|
val name: String? = null,
|
||||||
val preview: String? = null,
|
val preview: String? = null,
|
||||||
val sessionId: String? = null,
|
val sessionId: String? = null,
|
||||||
|
val toolId: String? = null,
|
||||||
) : WsEvent()
|
) : WsEvent()
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Tool generation active state.
|
* Tool generation active state.
|
||||||
*
|
*
|
||||||
* Events: `tool.generating`
|
* Events: `tool.generating`
|
||||||
* Payload: `{ name?: string }`
|
* Payload: `{ tool_id?: string, name?: string }`
|
||||||
*/
|
*/
|
||||||
data class ToolGenerating(
|
data class ToolGenerating(
|
||||||
val name: String? = null,
|
val name: String? = null,
|
||||||
val sessionId: String? = null,
|
val sessionId: String? = null,
|
||||||
|
val toolId: String? = null,
|
||||||
) : WsEvent()
|
) : WsEvent()
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -639,12 +639,7 @@ object ChatWsEventReducer {
|
|||||||
OkHttpProvider.json.encodeToString(it.toJsonElement())
|
OkHttpProvider.json.encodeToString(it.toJsonElement())
|
||||||
} ?: ""
|
} ?: ""
|
||||||
val messages = state.messages.toMutableList()
|
val messages = state.messages.toMutableList()
|
||||||
val toolIdx =
|
val toolIdx = findToolIndex(messages, event.name, event.data?.get("tool_id") as? String, ToolStatus.RUNNING)
|
||||||
messages.indexOfLast {
|
|
||||||
it.role == MessageRole.TOOL &&
|
|
||||||
it.toolName == event.name &&
|
|
||||||
it.toolStatus == ToolStatus.RUNNING
|
|
||||||
}
|
|
||||||
if (toolIdx < 0) return ReducerResult(state = state, streamingState = streamingState)
|
if (toolIdx < 0) return ReducerResult(state = state, streamingState = streamingState)
|
||||||
|
|
||||||
val updated =
|
val updated =
|
||||||
@@ -675,10 +670,10 @@ object ChatWsEventReducer {
|
|||||||
/**
|
/**
|
||||||
* Attaches [ToolOutputRiskData] to the matching tool message.
|
* Attaches [ToolOutputRiskData] to the matching tool message.
|
||||||
*
|
*
|
||||||
* Finds the last RUNNING tool message with matching [WsEvent.ToolOutputRisk.name].
|
* Finds the matching stable tool ID first. If the event has no ID or no ID
|
||||||
* If no RUNNING message exists (tool already completed), falls back to the last
|
* match exists, it falls back to the tool name for legacy payloads. It
|
||||||
* COMPLETED tool with that name. This covers the case where the risk event arrives
|
* prefers RUNNING tools and then falls back to COMPLETED tools because the
|
||||||
* after tool.complete.
|
* risk event may arrive after tool.complete.
|
||||||
*/
|
*/
|
||||||
private fun onToolOutputRisk(
|
private fun onToolOutputRisk(
|
||||||
state: ChatUiState,
|
state: ChatUiState,
|
||||||
@@ -694,20 +689,11 @@ object ChatWsEventReducer {
|
|||||||
|
|
||||||
val messages = state.messages.toMutableList()
|
val messages = state.messages.toMutableList()
|
||||||
|
|
||||||
// Prefer RUNNING tool, fall back to COMPLETED
|
// Prefer RUNNING tool, fall back to COMPLETED. Stable tool IDs win over
|
||||||
var toolIdx =
|
// name matching so overlapping calls to the same tool stay isolated.
|
||||||
messages.indexOfLast {
|
var toolIdx = findToolIndex(messages, event.name, event.toolId, ToolStatus.RUNNING)
|
||||||
it.role == MessageRole.TOOL &&
|
|
||||||
it.toolName == event.name &&
|
|
||||||
it.toolStatus == ToolStatus.RUNNING
|
|
||||||
}
|
|
||||||
if (toolIdx < 0) {
|
if (toolIdx < 0) {
|
||||||
toolIdx =
|
toolIdx = findToolIndex(messages, event.name, event.toolId, ToolStatus.COMPLETED)
|
||||||
messages.indexOfLast {
|
|
||||||
it.role == MessageRole.TOOL &&
|
|
||||||
it.toolName == event.name &&
|
|
||||||
it.toolStatus == ToolStatus.COMPLETED
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
if (toolIdx < 0) return ReducerResult(state = state, streamingState = streamingState)
|
if (toolIdx < 0) return ReducerResult(state = state, streamingState = streamingState)
|
||||||
|
|
||||||
@@ -904,12 +890,7 @@ object ChatWsEventReducer {
|
|||||||
event: WsEvent.ToolProgress,
|
event: WsEvent.ToolProgress,
|
||||||
): ReducerResult {
|
): ReducerResult {
|
||||||
val messages = state.messages.toMutableList()
|
val messages = state.messages.toMutableList()
|
||||||
val toolIdx =
|
val toolIdx = findToolIndex(messages, event.name, event.toolId, ToolStatus.RUNNING)
|
||||||
messages.indexOfLast {
|
|
||||||
it.role == MessageRole.TOOL &&
|
|
||||||
it.toolName == event.name &&
|
|
||||||
it.toolStatus == ToolStatus.RUNNING
|
|
||||||
}
|
|
||||||
if (toolIdx < 0) return ReducerResult(state = state, streamingState = streamingState)
|
if (toolIdx < 0) return ReducerResult(state = state, streamingState = streamingState)
|
||||||
|
|
||||||
messages[toolIdx] = messages[toolIdx].copy(progressPreview = event.preview ?: "")
|
messages[toolIdx] = messages[toolIdx].copy(progressPreview = event.preview ?: "")
|
||||||
@@ -925,12 +906,7 @@ object ChatWsEventReducer {
|
|||||||
event: WsEvent.ToolGenerating,
|
event: WsEvent.ToolGenerating,
|
||||||
): ReducerResult {
|
): ReducerResult {
|
||||||
val messages = state.messages.toMutableList()
|
val messages = state.messages.toMutableList()
|
||||||
val toolIdx =
|
val toolIdx = findToolIndex(messages, event.name, event.toolId, ToolStatus.RUNNING)
|
||||||
messages.indexOfLast {
|
|
||||||
it.role == MessageRole.TOOL &&
|
|
||||||
it.toolName == event.name &&
|
|
||||||
it.toolStatus == ToolStatus.RUNNING
|
|
||||||
}
|
|
||||||
if (toolIdx < 0) return ReducerResult(state = state, streamingState = streamingState)
|
if (toolIdx < 0) return ReducerResult(state = state, streamingState = streamingState)
|
||||||
|
|
||||||
messages[toolIdx] = messages[toolIdx].copy(progressPreview = "")
|
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(
|
private fun onSubagentEvent(
|
||||||
state: ChatUiState,
|
state: ChatUiState,
|
||||||
streamingState: StreamingState,
|
streamingState: StreamingState,
|
||||||
|
|||||||
@@ -25,6 +25,9 @@ internal interface ToolRenderer {
|
|||||||
/** Expanded body text. */
|
/** Expanded body text. */
|
||||||
fun detail(call: ToolCall): String? = null
|
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. */
|
/** Structured extras (streams, diffs, search hits) for special row layouts. */
|
||||||
fun extras(
|
fun extras(
|
||||||
call: ToolCall,
|
call: ToolCall,
|
||||||
|
|||||||
@@ -64,7 +64,13 @@ object ToolViewBuilder {
|
|||||||
|
|
||||||
val detailBody =
|
val detailBody =
|
||||||
ToolJson.stripDividerLines(
|
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 =
|
val detail =
|
||||||
if (error.isNotEmpty()) {
|
if (error.isNotEmpty()) {
|
||||||
|
|||||||
@@ -221,6 +221,11 @@ internal object ProcessRenderer : ToolRenderer {
|
|||||||
return if (procId.isNotEmpty()) "$action: $procId" else action
|
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 {
|
override fun detail(call: ToolCall): String {
|
||||||
val result = call.result
|
val result = call.result
|
||||||
val error = ToolJson.firstString(result, listOf("error"))
|
val error = ToolJson.firstString(result, listOf("error"))
|
||||||
|
|||||||
@@ -980,7 +980,12 @@ class EventParserTest {
|
|||||||
mapOf(
|
mapOf(
|
||||||
"type" to "tool.progress",
|
"type" to "tool.progress",
|
||||||
"session_id" to "sess-123",
|
"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)
|
val event = EventParser.parse(response)
|
||||||
@@ -989,6 +994,7 @@ class EventParserTest {
|
|||||||
assertEquals("web_search", toolProgress.name)
|
assertEquals("web_search", toolProgress.name)
|
||||||
assertEquals("downloading content...", toolProgress.preview)
|
assertEquals("downloading content...", toolProgress.preview)
|
||||||
assertEquals("sess-123", toolProgress.sessionId)
|
assertEquals("sess-123", toolProgress.sessionId)
|
||||||
|
assertEquals("call-progress", toolProgress.toolId)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@@ -1004,7 +1010,7 @@ class EventParserTest {
|
|||||||
mapOf(
|
mapOf(
|
||||||
"type" to "tool.generating",
|
"type" to "tool.generating",
|
||||||
"session_id" to "sess-123",
|
"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)
|
val event = EventParser.parse(response)
|
||||||
@@ -1012,6 +1018,7 @@ class EventParserTest {
|
|||||||
val toolGenerating = event as WsEvent.ToolGenerating
|
val toolGenerating = event as WsEvent.ToolGenerating
|
||||||
assertEquals("code_writer", toolGenerating.name)
|
assertEquals("code_writer", toolGenerating.name)
|
||||||
assertEquals("sess-123", toolGenerating.sessionId)
|
assertEquals("sess-123", toolGenerating.sessionId)
|
||||||
|
assertEquals("call-generating", toolGenerating.toolId)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
|
|||||||
@@ -509,6 +509,103 @@ class ChatWsEventReducerTest {
|
|||||||
assertTrue(result.state.todos[1].isInProgress)
|
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
|
@Test
|
||||||
fun testHydrateTodosFromMessages_parsesStoredToolMessage() {
|
fun testHydrateTodosFromMessages_parsesStoredToolMessage() {
|
||||||
val todoMessage =
|
val todoMessage =
|
||||||
|
|||||||
@@ -281,6 +281,21 @@ class ToolViewBuilderTest {
|
|||||||
assertTrue(view.detail.contains("sleep 100"))
|
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
|
@Test
|
||||||
fun `todo_list alias activates TodoRenderer`() {
|
fun `todo_list alias activates TodoRenderer`() {
|
||||||
val view =
|
val view =
|
||||||
|
|||||||
Reference in new issue
Block a user