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:
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"
|
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(
|
data class ToolExecutionUpdate(
|
||||||
val id: String,
|
val tool: String,
|
||||||
val index: Int,
|
val phase: String,
|
||||||
val delta: String,
|
val delta: String = "",
|
||||||
|
val id: String? = null,
|
||||||
override val turnId: String? = null,
|
override val turnId: String? = null,
|
||||||
override val seq: Long? = null,
|
override val seq: Long? = null,
|
||||||
override val sessionId: String? = null,
|
override val sessionId: String? = null,
|
||||||
@@ -162,6 +187,18 @@ sealed class WsEvent {
|
|||||||
override val type: String get() = "tool_execution.update"
|
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)。 */
|
/** 向后兼容整段文本(仅非流式回退路径;流式路径不 emit)。 */
|
||||||
data class StreamChunk(
|
data class StreamChunk(
|
||||||
val delta: String,
|
val delta: String,
|
||||||
@@ -359,8 +396,12 @@ sealed class WsEvent {
|
|||||||
.let { ToolCallDelta(it.id, it.index, it.delta, turnId, seq, sessionId) }
|
.let { ToolCallDelta(it.id, it.index, it.delta, turnId, seq, sessionId) }
|
||||||
"tool_call.end" -> decode<ToolCallIdData>(data)
|
"tool_call.end" -> decode<ToolCallIdData>(data)
|
||||||
.let { ToolCallEnd(it.id, it.index, turnId, seq, sessionId) }
|
.let { ToolCallEnd(it.id, it.index, turnId, seq, sessionId) }
|
||||||
"tool_execution.update" -> decode<ToolCallData>(data)
|
"tool_execution.start" -> decode<ToolExecutionData>(data)
|
||||||
.let { ToolExecutionUpdate(it.id, it.index, it.delta, turnId, seq, sessionId) }
|
.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_chunk" -> StreamChunk(decode<StreamChunkData>(data).delta, turnId, seq, sessionId)
|
||||||
"stream_end" -> decode<StreamEndData>(data)
|
"stream_end" -> decode<StreamEndData>(data)
|
||||||
.let { StreamEnd(it.stopReason, it.steps, it.error, turnId, seq, sessionId) }
|
.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
|
@Serializable
|
||||||
private data class ToolCallData(val id: String = "", val index: Int = 0, val delta: String = "")
|
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
|
@Serializable
|
||||||
private data class HistoryData(val messages: List<HistoryMessageDto> = emptyList())
|
private data class HistoryData(val messages: List<HistoryMessageDto> = emptyList())
|
||||||
|
|
||||||
|
|||||||
@@ -28,15 +28,83 @@ private const val DEVICE_ID = "voiceassist:cyrene:v1"
|
|||||||
/** 乐观消息:已发出、还没等到 `message_persisted` 归一的本地消息。 */
|
/** 乐观消息:已发出、还没等到 `message_persisted` 归一的本地消息。 */
|
||||||
data class PendingMessage(val localId: String, val text: String)
|
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(
|
data class ToolCallState(
|
||||||
val id: String,
|
val id: String,
|
||||||
val index: Int,
|
val index: Int,
|
||||||
|
/** 工具名;`tool_call.*` 帧不带,由 `tool_execution.*` 帧补上。 */
|
||||||
|
val tool: String = "",
|
||||||
val arguments: String = "",
|
val arguments: String = "",
|
||||||
val output: String = "",
|
val output: String = "",
|
||||||
|
/** `tool_call.end` 已到(参数流完整)。 */
|
||||||
val done: Boolean = false,
|
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<ToolCallState>, 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<ToolCallState>,
|
||||||
|
id: String?,
|
||||||
|
tool: String,
|
||||||
|
phase: String,
|
||||||
|
delta: String? = null,
|
||||||
|
): List<ToolCallState> {
|
||||||
|
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(
|
data class StreamingState(
|
||||||
val bubbles: List<ParsedBubble> = emptyList(),
|
val bubbles: List<ParsedBubble> = emptyList(),
|
||||||
@@ -251,13 +319,12 @@ class ChatViewModel(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
is WsEvent.ToolExecutionUpdate -> updateStreaming { streaming ->
|
is WsEvent.ToolExecutionStart -> onToolExecution(event.id, event.tool, event.phase)
|
||||||
streaming.copy(
|
|
||||||
tools = streaming.tools.map {
|
is WsEvent.ToolExecutionUpdate ->
|
||||||
if (it.id == event.id) it.copy(output = it.output + event.delta) else it
|
onToolExecution(event.id, event.tool, event.phase, event.delta)
|
||||||
},
|
|
||||||
)
|
is WsEvent.ToolExecutionEnd -> onToolExecution(event.id, event.tool, event.phase)
|
||||||
}
|
|
||||||
|
|
||||||
is WsEvent.StreamEnd -> {
|
is WsEvent.StreamEnd -> {
|
||||||
val bubbles = streamParser?.finish().orEmpty()
|
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,只能这样匹配)
|
* - user:按「同文本」把对应的乐观气泡去掉(协议没有 clientMsgId,只能这样匹配)
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ import androidx.compose.runtime.remember
|
|||||||
import androidx.compose.runtime.setValue
|
import androidx.compose.runtime.setValue
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import top.yeij.cyrene.core.prefs.LocalAppPrefs
|
import top.yeij.cyrene.core.prefs.LocalAppPrefs
|
||||||
import top.yukonga.miuix.kmp.basic.Text
|
import top.yukonga.miuix.kmp.basic.Text
|
||||||
@@ -54,7 +55,7 @@ fun ThinkingCard(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 工具调用卡片:参数与执行输出分开显示,默认折叠。 */
|
/** 工具调用卡片:参数与执行输出分开显示,默认折叠。标题/配色由 `tool_execution.*` 的 phase 驱动。 */
|
||||||
@Composable
|
@Composable
|
||||||
fun ToolCard(
|
fun ToolCard(
|
||||||
tool: ToolCallState,
|
tool: ToolCallState,
|
||||||
@@ -62,10 +63,24 @@ fun ToolCard(
|
|||||||
) {
|
) {
|
||||||
var expanded by remember { mutableStateOf(false) }
|
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 }) {
|
FoldCard(modifier = modifier, onClick = { expanded = !expanded }) {
|
||||||
FoldCardHeader(
|
FoldCardHeader(
|
||||||
title = if (tool.done) "工具调用" else "调用工具…",
|
title = title,
|
||||||
expanded = expanded,
|
expanded = expanded,
|
||||||
|
titleColor = titleColor,
|
||||||
)
|
)
|
||||||
if (expanded) {
|
if (expanded) {
|
||||||
if (tool.arguments.isNotBlank()) {
|
if (tool.arguments.isNotBlank()) {
|
||||||
@@ -101,12 +116,16 @@ private fun FoldCard(
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun FoldCardHeader(title: String, expanded: Boolean) {
|
private fun FoldCardHeader(
|
||||||
|
title: String,
|
||||||
|
expanded: Boolean,
|
||||||
|
titleColor: Color = MiuixTheme.colorScheme.onSurfaceVariantSummary,
|
||||||
|
) {
|
||||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||||
Text(
|
Text(
|
||||||
text = title,
|
text = title,
|
||||||
style = MiuixTheme.textStyles.footnote1,
|
style = MiuixTheme.textStyles.footnote1,
|
||||||
color = MiuixTheme.colorScheme.onSurfaceVariantSummary,
|
color = titleColor,
|
||||||
modifier = Modifier.weight(1f),
|
modifier = Modifier.weight(1f),
|
||||||
)
|
)
|
||||||
Text(
|
Text(
|
||||||
|
|||||||
@@ -146,7 +146,9 @@ class WsEventSessionScopeTest {
|
|||||||
"tool_call.start" to """{"id":"call_1","index":0}""",
|
"tool_call.start" to """{"id":"call_1","index":0}""",
|
||||||
"tool_call.delta" to """{"id":"call_1","index":0,"delta":"a"}""",
|
"tool_call.delta" to """{"id":"call_1","index":0,"delta":"a"}""",
|
||||||
"tool_call.end" to """{"id":"call_1","index":0}""",
|
"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.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_chunk" to """{"delta":"a"}""",
|
||||||
"stream_end" to """{"stopReason":"stop"}""",
|
"stream_end" to """{"stopReason":"stop"}""",
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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"))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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(""))
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in new issue
Block a user