fix(tools): 接上 tool_execution 三事件,并改用 id 关联工具卡

客户端此前**只映射了 `tool_execution.update`**——恰好是后端**没有生产者**的那一帧
(后端回执亦确认:`.start`/`.end` 有真实生产者,`.update` 运行期可能永不来)。
真正会发出的 `.start` / `.end` 落进 `else -> null` 被**静默丢弃**,所以
**工具执行状态从未到过界面**:工具卡能显示调用与参数,却永远不显示「执行中 / 完成 / 失败」。

后端 v0.50 已把 `tool_execution` 拆成三帧、载荷为 `{tool, phase, id?}`——刻意**不带
`index` / `streamId`**(WS bridge 注释明写「tool_execution.* 不属于某一次模型流」),
关联键是 **`id`**(与同轮 `tool_call.*` 同一个 provider 调用 id)。所以旧实现按 `index`
归属本身就是错的。

- data/ws/WsEvent.kt:三个事件类型承载 `tool` / `phase` / 可空 `id` / `delta`(仅 update);
  新增私有 `ToolExecutionData`(**无 `index`**);三个帧名都进 `dispatchStreaming`
- ui/chat/ChatViewModel.kt:`ToolPhase { Running, Done, Error }` + `toolPhaseOf`;
  `ToolCallState` 扩 `tool` 与可空 `phase`;新增两个纯函数
  `toolExecutionTargetIndex` / `applyToolExecution`;三帧经既有 `isForSession` 会话闸
- ui/chat/StreamingBlocks.kt:工具卡标题与配色由 `phase` 驱动(`执行 <tool>…` /
  `工具调用 · <tool>` / `工具调用失败 · <tool>`,失败用 error 色)

关联策略是「**不确定就不挂**」,绝不默认 0:`id` 非空 → 精确匹配,找不到返回 -1
(宁可不挂也不挂错);`id` 缺失 → 仅当「恰好一张卡 id 为空」或「整个列表只有一张卡」
才挂,否则 -1。`applyToolExecution` 不建卡、不动别的卡;`tool` 空白保留已有名字、
`phase` 无法识别保留已有阶段、`delta` 为空不改输出。`.update` 那条路也实现并测了。

测试:218 全绿(194 → +24)。新增 10 条三帧解码(按真实 `toolExecutionData` 形状:
id 有/无、phase running/done/error、update 带/不带 delta、多余 `streamId` 被忽略、
旧单帧形状可容忍、会话闸)+ 14 条关联规则与 phase/name/delta 应用。

实机:只读 WS 探针抓到真实帧
`tool_execution.start {"id":"call_00_o9Q…","phase":"running","tool":"calculator"}` 与
`.end {…,"phase":"done","tool":"calculator"}`,`tool_call.*` 共享同一 id、执行帧确实
没有 `index`/`streamId`;屏幕录制抓到渲染出的「工具调用 · calculator」——工具名只可能
来自 `tool_execution.*`,故端到端成立。全程只见 `.start`/`.end`,印证 `.update` 无生产者。

未覆盖:`phase:"running"`(内置工具太快)与 `phase:"error"`(无法诱发)的瞬时态,
仅单测覆盖。
This commit is contained in:
AskaEth committed 2026-10-04 16:28:22 +08:00
1 parent d12a920096
commit 4c14028c70
6 files changed
+522 -18

No files matched your search

@@ -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(""))
}
}