diff --git a/app/src/main/kotlin/top/yeij/cyrene/data/ws/WsEvent.kt b/app/src/main/kotlin/top/yeij/cyrene/data/ws/WsEvent.kt index 0ffd546..b0f5bb5 100644 --- a/app/src/main/kotlin/top/yeij/cyrene/data/ws/WsEvent.kt +++ b/app/src/main/kotlin/top/yeij/cyrene/data/ws/WsEvent.kt @@ -150,11 +150,36 @@ sealed class WsEvent { override val type: String get() = "tool_call.end" } - /** 工具执行进度 / 局部输出。 */ + /** + * 工具**进入执行**(后端契约 v0.50 `tool_execution` 三事件之一)。 + * + * 这三帧**不属于模型流**(`ws_bridge.go:143`):`data` 里既没有 `index` 也没有 `streamId`, + * 关联键是 provider 的 tool call id([id],与同轮 `tool_call.start/delta/end` 同源; + * provider 没给 id 时该键整个省略,即 `null`)。[phase] 是细粒度执行阶段,取值与流程图 + * tool 节点 status 同一套枚举(`running` / `done` / `error`),**不与 `delta` 混用**。 + */ + data class ToolExecutionStart( + val tool: String, + val phase: String, + val id: String? = null, + override val turnId: String? = null, + override val seq: Long? = null, + override val sessionId: String? = null, + ) : WsEvent() { + override val type: String get() = "tool_execution.start" + } + + /** + * 工具**运行中**的进度 / 局部输出,一次执行可多帧(契约 v0.50)。 + * + * 诚实边界:wire 映射已实现,但后端工具分发器**暂无中途进度上报源**,loop 今天不产出该帧 + * (`start` / `end` 有真实生产者)。此处按契约完整解码,生产者落地后无需再改客户端。 + */ data class ToolExecutionUpdate( - val id: String, - val index: Int, - val delta: String, + val tool: String, + val phase: String, + val delta: String = "", + val id: String? = null, override val turnId: String? = null, override val seq: Long? = null, override val sessionId: String? = null, @@ -162,6 +187,18 @@ sealed class WsEvent { override val type: String get() = "tool_execution.update" } + /** 工具执行**完成或失败**(契约 v0.50):`phase:"error"` 即失败。 */ + data class ToolExecutionEnd( + val tool: String, + val phase: String, + val id: String? = null, + override val turnId: String? = null, + override val seq: Long? = null, + override val sessionId: String? = null, + ) : WsEvent() { + override val type: String get() = "tool_execution.end" + } + /** 向后兼容整段文本(仅非流式回退路径;流式路径不 emit)。 */ data class StreamChunk( val delta: String, @@ -359,8 +396,12 @@ sealed class WsEvent { .let { ToolCallDelta(it.id, it.index, it.delta, turnId, seq, sessionId) } "tool_call.end" -> decode(data) .let { ToolCallEnd(it.id, it.index, turnId, seq, sessionId) } - "tool_execution.update" -> decode(data) - .let { ToolExecutionUpdate(it.id, it.index, it.delta, turnId, seq, sessionId) } + "tool_execution.start" -> decode(data) + .let { ToolExecutionStart(it.tool, it.phase, it.id, turnId, seq, sessionId) } + "tool_execution.update" -> decode(data) + .let { ToolExecutionUpdate(it.tool, it.phase, it.delta, it.id, turnId, seq, sessionId) } + "tool_execution.end" -> decode(data) + .let { ToolExecutionEnd(it.tool, it.phase, it.id, turnId, seq, sessionId) } "stream_chunk" -> StreamChunk(decode(data).delta, turnId, seq, sessionId) "stream_end" -> decode(data) .let { StreamEnd(it.stopReason, it.steps, it.error, turnId, seq, sessionId) } @@ -447,6 +488,20 @@ private data class ToolCallIdData(val id: String = "", val index: Int = 0) @Serializable private data class ToolCallData(val id: String = "", val index: Int = 0, val delta: String = "") +/** + * `tool_execution.*` 的载荷(后端 `ws_bridge.go:toolExecutionData`):`tool` + `phase` 恒在, + * `id` 在 provider 未给时**整个键省略**,`delta` 只在 `update` 且非空时出现。 + * 刻意**不复用** [ToolCallData]:那是 `tool_call.*` 的形状,真的有 `index`;这三帧没有, + * 也没有 `streamId`(它们不属于某一次模型流)。 + */ +@Serializable +private data class ToolExecutionData( + val tool: String = "", + val phase: String = "", + val id: String? = null, + val delta: String = "", +) + @Serializable private data class HistoryData(val messages: List = emptyList()) diff --git a/app/src/main/kotlin/top/yeij/cyrene/ui/chat/ChatViewModel.kt b/app/src/main/kotlin/top/yeij/cyrene/ui/chat/ChatViewModel.kt index d7ee304..31d72df 100644 --- a/app/src/main/kotlin/top/yeij/cyrene/ui/chat/ChatViewModel.kt +++ b/app/src/main/kotlin/top/yeij/cyrene/ui/chat/ChatViewModel.kt @@ -28,15 +28,83 @@ private const val DEVICE_ID = "voiceassist:cyrene:v1" /** 乐观消息:已发出、还没等到 `message_persisted` 归一的本地消息。 */ data class PendingMessage(val localId: String, val text: String) -/** 一次工具调用的中间态(参数增量累积 + 执行输出)。 */ +/** + * 工具执行的细粒度阶段,与后端流程图 tool 节点 status **同一套枚举**(契约 v0.50 §3.1 / §3.5)。 + * `null` = 还没收到任何 `tool_execution.*` 帧(只有调用与参数)。 + */ +enum class ToolPhase { Running, Done, Error } + +/** 后端 `phase` 字符串 → 本地阶段;未知取值回落 `null`(不猜、不替服务端造枚举)。 */ +fun toolPhaseOf(phase: String): ToolPhase? = when (phase) { + "running" -> ToolPhase.Running + "done" -> ToolPhase.Done + "error" -> ToolPhase.Error + else -> null +} + +/** 一次工具调用的中间态(参数增量累积 + 执行阶段 / 输出)。 */ data class ToolCallState( val id: String, val index: Int, + /** 工具名;`tool_call.*` 帧不带,由 `tool_execution.*` 帧补上。 */ + val tool: String = "", val arguments: String = "", val output: String = "", + /** `tool_call.end` 已到(参数流完整)。 */ val done: Boolean = false, + /** 执行阶段;`null` = 尚未进入执行。 */ + val phase: ToolPhase? = null, ) +/** + * 一帧 `tool_execution.*` 该落到 [tools] 的哪张卡上(纯函数;返回下标,`-1` = 不落位)。 + * + * **关联键是 `id`**(provider 的 tool call id,与同轮 `tool_call.*` 同源;这三帧刻意不带 + * `index`——它们不属于任何一次模型流)。`id` 有值但不匹配任何卡 → `-1`:宁可丢这一帧的状态, + * 也**不**错挂到别的工具上。 + * + * **`id` 缺席时的回落**(后端在 provider 没给 id 时整个省略该键):只在**无歧义**时才落位—— + * 1. `id` 为空的工具卡恰好只有一张 → 落那张(id 缺席与 id 为空是同一件事); + * 2. 否则整个列表恰好只有一张卡 → 落那张(这一轮只有一次工具调用,id 缺席不会认错); + * 3. 其余情况(多张卡且无法唯一识别)→ `-1`,**不**默认落 `index == 0` / 第一张。 + */ +fun toolExecutionTargetIndex(tools: List, id: String?): Int { + val key = id?.takeIf { it.isNotBlank() } + if (key != null) return tools.indexOfFirst { it.id == key } + val anonymous = tools.indices.filter { tools[it].id.isBlank() } + if (anonymous.size == 1) return anonymous.single() + if (tools.size == 1) return 0 + return -1 +} + +/** + * 把一帧 `tool_execution.*` 应用到工具卡列表(纯函数,VM 与测试共用)。 + * + * 没找到落点时**原样返回**:不新建卡、不改别的卡。`tool` 为空白时保留卡上已有的名字, + * `phase` 无法识别时保留卡上已有的阶段,`delta` 为空时不改输出。 + */ +fun applyToolExecution( + tools: List, + id: String?, + tool: String, + phase: String, + delta: String? = null, +): List { + val target = toolExecutionTargetIndex(tools, id) + if (target < 0) return tools + return tools.mapIndexed { index, state -> + if (index != target) { + state + } else { + state.copy( + tool = tool.ifBlank { state.tool }, + phase = toolPhaseOf(phase) ?: state.phase, + output = if (delta.isNullOrEmpty()) state.output else state.output + delta, + ) + } + } +} + /** 正在流式的助手回复。 */ data class StreamingState( val bubbles: List = emptyList(), @@ -251,13 +319,12 @@ class ChatViewModel( ) } - is WsEvent.ToolExecutionUpdate -> updateStreaming { streaming -> - streaming.copy( - tools = streaming.tools.map { - if (it.id == event.id) it.copy(output = it.output + event.delta) else it - }, - ) - } + is WsEvent.ToolExecutionStart -> onToolExecution(event.id, event.tool, event.phase) + + is WsEvent.ToolExecutionUpdate -> + onToolExecution(event.id, event.tool, event.phase, event.delta) + + is WsEvent.ToolExecutionEnd -> onToolExecution(event.id, event.tool, event.phase) is WsEvent.StreamEnd -> { val bubbles = streamParser?.finish().orEmpty() @@ -308,6 +375,16 @@ class ChatViewModel( } } + /** + * 收口三帧 `tool_execution.*`:按 `id` 关联到对应的工具卡(关联规则见 + * [toolExecutionTargetIndex]),落 phase / 工具名 / 局部输出。落不到就整帧忽略。 + */ + private fun onToolExecution(id: String?, tool: String, phase: String, delta: String? = null) { + updateStreaming { streaming -> + streaming.copy(tools = applyToolExecution(streaming.tools, id, tool, phase, delta)) + } + } + /** * 落库消息到达: * - user:按「同文本」把对应的乐观气泡去掉(协议没有 clientMsgId,只能这样匹配) diff --git a/app/src/main/kotlin/top/yeij/cyrene/ui/chat/StreamingBlocks.kt b/app/src/main/kotlin/top/yeij/cyrene/ui/chat/StreamingBlocks.kt index ec3e8f1..98173e2 100644 --- a/app/src/main/kotlin/top/yeij/cyrene/ui/chat/StreamingBlocks.kt +++ b/app/src/main/kotlin/top/yeij/cyrene/ui/chat/StreamingBlocks.kt @@ -20,6 +20,7 @@ import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color import androidx.compose.ui.unit.dp import top.yeij.cyrene.core.prefs.LocalAppPrefs import top.yukonga.miuix.kmp.basic.Text @@ -54,7 +55,7 @@ fun ThinkingCard( } } -/** 工具调用卡片:参数与执行输出分开显示,默认折叠。 */ +/** 工具调用卡片:参数与执行输出分开显示,默认折叠。标题/配色由 `tool_execution.*` 的 phase 驱动。 */ @Composable fun ToolCard( tool: ToolCallState, @@ -62,10 +63,24 @@ fun ToolCard( ) { var expanded by remember { mutableStateOf(false) } + val name = tool.tool.takeIf { it.isNotBlank() } + val title = when (tool.phase) { + ToolPhase.Running -> name?.let { "执行 $it…" } ?: "执行中…" + ToolPhase.Done -> name?.let { "工具调用 · $it" } ?: "工具调用" + ToolPhase.Error -> name?.let { "工具调用失败 · $it" } ?: "工具调用失败" + null -> if (tool.done) name?.let { "工具调用 · $it" } ?: "工具调用" else "调用工具…" + } + val titleColor = if (tool.phase == ToolPhase.Error) { + MiuixTheme.colorScheme.error + } else { + MiuixTheme.colorScheme.onSurfaceVariantSummary + } + FoldCard(modifier = modifier, onClick = { expanded = !expanded }) { FoldCardHeader( - title = if (tool.done) "工具调用" else "调用工具…", + title = title, expanded = expanded, + titleColor = titleColor, ) if (expanded) { if (tool.arguments.isNotBlank()) { @@ -101,12 +116,16 @@ private fun FoldCard( } @Composable -private fun FoldCardHeader(title: String, expanded: Boolean) { +private fun FoldCardHeader( + title: String, + expanded: Boolean, + titleColor: Color = MiuixTheme.colorScheme.onSurfaceVariantSummary, +) { Row(verticalAlignment = Alignment.CenterVertically) { Text( text = title, style = MiuixTheme.textStyles.footnote1, - color = MiuixTheme.colorScheme.onSurfaceVariantSummary, + color = titleColor, modifier = Modifier.weight(1f), ) Text( diff --git a/app/src/test/kotlin/top/yeij/cyrene/data/ws/WsEventSessionScopeTest.kt b/app/src/test/kotlin/top/yeij/cyrene/data/ws/WsEventSessionScopeTest.kt index 80b8bdc..057766a 100644 --- a/app/src/test/kotlin/top/yeij/cyrene/data/ws/WsEventSessionScopeTest.kt +++ b/app/src/test/kotlin/top/yeij/cyrene/data/ws/WsEventSessionScopeTest.kt @@ -146,7 +146,9 @@ class WsEventSessionScopeTest { "tool_call.start" to """{"id":"call_1","index":0}""", "tool_call.delta" to """{"id":"call_1","index":0,"delta":"a"}""", "tool_call.end" to """{"id":"call_1","index":0}""", + "tool_execution.start" to """{"tool":"read_file","phase":"running","id":"call_1"}""", "tool_execution.update" to """{"tool":"read_file","phase":"running","id":"call_1","delta":"x"}""", + "tool_execution.end" to """{"tool":"read_file","phase":"done","id":"call_1"}""", "stream_chunk" to """{"delta":"a"}""", "stream_end" to """{"stopReason":"stop"}""", ) diff --git a/app/src/test/kotlin/top/yeij/cyrene/data/ws/WsEventToolExecutionTest.kt b/app/src/test/kotlin/top/yeij/cyrene/data/ws/WsEventToolExecutionTest.kt new file mode 100644 index 0000000..60fe7f9 --- /dev/null +++ b/app/src/test/kotlin/top/yeij/cyrene/data/ws/WsEventToolExecutionTest.kt @@ -0,0 +1,172 @@ +package top.yeij.cyrene.data.ws + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test + +/** + * 契约 v0.50 把旧的单帧 `tool_execution.update {id,index,delta}` 拆成 **三帧** + * (`tool_execution.start` / `.update` / `.end`)。本端此前只映射 `.update`,且用的是 + * `tool_call.*` 的 DTO(读 `index`、丢掉 `tool` / `phase`)——`start` / `end` 直接落到未知类型 + * 分支被**静默丢弃**,而 `.update` 后端**没有生产者**,于是工具执行状态根本到不了 UI。 + * + * 帧形状逐字取自后端 `internal/services/gateway/ws_bridge.go` 的 `toolExecutionData` + * (`tool` + `phase` 恒在,`id` 空则省略该键,`delta` 只在 `.update` 且非空时出现)与 + * `streamEventData` 的类型名;这三帧**不带 `index` / `streamId`**(不属于模型流)。 + */ +class WsEventToolExecutionTest { + + private val turnId = "turn-3a77c0e5b1d9f204" + + private fun frame( + type: String, + data: String? = null, + sessionId: String? = "web", + seq: Int? = 17, + ): String = buildString { + append("""{"type":"$type"""") + append(""","turnId":"$turnId"""") + if (seq != null) append(""","seq":$seq""") + if (sessionId != null) append(""","sessionId":"$sessionId"""") + if (data != null) append(""","data":$data""") + append("}") + } + + private fun parse(raw: String): WsEvent = + requireNotNull(WsEvent.parse(raw)) { "帧没解出来(就是被静默丢弃):$raw" } + + // —— start —— + + @Test + fun `tool execution start decodes tool phase and id`() { + val ev = parse(frame("tool_execution.start", """{"tool":"bash","phase":"running","id":"call_1"}""")) + as WsEvent.ToolExecutionStart + + assertEquals("bash", ev.tool) + assertEquals("running", ev.phase) + assertEquals("call_1", ev.id) + assertEquals("web", ev.sessionId) + assertEquals(turnId, ev.turnId) + assertEquals(17L, ev.seq) + } + + @Test + fun `tool execution start without an id keeps it null`() { + // provider 没给 id 时后端整个省略该键(toolExecutionData 只在非空时并进 data)。 + val ev = parse(frame("tool_execution.start", """{"tool":"bash","phase":"running"}""")) + as WsEvent.ToolExecutionStart + + assertNull(ev.id) + assertEquals("bash", ev.tool) + } + + // —— update(后端暂无生产者,但契约已在) —— + + @Test + fun `tool execution update with delta decodes the partial output`() { + val ev = parse( + frame( + "tool_execution.update", + """{"tool":"bash","phase":"running","id":"call_1","delta":"line 1\n"}""", + ), + ) as WsEvent.ToolExecutionUpdate + + assertEquals("bash", ev.tool) + assertEquals("running", ev.phase) + assertEquals("call_1", ev.id) + assertEquals("line 1\n", ev.delta) + } + + @Test + fun `tool execution update without delta defaults to an empty delta and null id`() { + val ev = parse(frame("tool_execution.update", """{"tool":"bash","phase":"running"}""")) + as WsEvent.ToolExecutionUpdate + + assertEquals("", ev.delta) + assertNull(ev.id) + assertEquals("bash", ev.tool) + } + + // —— end —— + + @Test + fun `tool execution end done decodes the completion phase`() { + val ev = parse(frame("tool_execution.end", """{"tool":"bash","phase":"done","id":"call_1"}""")) + as WsEvent.ToolExecutionEnd + + assertEquals("bash", ev.tool) + assertEquals("done", ev.phase) + assertEquals("call_1", ev.id) + assertEquals("web", ev.sessionId) + } + + @Test + fun `tool execution end error decodes the failure phase`() { + val ev = parse(frame("tool_execution.end", """{"tool":"bash","phase":"error"}""")) + as WsEvent.ToolExecutionEnd + + assertEquals("error", ev.phase) + assertNull(ev.id) + } + + // —— 回归:三帧都不能再被丢弃 / 不能读错字段 —— + + @Test + fun `all three execution frames parse instead of falling through to null`() { + val frames = listOf( + "tool_execution.start" to """{"tool":"read_file","phase":"running","id":"call_9"}""", + "tool_execution.update" to """{"tool":"read_file","phase":"running","id":"call_9","delta":"x"}""", + "tool_execution.end" to """{"tool":"read_file","phase":"done","id":"call_9"}""", + ) + + for ((type, data) in frames) { + assertNotNull("$type 又被丢了", WsEvent.parse(frame(type, data))) + } + } + + @Test + fun `an update frame does not need index and ignores a stray streamId`() { + // 契约明说这三帧不带 index / streamId;万一服务端多塞一个键,也不该整帧解析失败。 + val ev = parse( + frame( + "tool_execution.update", + """{"tool":"bash","phase":"running","id":"call_1","delta":"x","streamId":"s-1"}""", + ), + ) as WsEvent.ToolExecutionUpdate + + assertEquals("x", ev.delta) + assertEquals("call_1", ev.id) + } + + @Test + fun `a legacy single frame update still decodes rather than being dropped`() { + // v0.50 之前的旧形状({id,index,delta})——loop 已不再产生,但解出来不该崩; + // `index` 是被忽略的未知键,`tool` / `phase` 缺席回落空串。 + val ev = parse(frame("tool_execution.update", """{"id":"call_1","index":0,"delta":"12 行"}""")) + as WsEvent.ToolExecutionUpdate + + assertEquals("call_1", ev.id) + assertEquals("12 行", ev.delta) + assertEquals("", ev.tool) + assertEquals("", ev.phase) + } + + // —— 会话闸对三帧同样生效 —— + + @Test + fun `execution frames pass the session gate like every structured stream frame`() { + val own = parse( + frame("tool_execution.start", """{"tool":"bash","phase":"running"}""", sessionId = "sess-9c3"), + ) + val foreign = parse( + frame("tool_execution.end", """{"tool":"bash","phase":"done"}""", sessionId = "web"), + ) + + assertTrue(own.isForSession("sess-9c3")) + assertFalse(own.isForSession("web")) + assertFalse(foreign.isForSession("sess-9c3")) + } +} diff --git a/app/src/test/kotlin/top/yeij/cyrene/ui/chat/ToolExecutionCorrelationTest.kt b/app/src/test/kotlin/top/yeij/cyrene/ui/chat/ToolExecutionCorrelationTest.kt new file mode 100644 index 0000000..3b1bfbc --- /dev/null +++ b/app/src/test/kotlin/top/yeij/cyrene/ui/chat/ToolExecutionCorrelationTest.kt @@ -0,0 +1,179 @@ +package top.yeij.cyrene.ui.chat + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Test + +/** + * 工具执行帧与工具卡的**关联规则**(契约 v0.50): + * + * - 三帧 `tool_execution.*` **不带 `index`**(不属于模型流),关联键是 provider 的 tool call id; + * - `id` 有值:按 id 精确匹配,匹配不上**不落位**(宁可丢状态,也不错挂); + * - `id` 缺席(provider 没给,后端整个省略该键):只在**无歧义**时落位——唯一一张 `id` 为空的卡, + * 或整轮只有一张卡;多张卡无法区分时**不落位**,绝不默认 `index == 0`。 + * + * [toolExecutionTargetIndex] 是规则本体;[applyToolExecution] 是 VM 实际消费它的纯函数, + * 这里一并验证 phase / 工具名 / delta 的落法。 + */ +class ToolExecutionCorrelationTest { + + private fun tool( + id: String, + index: Int = 0, + tool: String = "", + arguments: String = "", + output: String = "", + done: Boolean = false, + phase: ToolPhase? = null, + ) = ToolCallState( + id = id, + index = index, + tool = tool, + arguments = arguments, + output = output, + done = done, + phase = phase, + ) + + // —— id 匹配 —— + + @Test + fun `a frame matches the tool card carrying the same id`() { + val tools = listOf(tool("call_1"), tool("call_2"), tool("call_3")) + + assertEquals(1, toolExecutionTargetIndex(tools, "call_2")) + } + + @Test + fun `a frame whose id matches no tool call is not attributed`() { + val tools = listOf(tool("call_1"), tool("call_2")) + + assertEquals(-1, toolExecutionTargetIndex(tools, "call_9")) + assertEquals(-1, toolExecutionTargetIndex(emptyList(), "call_1")) + } + + // —— id 缺席的回落 —— + + @Test + fun `an id-less frame targets the only anonymous card`() { + // provider 没给 id 时 tool_call.* 也带空 id,客户端那张卡的 id 就是空串。 + val tools = listOf(tool("call_1"), tool(""), tool("call_3")) + + assertEquals(1, toolExecutionTargetIndex(tools, null)) + assertEquals(1, toolExecutionTargetIndex(tools, "")) + assertEquals(1, toolExecutionTargetIndex(tools, " ")) + } + + @Test + fun `an id-less frame targets the single card of the turn`() { + // 这一轮只有一次工具调用:id 缺席也不会认错。 + val tools = listOf(tool("call_1")) + + assertEquals(0, toolExecutionTargetIndex(tools, null)) + } + + @Test + fun `an id-less frame is not attributed when several cards are present`() { + // 两张卡都带真实 id:无法判定这一帧属于谁 → 不落位,绝不默认 0。 + val tools = listOf(tool("call_1"), tool("call_2")) + + assertEquals(-1, toolExecutionTargetIndex(tools, null)) + // 两个匿名卡(provider 全程没给 id)同样无歧义 → 不落位。 + assertEquals(-1, toolExecutionTargetIndex(listOf(tool(""), tool("")), null)) + } + + @Test + fun `an id-less frame on an empty list is not attributed`() { + assertEquals(-1, toolExecutionTargetIndex(emptyList(), null)) + } + + // —— 应用帧(VM 消费的纯函数) —— + + @Test + fun `running done and error phases drive the card state`() { + val initial = listOf(tool("call_1")) + + val running = applyToolExecution(initial, "call_1", "bash", "running") + assertEquals(ToolPhase.Running, running.single().phase) + + val done = applyToolExecution(running, "call_1", "bash", "done") + assertEquals(ToolPhase.Done, done.single().phase) + + val error = applyToolExecution(done, "call_1", "bash", "error") + assertEquals(ToolPhase.Error, error.single().phase) + } + + @Test + fun `an update frame appends the delta and keeps the tool name`() { + val tools = listOf(tool("call_1", tool = "bash")) + + val once = applyToolExecution(tools, "call_1", "bash", "running", "line 1\n") + val twice = applyToolExecution(once, "call_1", "bash", "running", "line 2\n") + + assertEquals("line 1\nline 2\n", twice.single().output) + assertEquals("bash", twice.single().tool) + } + + @Test + fun `the execution frame fills in the tool name the call frames do not carry`() { + val tools = listOf(tool("call_1")) + + val named = applyToolExecution(tools, "call_1", "read_file", "running") + + assertEquals("read_file", named.single().tool) + } + + @Test + fun `an empty tool name or blank delta leaves existing state untouched`() { + val tools = listOf(tool("call_1", tool = "bash", output = "already")) + + val same = applyToolExecution(tools, "call_1", "", "running", "") + val nullDelta = applyToolExecution(tools, "call_1", "", "running", null) + + assertEquals("bash", same.single().tool) + assertEquals("already", same.single().output) + assertEquals("already", nullDelta.single().output) + } + + @Test + fun `an unrecognised phase keeps the previous phase`() { + val tools = listOf(tool("call_1", phase = ToolPhase.Running)) + + val same = applyToolExecution(tools, "call_1", "bash", "paused") + + assertEquals(ToolPhase.Running, same.single().phase) + } + + @Test + fun `other cards are never touched`() { + val tools = listOf(tool("call_1", output = "a"), tool("call_2", output = "b")) + + val after = applyToolExecution(tools, "call_2", "bash", "done", "!") + + assertEquals("a", after[0].output) + assertNull(after[0].phase) + assertEquals("b!", after[1].output) + assertEquals(ToolPhase.Done, after[1].phase) + } + + @Test + fun `an unattributable frame returns the list unchanged`() { + val tools = listOf(tool("call_1"), tool("call_2")) + + // id 有值但不认识 → 不改任何卡。 + assertEquals(tools, applyToolExecution(tools, "call_9", "bash", "done")) + // id 缺席且有多张卡 → 不改任何卡。 + assertEquals(tools, applyToolExecution(tools, null, "bash", "done", "x")) + } + + // —— phase 字符串映射 —— + + @Test + fun `phase strings map to the backend tool node enum`() { + assertEquals(ToolPhase.Running, toolPhaseOf("running")) + assertEquals(ToolPhase.Done, toolPhaseOf("done")) + assertEquals(ToolPhase.Error, toolPhaseOf("error")) + assertNull(toolPhaseOf("paused")) + assertNull(toolPhaseOf("")) + } +}