From 316cd2704981ec2432f73ac015bb180f3e31e708 Mon Sep 17 00:00:00 2001 From: AskaEth Date: Wed, 7 Oct 2026 10:14:10 +0800 Subject: [PATCH] =?UTF-8?q?feat(chat):=20=E5=B7=A5=E5=85=B7=E5=8D=A1?= =?UTF-8?q?=E6=8E=A5=E4=B8=8A=20resultSummary=20=E7=BB=93=E6=9E=9C?= =?UTF-8?q?=E5=8C=BA=EF=BC=88=E7=BC=BA=E5=B8=AD=E6=97=B6=E4=B8=8E=E4=BB=8A?= =?UTF-8?q?=E5=A4=A9=E9=80=90=E5=AD=97=E8=8A=82=E4=B8=80=E8=87=B4=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 后端新契约切片给 `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)。 --- .../kotlin/top/yeij/cyrene/data/ws/WsEvent.kt | 18 +++- .../top/yeij/cyrene/ui/chat/ChatViewModel.kt | 50 ++++++++++- .../yeij/cyrene/ui/chat/StreamingBlocks.kt | 7 +- .../data/ws/WsEventToolExecutionTest.kt | 73 ++++++++++++++++ .../ui/chat/ToolExecutionCorrelationTest.kt | 83 ++++++++++++++++++- 5 files changed, 223 insertions(+), 8 deletions(-) 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 d005fab..92248e4 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 @@ -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(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) } + .let { ToolExecutionEnd(it.tool, it.phase, it.resultSummary, 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) } @@ -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 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 51db6cf..17cfeaa 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 @@ -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, 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, @@ -434,6 +466,7 @@ fun applyToolExecution( tool: String, phase: String, delta: String? = null, + resultSummary: String? = null, ): List { 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), + ) } } 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 98173e2..7ca6c9d 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 @@ -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) } } } 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 index 60fe7f9..f0e99f6 100644 --- a/app/src/test/kotlin/top/yeij/cyrene/data/ws/WsEventToolExecutionTest.kt +++ b/app/src/test/kotlin/top/yeij/cyrene/data/ws/WsEventToolExecutionTest.kt @@ -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 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 index 3b1bfbc..4ad9f19 100644 --- a/app/src/test/kotlin/top/yeij/cyrene/ui/chat/ToolExecutionCorrelationTest.kt +++ b/app/src/test/kotlin/top/yeij/cyrene/ui/chat/ToolExecutionCorrelationTest.kt @@ -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