feat(chat): 工具卡接上 resultSummary 结果区(缺席时与今天逐字节一致)

后端新契约切片给 `tool_execution.end` 的 `data` 补了一个**可选** `resultSummary`
(与回看面 `toolCalls[].resultSummary` 同一份预算:已脱敏、已截断 ≤500 字符)。
它的语义是「拿到就带、没拿到就缺席」——失败 / 取消 / 超时 / 未落图时**整个键缺席**,
且契约明说**别把缺席读成「没有输出」**。在那之前工具卡只能显示工具名与状态。

改了什么:
- wire:`WsEvent.ToolExecutionEnd` 新增 `resultSummary: String? = null`;共用的
  `ToolExecutionData` 跟着加这个字段(只由 `.end` 读,`.start` / `.update` 的领域事件
  仍然没有它)。缺键 ⇒ `null`,空串 ⇒ `""`,都在解码层照原样保留、不编结论。
- 领域:`ToolCallState` 新增 `resultSummary`;`applyToolExecution` 多一个同参数
  (非空白时顶替、缺席 / 空白时保留原值——三帧里只有 `.end` 带它,一次落不到不该抹掉旧的);
  VM 的 `onToolExecution` 与 `ToolExecutionEnd` 分支透传。
- 判据:新增纯函数 `toolResultTextOf(tool)`:摘要非空白 ⇒ 用它;否则回落 `output`
  (今天唯一的来源,口径不变);两者都没有 ⇒ `null`。
- UI:`ToolCard` 折叠区里「结果」那一块(参数之后)改由 `toolResultTextOf` 决定要不要画。
  `null` 时**连标签与它上方那道 Spacer 都不画** ⇒ 摘要缺席时这一屏与后端补这个键之前
  逐字节相同。空串同理(服务端承诺不发空串,但真发了也绝不渲染出一个空的结果区块)。
  摘要只展示:不再截断第二次、不解析、不当 markdown;正文沿用与参数同一个 `FoldCardBody`
  (`SelectionContainer` + 不设 `maxLines`),长文本整段可见,没有新增任何滚动 / 限高 modifier。

为什么这么做:一句话的判据、纯函数、单测可钉;渲染层只做「画 / 不画」这一个决定,
于是「老服务端(键恒缺席)」与「新服务端(键在场)」共用同一套 UI,不用等部署。

测试:新增 14 条(`WsEventToolExecutionTest` +5、`ToolExecutionCorrelationTest` +9),
覆盖 ①键在场 ⇒ 解出该值(含 500 字符原样透传)②键缺席 ⇒ `null` 且不报错
③空串 ⇒ 解出 `""` 且判据按「不画」处理 ④`toolResultTextOf` 在缺席 / 空串 / 有值三档下的输出
(含摘要优先于 `output`、结果落卡、缺席不抹旧值、落不到卡时不影响任何卡)。
`assembleDebug` 通过;全量单测 114 suites / 1388 tests / 0 failures / 0 errors(基线 1374)。
This commit is contained in:
AskaEth committed 2026-10-07 10:14:10 +08:00
1 parent c40a752bcf
commit 316cd27049
5 files changed
+223 -8

No files matched your search

@@ -190,10 +190,19 @@ sealed class WsEvent {
override val type: String get() = "tool_execution.update"
}
/** 工具执行**完成或失败**(契约 v0.50):`phase:"error"` 即失败。 */
/**
* 工具执行**完成或失败**(契约 v0.50):`phase:"error"` 即失败。
*
* [resultSummary] 是**执行结果摘要**(新契约切片,与回看面 `toolCalls[].resultSummary` 同一份预算:
* 已经脱敏、已经截断 ≤500 字符的成品,本端只展示)。它的语义是**缺席即「不知道」**:
* 只有这一轮的执行结果**真的拿到了**才在场,失败 / 取消 / 超时 / 未落图时后端**整个键省略**
* (且承诺不用空串顶位)。所以 `null` 与 `""` 在本端都读作**不知道**——绝不解成「没有输出」,
* 因为那是替服务端下一个它没下的结论。当前服务端**尚未部署**该键 ⇒ 真机上它恒缺席。
*/
data class ToolExecutionEnd(
val tool: String,
val phase: String,
val resultSummary: String? = null,
val id: String? = null,
override val turnId: String? = null,
override val seq: Long? = null,
@@ -760,7 +769,7 @@ sealed class WsEvent {
"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) }
.let { ToolExecutionEnd(it.tool, it.phase, it.resultSummary, 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) }
@@ -983,6 +992,10 @@ private data class ToolCallData(val id: String = "", val index: Int = 0, val del
* `id` 在 provider 未给时**整个键省略**,`delta` 只在 `update` 且非空时出现。
* 刻意**不复用** [ToolCallData]:那是 `tool_call.*` 的形状,真的有 `index`;这三帧没有,
* 也没有 `streamId`(它们不属于某一次模型流)。
*
* [resultSummary] 只由 `.end` 携带(后端在新契约切片里只补了 `.end`)。它放在这份共用载荷上
* 是因为三帧形状同源、共用一个 DTO;`.start` / `.update` 即便被塞了这个键也**不读**
* (它们的领域事件上根本没有这个字段),所以契约没变宽——变宽的只是「多一个键不再算坏帧」。
*/
@Serializable
private data class ToolExecutionData(
@@ -990,6 +1003,7 @@ private data class ToolExecutionData(
val phase: String = "",
val id: String? = null,
val delta: String = "",
val resultSummary: String? = null,
)
@Serializable
@@ -395,6 +395,14 @@ data class ToolCallState(
val tool: String = "",
val arguments: String = "",
val output: String = "",
/**
* 执行结果摘要(`tool_execution.end` 的可选 `resultSummary`;服务端当前**未部署**该键)。
*
* `null` 与 `""` 都是**「不知道」**:`null` 是键缺席(老服务端 / 这一轮没拿到结果),
* `""` 是万一被空串顶位。两者**都不等于**「没有输出」——那是我们替服务端下的结论,
* 契约明确不要这么读。渲染判据见 [toolResultTextOf]。
*/
val resultSummary: String? = null,
/** `tool_call.end` 已到(参数流完整)。 */
val done: Boolean = false,
/** 执行阶段;`null` = 尚未进入执行。 */
@@ -422,11 +430,35 @@ fun toolExecutionTargetIndex(tools: List<ToolCallState>, id: String?): Int {
return -1
}
/**
* 工具卡的**结果区该显示什么**(纯函数):返回 `null` = **一个像素都不画**。
*
* 判据只有两条来源,优先级如下:
* 1. [ToolCallState.resultSummary] 非空白 ⇒ 显示它。它是服务端脱敏 + 截断 ≤500 字符的**成品**,
* 本端**只展示**:不再截断、不解析、不当 markdown。
* 2. 否则回落到 [ToolCallState.output](`tool_execution.update` 累积的局部输出)——这是这张卡
* 今天唯一的来源,**逐字节保持不变**。
*
* ⚠️ 两个方向都不能反过来读:
* - 摘要**缺席或为空**时**绝不**合成「没有输出」/「无结果」这类文案。契约明说缺席只表示
* 「不知道」(这一轮的结果没拿到,可能是失败 / 取消 / 超时 / 未落图),替服务端下结论就是撒谎。
* - 空串与 `null` **同等对待**(都按不画):服务端承诺不会发空串,但只要它真发了,
* 渲染出一个空的「结果」区块比什么都不画更糟。
*/
fun toolResultTextOf(tool: ToolCallState): String? {
val summary = tool.resultSummary
if (!summary.isNullOrBlank()) return summary
return tool.output.takeIf { it.isNotBlank() }
}
/**
* 把一帧 `tool_execution.*` 应用到工具卡列表(纯函数,VM 与测试共用)。
*
* 没找到落点时**原样返回**:不新建卡、不改别的卡。`tool` 为空白时保留卡上已有的名字,
* `phase` 无法识别时保留卡上已有的阶段,`delta` 为空时不改输出。
*
* [resultSummary] 与 `delta` 不同:它是**整段成品**,非空白时直接顶替(不是追加)。缺席(`null`)
* 或空白时**保留卡上已有的值**——三帧里只有 `.end` 会带它,一次落不到不该把上一次的抹掉。
*/
fun applyToolExecution(
tools: List<ToolCallState>,
@@ -434,6 +466,7 @@ fun applyToolExecution(
tool: String,
phase: String,
delta: String? = null,
resultSummary: String? = null,
): List<ToolCallState> {
val target = toolExecutionTargetIndex(tools, id)
if (target < 0) return tools
@@ -445,6 +478,7 @@ fun applyToolExecution(
tool = tool.ifBlank { state.tool },
phase = toolPhaseOf(phase) ?: state.phase,
output = if (delta.isNullOrEmpty()) state.output else state.output + delta,
resultSummary = resultSummary?.takeIf { it.isNotBlank() } ?: state.resultSummary,
)
}
}
@@ -2729,7 +2763,14 @@ class ChatViewModel(
is WsEvent.ToolExecutionUpdate ->
onToolExecution(event.turnId, event.id, event.tool, event.phase, event.delta)
is WsEvent.ToolExecutionEnd -> onToolExecution(event.turnId, event.id, event.tool, event.phase)
is WsEvent.ToolExecutionEnd ->
onToolExecution(
event.turnId,
event.id,
event.tool,
event.phase,
resultSummary = event.resultSummary,
)
is WsEvent.StreamEnd -> {
val bucket = bucketToClose(event.turnId)
@@ -2996,7 +3037,7 @@ class ChatViewModel(
/**
* 收口三帧 `tool_execution.*`:按 `id` 关联到对应的工具卡(关联规则见
* [toolExecutionTargetIndex]),落 phase / 工具名 / 局部输出。落不到就整帧忽略。
* [toolExecutionTargetIndex]),落 phase / 工具名 / 局部输出 / 结果摘要。落不到就整帧忽略。
*
* 落哪张卡由**两件事**决定:先按 `turnId` 选**这一路**(两路并行时各自的工具批次互不可见,
* 不变量 I3′),再在那一路上按 `id` 关联。帧壳没给 `turnId` 时落回本会话当前那一路
@@ -3008,10 +3049,13 @@ class ChatViewModel(
tool: String,
phase: String,
delta: String? = null,
resultSummary: String? = null,
) {
val bucket = openBucket(frameTurnId)
updateStreaming(bucket) { streaming ->
streaming.copy(tools = applyToolExecution(streaming.tools, id, tool, phase, delta))
streaming.copy(
tools = applyToolExecution(streaming.tools, id, tool, phase, delta, resultSummary),
)
}
}
@@ -88,10 +88,13 @@ fun ToolCard(
FoldCardLabel("参数")
FoldCardBody(tool.arguments)
}
if (tool.output.isNotBlank()) {
// 结果区是否出现**完全**由 [toolResultTextOf] 决定:`null` 就整块不画(连标签和
// 上方那道 Spacer 都不画),于是「摘要缺席」时这一屏与后端补这个键之前**逐字节相同**。
val result = toolResultTextOf(tool)
if (result != null) {
Spacer(Modifier.height(6.dp))
FoldCardLabel("结果")
FoldCardBody(tool.output)
FoldCardBody(result)
}
}
}
@@ -16,6 +16,10 @@ import org.junit.Test
* 帧形状逐字取自后端 `internal/services/gateway/ws_bridge.go` 的 `toolExecutionData`
* (`tool` + `phase` 恒在,`id` 空则省略该键,`delta` 只在 `.update` 且非空时出现)与
* `streamEventData` 的类型名;这三帧**不带 `index` / `streamId`**(不属于模型流)。
*
* 新契约切片给 `.end` 补了一个**可选**的 `resultSummary`(脱敏 + 截断 ≤500 字符的成品,
* 只有这一轮真的拿到执行结果才在场)。服务端**尚未部署**它,所以「没有这个键」那一档才是
* 今天真机上的唯一路径——它必须解出 `null` 而不是报错。
*/
class WsEventToolExecutionTest {
@@ -112,6 +116,75 @@ class WsEventToolExecutionTest {
assertNull(ev.id)
}
// —— end 的可选 resultSummary(新契约切片;服务端**未部署**) ——
@Test
fun `tool execution end decodes the result summary when the backend sends one`() {
val ev = parse(
frame(
"tool_execution.end",
"""{"tool":"bash","phase":"done","id":"call_1","resultSummary":"12 行输出,退出码 0"}""",
),
) as WsEvent.ToolExecutionEnd
assertEquals("12 行输出,退出码 0", ev.resultSummary)
assertEquals("bash", ev.tool)
assertEquals("done", ev.phase)
assertEquals("call_1", ev.id)
}
@Test
fun `tool execution end without the key yields null rather than a runtime error`() {
// 服务端**当前未部署**这个键:老帧必须照旧解出来,`resultSummary` 是 `null`——
// 不是 `""`。两者在渲染上等价(都按「不画结果区」处理),但「键在不在」这件事别在下游丢掉。
val ev = parse(frame("tool_execution.end", """{"tool":"bash","phase":"done","id":"call_1"}"""))
as WsEvent.ToolExecutionEnd
assertNull(ev.resultSummary)
assertEquals("done", ev.phase)
}
@Test
fun `an empty result summary decodes to the empty string and is not invented into anything`() {
// 契约承诺不会用空串顶位;真出现了也照原样解,别在解码层替它编一个结论。
val ev = parse(
frame("tool_execution.end", """{"tool":"bash","phase":"done","resultSummary":""}"""),
) as WsEvent.ToolExecutionEnd
assertEquals("", ev.resultSummary)
}
@Test
fun `a five hundred character summary survives decoding untouched`() {
// 后端已按同一份预算截断到 ≤500 字符;本端不许再截第二刀。
val summary = "输".repeat(500)
val ev = parse(
frame("tool_execution.end", """{"tool":"bash","phase":"done","resultSummary":"$summary"}"""),
) as WsEvent.ToolExecutionEnd
assertEquals(summary, ev.resultSummary)
}
@Test
fun `only the end frame carries a result summary`() {
// 契约只给 `.end` 补了这个键:`.start` / `.update` 的领域事件上根本没有该字段。
// (多一个未知键也不该让整帧解析失败——[WsEvent.parse] 本来就是宽容的。)
val start = parse(
frame("tool_execution.start", """{"tool":"bash","phase":"running","resultSummary":"不该在这"}"""),
)
val update = parse(
frame(
"tool_execution.update",
"""{"tool":"bash","phase":"running","delta":"x","resultSummary":"不该在这"}""",
),
)
assertTrue(start is WsEvent.ToolExecutionStart)
assertEquals("bash", (update as WsEvent.ToolExecutionUpdate).tool)
assertEquals("x", update.delta)
}
// —— 回归:三帧都不能再被丢弃 / 不能读错字段 ——
@Test
@@ -13,7 +13,8 @@ import org.junit.Test
* 或整轮只有一张卡;多张卡无法区分时**不落位**,绝不默认 `index == 0`。
*
* [toolExecutionTargetIndex] 是规则本体;[applyToolExecution] 是 VM 实际消费它的纯函数,
* 这里一并验证 phase / 工具名 / delta 的落法。
* 这里一并验证 phase / 工具名 / delta / 结果摘要的落法,以及**结果区判据**
* [toolResultTextOf] 在「缺席 / 空串 / 有值」三档下的输出。
*/
class ToolExecutionCorrelationTest {
@@ -23,6 +24,7 @@ class ToolExecutionCorrelationTest {
tool: String = "",
arguments: String = "",
output: String = "",
resultSummary: String? = null,
done: Boolean = false,
phase: ToolPhase? = null,
) = ToolCallState(
@@ -31,6 +33,7 @@ class ToolExecutionCorrelationTest {
tool = tool,
arguments = arguments,
output = output,
resultSummary = resultSummary,
done = done,
phase = phase,
)
@@ -166,6 +169,84 @@ class ToolExecutionCorrelationTest {
assertEquals(tools, applyToolExecution(tools, null, "bash", "done", "x"))
}
// —— 结果区判据:缺席 / 空串 / 有值 ——
@Test
fun `with neither a summary nor an output nothing is drawn`() {
// 这就是今天真机上的样子(服务端还没补 resultSummary,且 update 帧没有生产者):
// 结果区**一个像素都不画**,不是画一个空区块,也不是画「没有输出」。
assertNull(toolResultTextOf(tool("call_1", tool = "bash", phase = ToolPhase.Done)))
}
@Test
fun `an empty or blank summary counts as absent`() {
// 服务端承诺不会用空串顶位;真顶了也绝不能让卡片渲染出一个空的「结果」区块。
assertNull(toolResultTextOf(tool("call_1", resultSummary = "")))
assertNull(toolResultTextOf(tool("call_1", resultSummary = " ")))
}
@Test
fun `a present summary is exactly what the card shows`() {
assertEquals("12 行输出", toolResultTextOf(tool("call_1", resultSummary = "12 行输出")))
}
@Test
fun `a summary takes precedence over the accumulated partial output`() {
val card = tool("call_1", output = "局部\n", resultSummary = "摘要")
assertEquals("摘要", toolResultTextOf(card))
}
@Test
fun `without a summary the partial output still drives the result area`() {
// 老服务端 / 老路径:结果区唯一的来源仍是 update 帧累积的 output,与改动前同一口径。
assertEquals("局部\n", toolResultTextOf(tool("call_1", output = "局部\n")))
}
@Test
fun `a long summary is shown whole and never truncated a second time`() {
// 服务端已按 ≤500 字符的预算截断并脱敏;本端只展示,不许再截。
val fiveHundred = "输".repeat(500)
assertEquals(fiveHundred, toolResultTextOf(tool("call_1", resultSummary = fiveHundred)))
}
@Test
fun `the end frame lands the summary on the card`() {
val tools = listOf(tool("call_1", tool = "bash"))
val after = applyToolExecution(tools, "call_1", "bash", "done", resultSummary = "12 行")
assertEquals("12 行", after.single().resultSummary)
assertEquals(ToolPhase.Done, after.single().phase)
}
@Test
fun `an absent or blank summary never wipes a previous one`() {
// 三帧里只有 `.end` 会带这个键;一次没带(或空串顶位)不该把上一次拿到的结果抹掉。
val tools = listOf(tool("call_1", tool = "bash", resultSummary = "12 行"))
assertEquals("12 行", applyToolExecution(tools, "call_1", "bash", "done").single().resultSummary)
assertEquals(
"12 行",
applyToolExecution(tools, "call_1", "bash", "done", resultSummary = "").single().resultSummary,
)
assertEquals(
"12 行",
applyToolExecution(tools, "call_1", "bash", "done", resultSummary = " ").single().resultSummary,
)
}
@Test
fun `an unattributable end frame leaves every card without a summary`() {
val tools = listOf(tool("call_1"), tool("call_2"))
val after = applyToolExecution(tools, "call_9", "bash", "done", resultSummary = "12 行")
assertNull(after[0].resultSummary)
assertNull(after[1].resultSummary)
}
// —— phase 字符串映射 ——
@Test