@@ -7,16 +7,18 @@ import org.junit.Assert.assertTrue
77import org.junit.Test
88
99/* *
10- * [T-android-round-collapse ] Fold contracts under the round model.
10+ * [P-process-card ] Fold contracts under the process-card model.
1111 *
1212 * A round = one user message + the assistant/system messages after it up to
13- * the next user message. Fold rules:
14- * 1. rounds with ≤ 2 foldable steps never fold (a bar for two rows is noise);
15- * 2. a RUNNING round is always fully open (effectiveExpanded =
16- * isRunning || expanded) — completion auto-collapses with no trigger;
17- * 3. a collapsed round keeps its LATEST 2 steps visible;
18- * 4. one bar per round, owned by the round's LAST assistant message
19- * (messageId == that message's id, which is also the expansion key).
13+ * the next user message. Inside a round, reply text splits the process into
14+ * runs, and each run of ≥ 2 steps (thinking or tool, including a tool that
15+ * is still running) becomes one card. A single step stays inline.
16+ *
17+ * Cards own their steps: collapsed or expanded, the steps render inside the
18+ * card, never back in the list. The expansion key is the card's own key
19+ * (`<round key>--<first step id>`), not the message id, so opening one card
20+ * never opens another. A running card reports the live step; reply text ends
21+ * the card immediately.
2022 */
2123class ChatFlatItemsProcessFoldTest {
2224
@@ -85,19 +87,29 @@ class ChatFlatItemsProcessFoldTest {
8587 }
8688
8789 @Test
88- fun `round with at most two foldable steps never folds` () {
89- // Rule 1: a fold bar summarising two rows is noise — the round stays
90- // open even with folding enabled.
91- val items = buildFlatChatItems(
90+ fun `a two step run becomes a card and a single step stays inline` () {
91+ // [P-process-card] The card threshold is the RUN, not the round:
92+ // two process steps are enough to card, one step is noise and stays
93+ // inline. The old round rule (≤ 2 steps never fold) predates cards.
94+ val carded = buildFlatChatItems(
9295 listOf (assistant(blocks = listOf (thinking(), tool(), text()))),
9396 showCompletedToolCards = false ,
9497 foldAiProcess = true ,
9598 )
96- val k = kinds(items)
97- assertFalse(k.contains(" summary" ))
98- assertTrue(k.contains(" thinking" ))
99- assertTrue(k.contains(" tool:bash" ))
100- assertTrue(k.contains(" md" ))
99+ val k = kinds(carded)
100+ assertEquals(listOf (" header" , " summary" , " md" ), k.filter { it in setOf (" header" , " summary" , " md" , " thinking" , " tool:bash" ) })
101+ assertFalse(k.contains(" thinking" ))
102+ assertFalse(k.contains(" tool:bash" ))
103+
104+ val inline = buildFlatChatItems(
105+ listOf (assistant(blocks = listOf (thinking(), text()))),
106+ showCompletedToolCards = false ,
107+ foldAiProcess = true ,
108+ )
109+ val ik = kinds(inline)
110+ assertFalse(ik.contains(" summary" ))
111+ assertTrue(ik.contains(" thinking" ))
112+ assertTrue(ik.contains(" md" ))
101113 }
102114
103115 @Test
@@ -186,63 +198,74 @@ class ChatFlatItemsProcessFoldTest {
186198 foldAiProcess = true ,
187199 )
188200 val k = kinds(items)
189- // A running run of ≥ 2 steps is already a card; its steps render
190- // inside the card, not in the list. The reply text stays outside.
201+ // Reply text ends the card the moment it arrives, even while the
202+ // message is still streaming — "输出中" was dropped because it would
203+ // flash for one frame. The steps render inside the card.
191204 assertTrue(k.contains(" summary" ))
192205 assertFalse(k.contains(" thinking" ))
193206 assertFalse(k.contains(" tool:bash" ))
194207 assertTrue(k.contains(" md" ))
195208 val summary = items.filterIsInstance<FlatChatItem .AssistantProcessSummary >().single()
196- assertTrue (summary.running)
209+ assertFalse (summary.running)
197210 }
198211
199212 @Test
200213 fun `running round stays open and folds when it finishes` () {
201- // The auto-collapse needs no trigger of its own: the round simply
202- // stops forcing itself open the moment it stops running.
203- val blocks = listOf (thinking(), tool(id = " t1" ), tool(id = " t2" ), text())
214+ // The card is live only while one of its own steps is still running.
215+ // The moment the reply text arrives the card settles, whether or not
216+ // the message itself is still streaming.
217+ val blocks = listOf (thinking(), tool(id = " t1" ), tool(id = " t2" , status = ToolBlockStatus .RUNNING ))
204218 val running = buildFlatChatItems(
205219 listOf (assistant(streaming = true , blocks = blocks)),
206220 showCompletedToolCards = false ,
207221 foldAiProcess = true ,
208222 )
209223 val rk = kinds(running)
210224 assertTrue(rk.contains(" summary" ))
225+ assertFalse(rk.contains(" thinking" ))
211226 assertTrue(running.filterIsInstance<FlatChatItem .AssistantProcessSummary >().single().running)
212227
213228 val finished = buildFlatChatItems(
214- listOf (assistant(streaming = false , blocks = blocks)),
229+ listOf (assistant(streaming = true , blocks = blocks + text() )),
215230 showCompletedToolCards = false ,
216231 foldAiProcess = true ,
217232 )
218233 val fk = kinds(finished)
219234 assertTrue(fk.contains(" summary" ))
220235 assertFalse(fk.contains(" thinking" ))
236+ assertFalse(finished.filterIsInstance<FlatChatItem .AssistantProcessSummary >().single().running)
221237 }
222238
223239 @Test
224240 fun `fold on awaiting keeps the running round open` () {
225- // Awaiting (between tool rounds) is still a running round.
241+ // Awaiting is a running round, but a card closes when its reply text
242+ // arrives. The steps are inside the card; the reply stays outside.
226243 val items = buildFlatChatItems(
227244 listOf (assistant(awaiting = true , blocks = listOf (thinking(), tool(), text()))),
228245 showCompletedToolCards = false ,
229246 foldAiProcess = true ,
230247 )
231248 val k = kinds(items)
232- // Awaiting is still a running round, so the card is live.
233249 assertTrue(k.contains(" summary" ))
234- assertTrue (items.filterIsInstance<FlatChatItem .AssistantProcessSummary >().single().running)
250+ assertFalse (items.filterIsInstance<FlatChatItem .AssistantProcessSummary >().single().running)
235251 assertFalse(k.contains(" thinking" ))
236252 assertTrue(k.contains(" md" ))
237253 }
238254
239255 @Test
240256 fun `tap expand restores thinking and completed tools` () {
257+ // The toggle stores the card's own key (round key + first step),
258+ // never the bare message id — one message can hold several cards.
259+ val collapsed = buildFlatChatItems(
260+ listOf (assistant(blocks = listOf (thinking(), tool(id = " t1" ), tool(id = " t2" ), text()))),
261+ showCompletedToolCards = false ,
262+ foldAiProcess = true ,
263+ ).filterIsInstance<FlatChatItem .AssistantProcessSummary >().single()
241264 val items = buildFlatChatItems(
242265 listOf (assistant(blocks = listOf (thinking(), tool(id = " t1" ), tool(id = " t2" ), text()))),
243266 showCompletedToolCards = false ,
244267 foldAiProcess = true ,
245- expandedProcessIds = setOf (" m1 " ),
268+ expandedProcessIds = setOf (collapsed.messageId ),
246269 )
247270 val k = kinds(items)
248271 // Expanding renders the steps INSIDE the card, never back in the list.
@@ -440,11 +463,16 @@ class ChatFlatItemsProcessFoldTest {
440463 tool(id = " tool2" , name = " bash" , status = ToolBlockStatus .SUCCESS ),
441464 text(),
442465 )
466+ val cardKey = buildFlatChatItems(
467+ listOf (assistant(blocks = blocks)),
468+ showCompletedToolCards = false ,
469+ foldAiProcess = true ,
470+ ).filterIsInstance<FlatChatItem .AssistantProcessSummary >().single().messageId
443471 val expanded = buildFlatChatItems(
444472 listOf (assistant(blocks = blocks)),
445473 showCompletedToolCards = false ,
446474 foldAiProcess = true ,
447- expandedProcessIds = setOf (" m1 " ),
475+ expandedProcessIds = setOf (cardKey ),
448476 )
449477 val k = kinds(expanded)
450478 val summary = expanded.filterIsInstance<FlatChatItem .AssistantProcessSummary >().single()
0 commit comments