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:
1 parent
c40a752bcf
commit
316cd27049
5 files changed
+223
-8
No files matched your search
@@ -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
|
||||
|
||||
Reference in new issue
Block a user