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
@@ -190,10 +190,19 @@ sealed class WsEvent {
|
|||||||
override val type: String get() = "tool_execution.update"
|
override val type: String get() = "tool_execution.update"
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 工具执行**完成或失败**(契约 v0.50):`phase:"error"` 即失败。 */
|
/**
|
||||||
|
* 工具执行**完成或失败**(契约 v0.50):`phase:"error"` 即失败。
|
||||||
|
*
|
||||||
|
* [resultSummary] 是**执行结果摘要**(新契约切片,与回看面 `toolCalls[].resultSummary` 同一份预算:
|
||||||
|
* 已经脱敏、已经截断 ≤500 字符的成品,本端只展示)。它的语义是**缺席即「不知道」**:
|
||||||
|
* 只有这一轮的执行结果**真的拿到了**才在场,失败 / 取消 / 超时 / 未落图时后端**整个键省略**
|
||||||
|
* (且承诺不用空串顶位)。所以 `null` 与 `""` 在本端都读作**不知道**——绝不解成「没有输出」,
|
||||||
|
* 因为那是替服务端下一个它没下的结论。当前服务端**尚未部署**该键 ⇒ 真机上它恒缺席。
|
||||||
|
*/
|
||||||
data class ToolExecutionEnd(
|
data class ToolExecutionEnd(
|
||||||
val tool: String,
|
val tool: String,
|
||||||
val phase: String,
|
val phase: String,
|
||||||
|
val resultSummary: String? = null,
|
||||||
val id: String? = null,
|
val id: String? = null,
|
||||||
override val turnId: String? = null,
|
override val turnId: String? = null,
|
||||||
override val seq: Long? = null,
|
override val seq: Long? = null,
|
||||||
@@ -760,7 +769,7 @@ sealed class WsEvent {
|
|||||||
"tool_execution.update" -> decode<ToolExecutionData>(data)
|
"tool_execution.update" -> decode<ToolExecutionData>(data)
|
||||||
.let { ToolExecutionUpdate(it.tool, it.phase, it.delta, it.id, turnId, seq, sessionId) }
|
.let { ToolExecutionUpdate(it.tool, it.phase, it.delta, it.id, turnId, seq, sessionId) }
|
||||||
"tool_execution.end" -> decode<ToolExecutionData>(data)
|
"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_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) }
|
||||||
@@ -983,6 +992,10 @@ private data class ToolCallData(val id: String = "", val index: Int = 0, val del
|
|||||||
* `id` 在 provider 未给时**整个键省略**,`delta` 只在 `update` 且非空时出现。
|
* `id` 在 provider 未给时**整个键省略**,`delta` 只在 `update` 且非空时出现。
|
||||||
* 刻意**不复用** [ToolCallData]:那是 `tool_call.*` 的形状,真的有 `index`;这三帧没有,
|
* 刻意**不复用** [ToolCallData]:那是 `tool_call.*` 的形状,真的有 `index`;这三帧没有,
|
||||||
* 也没有 `streamId`(它们不属于某一次模型流)。
|
* 也没有 `streamId`(它们不属于某一次模型流)。
|
||||||
|
*
|
||||||
|
* [resultSummary] 只由 `.end` 携带(后端在新契约切片里只补了 `.end`)。它放在这份共用载荷上
|
||||||
|
* 是因为三帧形状同源、共用一个 DTO;`.start` / `.update` 即便被塞了这个键也**不读**
|
||||||
|
* (它们的领域事件上根本没有这个字段),所以契约没变宽——变宽的只是「多一个键不再算坏帧」。
|
||||||
*/
|
*/
|
||||||
@Serializable
|
@Serializable
|
||||||
private data class ToolExecutionData(
|
private data class ToolExecutionData(
|
||||||
@@ -990,6 +1003,7 @@ private data class ToolExecutionData(
|
|||||||
val phase: String = "",
|
val phase: String = "",
|
||||||
val id: String? = null,
|
val id: String? = null,
|
||||||
val delta: String = "",
|
val delta: String = "",
|
||||||
|
val resultSummary: String? = null,
|
||||||
)
|
)
|
||||||
|
|
||||||
@Serializable
|
@Serializable
|
||||||
|
|||||||
@@ -395,6 +395,14 @@ data class ToolCallState(
|
|||||||
val tool: String = "",
|
val tool: String = "",
|
||||||
val arguments: String = "",
|
val arguments: String = "",
|
||||||
val output: String = "",
|
val output: String = "",
|
||||||
|
/**
|
||||||
|
* 执行结果摘要(`tool_execution.end` 的可选 `resultSummary`;服务端当前**未部署**该键)。
|
||||||
|
*
|
||||||
|
* `null` 与 `""` 都是**「不知道」**:`null` 是键缺席(老服务端 / 这一轮没拿到结果),
|
||||||
|
* `""` 是万一被空串顶位。两者**都不等于**「没有输出」——那是我们替服务端下的结论,
|
||||||
|
* 契约明确不要这么读。渲染判据见 [toolResultTextOf]。
|
||||||
|
*/
|
||||||
|
val resultSummary: String? = null,
|
||||||
/** `tool_call.end` 已到(参数流完整)。 */
|
/** `tool_call.end` 已到(参数流完整)。 */
|
||||||
val done: Boolean = false,
|
val done: Boolean = false,
|
||||||
/** 执行阶段;`null` = 尚未进入执行。 */
|
/** 执行阶段;`null` = 尚未进入执行。 */
|
||||||
@@ -422,11 +430,35 @@ fun toolExecutionTargetIndex(tools: List<ToolCallState>, id: String?): Int {
|
|||||||
return -1
|
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_execution.*` 应用到工具卡列表(纯函数,VM 与测试共用)。
|
||||||
*
|
*
|
||||||
* 没找到落点时**原样返回**:不新建卡、不改别的卡。`tool` 为空白时保留卡上已有的名字,
|
* 没找到落点时**原样返回**:不新建卡、不改别的卡。`tool` 为空白时保留卡上已有的名字,
|
||||||
* `phase` 无法识别时保留卡上已有的阶段,`delta` 为空时不改输出。
|
* `phase` 无法识别时保留卡上已有的阶段,`delta` 为空时不改输出。
|
||||||
|
*
|
||||||
|
* [resultSummary] 与 `delta` 不同:它是**整段成品**,非空白时直接顶替(不是追加)。缺席(`null`)
|
||||||
|
* 或空白时**保留卡上已有的值**——三帧里只有 `.end` 会带它,一次落不到不该把上一次的抹掉。
|
||||||
*/
|
*/
|
||||||
fun applyToolExecution(
|
fun applyToolExecution(
|
||||||
tools: List<ToolCallState>,
|
tools: List<ToolCallState>,
|
||||||
@@ -434,6 +466,7 @@ fun applyToolExecution(
|
|||||||
tool: String,
|
tool: String,
|
||||||
phase: String,
|
phase: String,
|
||||||
delta: String? = null,
|
delta: String? = null,
|
||||||
|
resultSummary: String? = null,
|
||||||
): List<ToolCallState> {
|
): List<ToolCallState> {
|
||||||
val target = toolExecutionTargetIndex(tools, id)
|
val target = toolExecutionTargetIndex(tools, id)
|
||||||
if (target < 0) return tools
|
if (target < 0) return tools
|
||||||
@@ -445,6 +478,7 @@ fun applyToolExecution(
|
|||||||
tool = tool.ifBlank { state.tool },
|
tool = tool.ifBlank { state.tool },
|
||||||
phase = toolPhaseOf(phase) ?: state.phase,
|
phase = toolPhaseOf(phase) ?: state.phase,
|
||||||
output = if (delta.isNullOrEmpty()) state.output else state.output + delta,
|
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 ->
|
is WsEvent.ToolExecutionUpdate ->
|
||||||
onToolExecution(event.turnId, event.id, event.tool, event.phase, event.delta)
|
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 -> {
|
is WsEvent.StreamEnd -> {
|
||||||
val bucket = bucketToClose(event.turnId)
|
val bucket = bucketToClose(event.turnId)
|
||||||
@@ -2996,7 +3037,7 @@ class ChatViewModel(
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* 收口三帧 `tool_execution.*`:按 `id` 关联到对应的工具卡(关联规则见
|
* 收口三帧 `tool_execution.*`:按 `id` 关联到对应的工具卡(关联规则见
|
||||||
* [toolExecutionTargetIndex]),落 phase / 工具名 / 局部输出。落不到就整帧忽略。
|
* [toolExecutionTargetIndex]),落 phase / 工具名 / 局部输出 / 结果摘要。落不到就整帧忽略。
|
||||||
*
|
*
|
||||||
* 落哪张卡由**两件事**决定:先按 `turnId` 选**这一路**(两路并行时各自的工具批次互不可见,
|
* 落哪张卡由**两件事**决定:先按 `turnId` 选**这一路**(两路并行时各自的工具批次互不可见,
|
||||||
* 不变量 I3′),再在那一路上按 `id` 关联。帧壳没给 `turnId` 时落回本会话当前那一路
|
* 不变量 I3′),再在那一路上按 `id` 关联。帧壳没给 `turnId` 时落回本会话当前那一路
|
||||||
@@ -3008,10 +3049,13 @@ class ChatViewModel(
|
|||||||
tool: String,
|
tool: String,
|
||||||
phase: String,
|
phase: String,
|
||||||
delta: String? = null,
|
delta: String? = null,
|
||||||
|
resultSummary: String? = null,
|
||||||
) {
|
) {
|
||||||
val bucket = openBucket(frameTurnId)
|
val bucket = openBucket(frameTurnId)
|
||||||
updateStreaming(bucket) { streaming ->
|
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("参数")
|
FoldCardLabel("参数")
|
||||||
FoldCardBody(tool.arguments)
|
FoldCardBody(tool.arguments)
|
||||||
}
|
}
|
||||||
if (tool.output.isNotBlank()) {
|
// 结果区是否出现**完全**由 [toolResultTextOf] 决定:`null` 就整块不画(连标签和
|
||||||
|
// 上方那道 Spacer 都不画),于是「摘要缺席」时这一屏与后端补这个键之前**逐字节相同**。
|
||||||
|
val result = toolResultTextOf(tool)
|
||||||
|
if (result != null) {
|
||||||
Spacer(Modifier.height(6.dp))
|
Spacer(Modifier.height(6.dp))
|
||||||
FoldCardLabel("结果")
|
FoldCardLabel("结果")
|
||||||
FoldCardBody(tool.output)
|
FoldCardBody(result)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,6 +16,10 @@ import org.junit.Test
|
|||||||
* 帧形状逐字取自后端 `internal/services/gateway/ws_bridge.go` 的 `toolExecutionData`
|
* 帧形状逐字取自后端 `internal/services/gateway/ws_bridge.go` 的 `toolExecutionData`
|
||||||
* (`tool` + `phase` 恒在,`id` 空则省略该键,`delta` 只在 `.update` 且非空时出现)与
|
* (`tool` + `phase` 恒在,`id` 空则省略该键,`delta` 只在 `.update` 且非空时出现)与
|
||||||
* `streamEventData` 的类型名;这三帧**不带 `index` / `streamId`**(不属于模型流)。
|
* `streamEventData` 的类型名;这三帧**不带 `index` / `streamId`**(不属于模型流)。
|
||||||
|
*
|
||||||
|
* 新契约切片给 `.end` 补了一个**可选**的 `resultSummary`(脱敏 + 截断 ≤500 字符的成品,
|
||||||
|
* 只有这一轮真的拿到执行结果才在场)。服务端**尚未部署**它,所以「没有这个键」那一档才是
|
||||||
|
* 今天真机上的唯一路径——它必须解出 `null` 而不是报错。
|
||||||
*/
|
*/
|
||||||
class WsEventToolExecutionTest {
|
class WsEventToolExecutionTest {
|
||||||
|
|
||||||
@@ -112,6 +116,75 @@ class WsEventToolExecutionTest {
|
|||||||
assertNull(ev.id)
|
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
|
@Test
|
||||||
|
|||||||
@@ -13,7 +13,8 @@ import org.junit.Test
|
|||||||
* 或整轮只有一张卡;多张卡无法区分时**不落位**,绝不默认 `index == 0`。
|
* 或整轮只有一张卡;多张卡无法区分时**不落位**,绝不默认 `index == 0`。
|
||||||
*
|
*
|
||||||
* [toolExecutionTargetIndex] 是规则本体;[applyToolExecution] 是 VM 实际消费它的纯函数,
|
* [toolExecutionTargetIndex] 是规则本体;[applyToolExecution] 是 VM 实际消费它的纯函数,
|
||||||
* 这里一并验证 phase / 工具名 / delta 的落法。
|
* 这里一并验证 phase / 工具名 / delta / 结果摘要的落法,以及**结果区判据**
|
||||||
|
* [toolResultTextOf] 在「缺席 / 空串 / 有值」三档下的输出。
|
||||||
*/
|
*/
|
||||||
class ToolExecutionCorrelationTest {
|
class ToolExecutionCorrelationTest {
|
||||||
|
|
||||||
@@ -23,6 +24,7 @@ class ToolExecutionCorrelationTest {
|
|||||||
tool: String = "",
|
tool: String = "",
|
||||||
arguments: String = "",
|
arguments: String = "",
|
||||||
output: String = "",
|
output: String = "",
|
||||||
|
resultSummary: String? = null,
|
||||||
done: Boolean = false,
|
done: Boolean = false,
|
||||||
phase: ToolPhase? = null,
|
phase: ToolPhase? = null,
|
||||||
) = ToolCallState(
|
) = ToolCallState(
|
||||||
@@ -31,6 +33,7 @@ class ToolExecutionCorrelationTest {
|
|||||||
tool = tool,
|
tool = tool,
|
||||||
arguments = arguments,
|
arguments = arguments,
|
||||||
output = output,
|
output = output,
|
||||||
|
resultSummary = resultSummary,
|
||||||
done = done,
|
done = done,
|
||||||
phase = phase,
|
phase = phase,
|
||||||
)
|
)
|
||||||
@@ -166,6 +169,84 @@ class ToolExecutionCorrelationTest {
|
|||||||
assertEquals(tools, applyToolExecution(tools, null, "bash", "done", "x"))
|
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 字符串映射 ——
|
// —— phase 字符串映射 ——
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
|
|||||||
Reference in new issue
Block a user