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

@@ -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<ToolCallIdData>(data)
.let { ToolCallEnd(it.id, it.index, turnId, seq, sessionId) }
"tool_execution.update" -> decode<ToolCallData>(data)
.let { ToolExecutionUpdate(it.id, it.index, it.delta, turnId, seq, sessionId) }
"tool_execution.start" -> decode<ToolExecutionData>(data)
.let { ToolExecutionStart(it.tool, it.phase, it.id, turnId, seq, sessionId) }
"tool_execution.update" -> decode<ToolExecutionData>(data)
.let { ToolExecutionUpdate(it.tool, it.phase, it.delta, it.id, turnId, seq, sessionId) }
"tool_execution.end" -> decode<ToolExecutionData>(data)
.let { ToolExecutionEnd(it.tool, it.phase, it.id, turnId, seq, sessionId) }
"stream_chunk" -> StreamChunk(decode<StreamChunkData>(data).delta, turnId, seq, sessionId)
"stream_end" -> decode<StreamEndData>(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<HistoryMessageDto> = emptyList())