feat: reasoning, sources, and tool-call display

StreamConsumer.Incremental now returns StreamState (content, reasoning,
tools, query, sources) instead of just visible text. All sentinel sections
are parsed and structured:
- \x01 REASONING: captured as separate string
- \x02 SOURCES: parsed as List<SearchSource> (title, url, snippet)
- \x03 TOOL: parsed as List<ToolEvent> (type, name, args, result, elapsed)
- \x04 QUERY: captured as search query string

UI components:
- ReasoningBlock: collapsible "Gedankengang" with expand/collapse animation
- ToolEventsBlock: shows tool-call steps with status icons (start/end/error)
- SourcesBlock: web search sources as compact cards with domain extraction

Message model extended with reasoning, tools, query, sources fields.
KaizenApi.chat() returns Flow<StreamState> instead of Flow<String>.
ChatScreen wires all fields to Message during streaming.

Fixed: empty append() on Incremental no longer prematurely ends tool phase.
StreamConsumerTest rewritten to verify all parsed sections.
This commit is contained in:
Bruno Deanoz 2026-06-21 15:35:46 +02:00
parent 736bb4c206
commit 76ab8bfde9
9 changed files with 423 additions and 162 deletions

View file

@ -223,12 +223,24 @@ fun MessageRow(message: Message) {
Spacer(Modifier.width(10.dp)) Spacer(Modifier.width(10.dp))
Box(Modifier.weight(1f).padding(top = 3.dp)) { Box(Modifier.weight(1f).padding(top = 3.dp)) {
Column { Column {
if (message.tools.isNotEmpty()) {
ToolEventsBlock(message.tools, streaming = message.streaming)
Spacer(Modifier.height(8.dp))
}
if (hasAttachments) { if (hasAttachments) {
AttachmentGrid(message.attachments) AttachmentGrid(message.attachments)
if (message.content.isNotBlank() || message.thinking) Spacer(Modifier.height(8.dp)) if (message.content.isNotBlank() || message.thinking) Spacer(Modifier.height(8.dp))
} }
if (message.thinking) TypingDots() if (message.thinking) TypingDots()
else MarkdownText(text = message.content, streaming = message.streaming) else MarkdownText(text = message.content, streaming = message.streaming)
if (message.reasoning.isNotBlank()) {
Spacer(Modifier.height(8.dp))
ReasoningBlock(message.reasoning, streaming = message.streaming)
}
if (message.sources.isNotEmpty()) {
Spacer(Modifier.height(8.dp))
SourcesBlock(message.sources, query = message.query)
}
} }
} }
} }

View file

@ -13,6 +13,8 @@ import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import dev.kaizen.app.R import dev.kaizen.app.R
import dev.kaizen.app.net.Attachment import dev.kaizen.app.net.Attachment
import dev.kaizen.app.net.SearchSource
import dev.kaizen.app.net.ToolEvent
enum class Role { User, Assistant } enum class Role { User, Assistant }
@ -24,6 +26,10 @@ data class Message(
val thinking: Boolean = false, val thinking: Boolean = false,
val wireId: String = java.util.UUID.randomUUID().toString(), val wireId: String = java.util.UUID.randomUUID().toString(),
val attachments: List<Attachment> = emptyList(), val attachments: List<Attachment> = emptyList(),
val reasoning: String = "",
val tools: List<ToolEvent> = emptyList(),
val query: String = "",
val sources: List<SearchSource> = emptyList(),
) )
data class Suggestion(val labelRes: Int, val promptRes: Int, val icon: ImageVector) data class Suggestion(val labelRes: Int, val promptRes: Int, val icon: ImageVector)

View file

@ -267,16 +267,20 @@ fun ChatScreen(
KaizenApi.chat( KaizenApi.chat(
cfg.baseUrl, cfg.token, cfg.model, history, cfg.speed, cfg.baseUrl, cfg.token, cfg.model, history, cfg.speed,
attachments = uploadedAtts.takeIf { it.isNotEmpty() }, attachments = uploadedAtts.takeIf { it.isNotEmpty() },
).collect { visible -> ).collect { state ->
val idx = messages.indexOfLast { it.id == assistantId } val idx = messages.indexOfLast { it.id == assistantId }
if (idx < 0) return@collect if (idx < 0) return@collect
if (!sawContent && visible.isNotEmpty()) { if (!sawContent && state.content.isNotEmpty()) {
sawContent = true sawContent = true
haptics.responseStart() haptics.responseStart()
} }
messages[idx] = messages[idx].copy( messages[idx] = messages[idx].copy(
thinking = if (sawContent) false else messages[idx].thinking, thinking = if (sawContent) false else messages[idx].thinking,
content = visible, content = state.content,
reasoning = state.reasoning,
tools = state.tools,
query = state.query,
sources = state.sources,
) )
} }
val idx = messages.indexOfLast { it.id == assistantId } val idx = messages.indexOfLast { it.id == assistantId }

View file

@ -0,0 +1,215 @@
package dev.kaizen.app.chat
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.expandVertically
import androidx.compose.animation.shrinkVertically
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ExperimentalLayoutApi
import androidx.compose.foundation.layout.FlowRow
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.rounded.Check
import androidx.compose.material.icons.rounded.Error
import androidx.compose.material.icons.rounded.ExpandLess
import androidx.compose.material.icons.rounded.ExpandMore
import androidx.compose.material.icons.rounded.Language
import androidx.compose.material.icons.rounded.Psychology
import androidx.compose.material.icons.rounded.Build
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import dev.kaizen.app.net.SearchSource
import dev.kaizen.app.net.ToolEvent
import dev.kaizen.app.ui.shape.KaizenShapes
import dev.kaizen.app.ui.theme.Amber
@Composable
fun ReasoningBlock(reasoning: String, streaming: Boolean = false) {
val cs = MaterialTheme.colorScheme
val isDark = isSystemInDarkTheme()
var expanded by remember { mutableStateOf(streaming) }
val bg = if (isDark) Color.White.copy(alpha = 0.05f) else Color.Black.copy(alpha = 0.03f)
val borderColor = if (isDark) Color.White.copy(alpha = 0.08f) else Color.Black.copy(alpha = 0.06f)
Column(
Modifier
.fillMaxWidth()
.clip(KaizenShapes.sm)
.background(bg)
.border(1.dp, borderColor, KaizenShapes.sm)
) {
Row(
Modifier
.fillMaxWidth()
.clickable { expanded = !expanded }
.padding(horizontal = 12.dp, vertical = 8.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Icon(Icons.Rounded.Psychology, null, tint = Amber, modifier = Modifier.size(16.dp))
Spacer(Modifier.width(8.dp))
Text(
"Gedankengang",
color = cs.onSurfaceVariant,
fontSize = 13.sp,
fontWeight = FontWeight.Medium,
modifier = Modifier.weight(1f),
)
Icon(
if (expanded) Icons.Rounded.ExpandLess else Icons.Rounded.ExpandMore,
null, tint = cs.onSurfaceVariant, modifier = Modifier.size(16.dp),
)
}
AnimatedVisibility(expanded, enter = expandVertically(), exit = shrinkVertically()) {
Text(
reasoning,
color = cs.onSurfaceVariant.copy(alpha = 0.8f),
fontSize = 13.sp,
lineHeight = 18.sp,
modifier = Modifier.padding(start = 12.dp, end = 12.dp, bottom = 10.dp),
)
}
}
}
@Composable
fun ToolEventsBlock(tools: List<ToolEvent>, streaming: Boolean = false) {
val cs = MaterialTheme.colorScheme
val isDark = isSystemInDarkTheme()
val bg = if (isDark) Color.White.copy(alpha = 0.04f) else Color.Black.copy(alpha = 0.02f)
val borderColor = if (isDark) Color.White.copy(alpha = 0.06f) else Color.Black.copy(alpha = 0.04f)
Column(
Modifier
.fillMaxWidth()
.clip(KaizenShapes.sm)
.background(bg)
.border(1.dp, borderColor, KaizenShapes.sm)
.padding(horizontal = 12.dp, vertical = 8.dp),
verticalArrangement = Arrangement.spacedBy(4.dp),
) {
tools.forEach { tool ->
Row(verticalAlignment = Alignment.CenterVertically) {
val icon = when (tool.type) {
"end" -> Icons.Rounded.Check
"error" -> Icons.Rounded.Error
else -> Icons.Rounded.Build
}
val tint = when (tool.type) {
"end" -> Color(0xFF10B981)
"error" -> Color(0xFFEF4444)
else -> Amber
}
Icon(icon, null, tint = tint, modifier = Modifier.size(14.dp))
Spacer(Modifier.width(8.dp))
Text(
tool.name.ifEmpty { "Tool" },
color = cs.onBackground,
fontSize = 13.sp,
fontWeight = FontWeight.Medium,
)
if (tool.elapsed != null) {
Spacer(Modifier.width(6.dp))
Text(
"${tool.elapsed}ms",
color = cs.onSurfaceVariant.copy(alpha = 0.5f),
fontSize = 11.sp,
)
}
if (tool.error != null) {
Spacer(Modifier.width(6.dp))
Text(tool.error, color = Color(0xFFEF4444), fontSize = 11.sp, maxLines = 1, overflow = TextOverflow.Ellipsis)
}
}
}
}
}
@OptIn(ExperimentalLayoutApi::class)
@Composable
fun SourcesBlock(sources: List<SearchSource>, query: String = "") {
val cs = MaterialTheme.colorScheme
val isDark = isSystemInDarkTheme()
Column {
if (query.isNotBlank()) {
Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.padding(bottom = 6.dp)) {
Icon(Icons.Rounded.Language, null, tint = cs.onSurfaceVariant, modifier = Modifier.size(14.dp))
Spacer(Modifier.width(6.dp))
Text(query, color = cs.onSurfaceVariant, fontSize = 12.sp, fontWeight = FontWeight.Medium)
}
}
FlowRow(
horizontalArrangement = Arrangement.spacedBy(6.dp),
verticalArrangement = Arrangement.spacedBy(6.dp),
) {
sources.forEach { source ->
SourceChip(source, isDark)
}
}
}
}
@Composable
private fun SourceChip(source: SearchSource, isDark: Boolean) {
val cs = MaterialTheme.colorScheme
val bg = if (isDark) Color.White.copy(alpha = 0.06f) else Color.Black.copy(alpha = 0.04f)
val borderColor = if (isDark) Color.White.copy(alpha = 0.08f) else Color.Black.copy(alpha = 0.06f)
val domain = remember(source.url) {
try {
java.net.URI(source.url).host?.removePrefix("www.") ?: source.url
} catch (_: Exception) { source.url }
}
Column(
Modifier
.width(140.dp)
.clip(KaizenShapes.xs)
.background(bg)
.border(1.dp, borderColor, KaizenShapes.xs)
.padding(horizontal = 10.dp, vertical = 8.dp),
) {
Text(
source.title.ifEmpty { domain },
color = cs.onBackground,
fontSize = 12.sp,
fontWeight = FontWeight.Medium,
maxLines = 2,
overflow = TextOverflow.Ellipsis,
)
Spacer(Modifier.height(2.dp))
Text(
domain,
color = cs.onSurfaceVariant.copy(alpha = 0.6f),
fontSize = 10.sp,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
}

View file

@ -350,7 +350,7 @@ object KaizenApi {
messages: List<WireMessage>, messages: List<WireMessage>,
speed: ChatSpeed = ChatSpeed.Normal, speed: ChatSpeed = ChatSpeed.Normal,
attachments: List<Attachment>? = null, attachments: List<Attachment>? = null,
): Flow<String> = flow { ): Flow<StreamState> = flow {
val payload = json.encodeToString( val payload = json.encodeToString(
ChatRequest( ChatRequest(
model = model, model = model,

View file

@ -1,54 +1,54 @@
package dev.kaizen.app.net package dev.kaizen.app.net
/** import kotlinx.serialization.Serializable
* Sentinel code points delimiting control sections in the `/api/v1/chat` stream. import kotlinx.serialization.json.Json
* Shared between the batch parser ([StreamConsumer.visibleText]) and the
* incremental parser ([StreamConsumer.Incremental]). private val USAGE = 0.toChar()
*/ private val REASONING = 1.toChar()
private val USAGE = 0.toChar() // private val SOURCES = 2.toChar()
private val REASONING = 1.toChar() // private val TOOL = 3.toChar()
private val SOURCES = 2.toChar() // private val QUERY = 4.toChar()
private val TOOL = 3.toChar() //
private val QUERY = 4.toChar() // private val lenientJson = Json { ignoreUnknownKeys = true }
@Serializable
data class ToolEvent(
val type: String = "",
val name: String = "",
val args: kotlinx.serialization.json.JsonObject? = null,
val result: String? = null,
val error: String? = null,
val elapsed: Long? = null,
)
@Serializable
data class SearchSource(
val title: String = "",
val url: String = "",
val snippet: String = "",
)
data class StreamState(
val content: String = "",
val reasoning: String = "",
val tools: List<ToolEvent> = emptyList(),
val query: String = "",
val sources: List<SearchSource> = emptyList(),
)
/**
* Pure parsing layer for the `/api/v1/chat` response stream a Kotlin port of the
* backend's `lib/chat/stream-consumer.ts`. The provider stream is a flat string
* with control sentinels (code points, see lib/chat/constants.ts):
*
* 0x00 USAGE trailing usage/cost json
* 0x01 REASONING wraps reasoning, toggling content <-> reasoning
* 0x02 SOURCES web-search sources json
* 0x03 TOOL leading newline-framed tool events ("{json}\n")
* 0x04 QUERY web-search query json
*
* Layout: [tool-events] <content> [0x01 reasoning 0x01 content] [0x04 q] [0x02 src] [0x00 usage]
*
* Two parsers are available:
* - [visibleText]: batch correct but O(N) per call, used for one-shot parsing and tests.
* - [Incremental]: streaming O(chunk-size) per call, O(N) total. Used during live streaming.
*/
object StreamConsumer { object StreamConsumer {
/**
* Batch extraction parses the full accumulated buffer from scratch.
* Correct but O(N) per call; kept for unit tests and one-shot use.
*/
fun visibleText(raw: String): String { fun visibleText(raw: String): String {
val rest = stripLeadingToolEvents(raw) ?: return "" val rest = stripLeadingToolEvents(raw) ?: return ""
val segments = rest.split(REASONING) val segments = rest.split(REASONING)
val sb = StringBuilder() val sb = StringBuilder()
for (i in segments.indices) { for (i in segments.indices) {
if (i % 2 == 0) sb.append(segments[i]) if (i % 2 == 0) sb.append(segments[i])
} }
var text = sb.toString() var text = sb.toString()
val cut = listOf(text.indexOf(QUERY), text.indexOf(SOURCES), text.indexOf(USAGE)) val cut = listOf(text.indexOf(QUERY), text.indexOf(SOURCES), text.indexOf(USAGE))
.filter { it != -1 } .filter { it != -1 }.minOrNull()
.minOrNull()
if (cut != null) text = text.substring(0, cut) if (cut != null) text = text.substring(0, cut)
return text return text
} }
@ -56,38 +56,33 @@ object StreamConsumer {
var rest = raw var rest = raw
while (rest.isNotEmpty() && rest[0] == TOOL) { while (rest.isNotEmpty() && rest[0] == TOOL) {
val nl = rest.indexOf('\n') val nl = rest.indexOf('\n')
if (nl == -1) return null // incomplete event — wait for more data if (nl == -1) return null
rest = rest.substring(nl + 1) rest = rest.substring(nl + 1)
} }
return rest return rest
} }
/**
* Incremental parser O(chunk-size) per [append] instead of O(total-buffer).
* Each character is visited exactly once across all calls, giving O(N) total
* for the entire stream instead of O().
*
* Operates on decoded [Char]s the caller must use [java.io.InputStreamReader]
* to handle UTF-8 multi-byte boundaries at the byte level.
*/
class Incremental { class Incremental {
private val content = StringBuilder(256) private val content = StringBuilder(256)
private val reasoningBuf = StringBuilder(256)
private val toolBuffer = StringBuilder() private val toolBuffer = StringBuilder()
private val trailingBuf = StringBuilder()
private val tools = mutableListOf<ToolEvent>()
private var pastToolEvents = false private var pastToolEvents = false
private var inReasoning = false private var inReasoning = false
private var inTrailing = false
private var done = false private var done = false
private var trailingSentinel: Char? = null
/** fun append(chars: CharArray, offset: Int, length: Int): StreamState {
* Feed a chunk of decoded characters. Returns the current visible text. if (done) return buildState()
* Safe to call on every network chunk only the new characters are scanned.
*/
fun append(chars: CharArray, offset: Int, length: Int): String {
if (done) return content.toString()
if (!pastToolEvents) { if (!pastToolEvents) {
toolBuffer.append(chars, offset, length) toolBuffer.append(chars, offset, length)
if (toolBuffer.isEmpty()) return buildState()
val end = findToolEventsEnd(toolBuffer) val end = findToolEventsEnd(toolBuffer)
if (end == -1) return "" if (end == -1) return buildState()
parseToolEvents(toolBuffer, 0, end)
pastToolEvents = true pastToolEvents = true
processRange(toolBuffer, end, toolBuffer.length) processRange(toolBuffer, end, toolBuffer.length)
toolBuffer.setLength(0) toolBuffer.setLength(0)
@ -96,11 +91,63 @@ object StreamConsumer {
processChars(chars, offset, length) processChars(chars, offset, length)
} }
return content.toString() return buildState()
} }
/** Convenience overload for string input (tests). */ fun append(chunk: String): StreamState = append(chunk.toCharArray(), 0, chunk.length)
fun append(chunk: String): String = append(chunk.toCharArray(), 0, chunk.length)
fun currentContent(): String = content.toString()
private fun buildState(): StreamState {
val queryStr = parseTrailingField(QUERY)
val sourcesList = parseTrailingSources()
return StreamState(
content = content.toString(),
reasoning = reasoningBuf.toString(),
tools = tools.toList(),
query = queryStr,
sources = sourcesList,
)
}
private fun parseTrailingField(sentinel: Char): String {
val full = trailingBuf.toString()
val idx = full.indexOf(sentinel)
if (idx == -1) return ""
val after = full.substring(idx + 1)
val end = listOf(after.indexOf(SOURCES), after.indexOf(USAGE), after.indexOf(QUERY))
.filter { it != -1 }.minOrNull() ?: after.length
return after.substring(0, end).trim()
}
private fun parseTrailingSources(): List<SearchSource> {
val full = trailingBuf.toString()
val idx = full.indexOf(SOURCES)
if (idx == -1) return emptyList()
val after = full.substring(idx + 1)
val end = listOf(after.indexOf(USAGE)).filter { it != -1 }.minOrNull() ?: after.length
val json = after.substring(0, end).trim()
if (json.isEmpty()) return emptyList()
return try {
lenientJson.decodeFromString<List<SearchSource>>(json)
} catch (_: Exception) {
emptyList()
}
}
private fun parseToolEvents(buf: CharSequence, from: Int, to: Int) {
val s = buf.toString()
var pos = from
while (pos < to && s[pos] == TOOL) {
val nl = s.indexOf('\n', pos)
if (nl == -1 || nl >= to) break
val jsonStr = s.substring(pos + 1, nl)
try {
tools.add(lenientJson.decodeFromString<ToolEvent>(jsonStr))
} catch (_: Exception) { }
pos = nl + 1
}
}
private fun processRange(buf: CharSequence, from: Int, to: Int) { private fun processRange(buf: CharSequence, from: Int, to: Int) {
for (i in from until to) { for (i in from until to) {
@ -118,18 +165,24 @@ object StreamConsumer {
} }
private fun processChar(c: Char) { private fun processChar(c: Char) {
when (c) { when {
REASONING -> inReasoning = !inReasoning inTrailing -> trailingBuf.append(c)
USAGE, SOURCES, QUERY -> done = true c == REASONING -> inReasoning = !inReasoning
else -> if (!inReasoning) content.append(c) c == QUERY || c == SOURCES || c == USAGE -> {
inTrailing = true
trailingBuf.append(c)
}
inReasoning -> reasoningBuf.append(c)
else -> content.append(c)
} }
} }
companion object { companion object {
private fun findToolEventsEnd(buf: StringBuilder): Int { private fun findToolEventsEnd(buf: StringBuilder): Int {
var pos = 0 var pos = 0
while (pos < buf.length && buf[pos] == TOOL) { val s = buf.toString()
val nl = buf.indexOf("\n", pos) while (pos < s.length && s[pos] == TOOL) {
val nl = s.indexOf('\n', pos)
if (nl == -1) return -1 if (nl == -1) return -1
pos = nl + 1 pos = nl + 1
} }

View file

@ -19,6 +19,10 @@
<string name="chat_error">Error</string> <string name="chat_error">Error</string>
<string name="chat_open_menu">Open menu</string> <string name="chat_open_menu">Open menu</string>
<!-- Stream blocks -->
<string name="stream_reasoning">Reasoning</string>
<string name="stream_sources">Sources</string>
<!-- Chat errors --> <!-- Chat errors -->
<string name="error_session_expired">Session expired. Please sign in again.</string> <string name="error_session_expired">Session expired. Please sign in again.</string>
<string name="error_credits_exhausted">Credits exhausted.</string> <string name="error_credits_exhausted">Credits exhausted.</string>

View file

@ -21,6 +21,10 @@
<string name="chat_error">Fehler</string> <string name="chat_error">Fehler</string>
<string name="chat_open_menu">Menü öffnen</string> <string name="chat_open_menu">Menü öffnen</string>
<!-- Stream blocks -->
<string name="stream_reasoning">Gedankengang</string>
<string name="stream_sources">Quellen</string>
<!-- Chat errors --> <!-- Chat errors -->
<string name="error_session_expired">Sitzung abgelaufen. Bitte erneut anmelden.</string> <string name="error_session_expired">Sitzung abgelaufen. Bitte erneut anmelden.</string>
<string name="error_credits_exhausted">Guthaben aufgebraucht.</string> <string name="error_credits_exhausted">Guthaben aufgebraucht.</string>

View file

@ -2,21 +2,16 @@ package dev.kaizen.app
import dev.kaizen.app.net.StreamConsumer import dev.kaizen.app.net.StreamConsumer
import org.junit.Assert.assertEquals import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Test import org.junit.Test
/**
* Characterization tests for the sentinel-stripping stream parser mirrors the
* backend's lib/chat/stream-consumer.ts so the app shows exactly the same visible
* text the web does. Tests both [StreamConsumer.visibleText] (batch) and
* [StreamConsumer.Incremental] (streaming).
*/
class StreamConsumerTest { class StreamConsumerTest {
private val usage = 0.toChar() // private val usage = 0.toChar()
private val reasoning = 1.toChar() // private val reasoning = 1.toChar()
private val sources = 2.toChar() // private val sources = 2.toChar()
private val tool = 3.toChar() // private val tool = 3.toChar()
private val query = 4.toChar() // private val query = 4.toChar()
// ── Batch parser (visibleText) ────────────────────────────────────────── // ── Batch parser (visibleText) ──────────────────────────────────────────
@ -27,38 +22,27 @@ class StreamConsumerTest {
@Test @Test
fun usageSentinel_isClipped() { fun usageSentinel_isClipped() {
val raw = "Antwort.$usage{\"i\":12,\"o\":34}" assertEquals("Antwort.", StreamConsumer.visibleText("Antwort.$usage{\"i\":12,\"o\":34}"))
assertEquals("Antwort.", StreamConsumer.visibleText(raw))
} }
@Test @Test
fun reasoning_isStripped_contentKept() { fun reasoning_isStripped_contentKept() {
val raw = "${reasoning}denke nach${reasoning}Die Antwort ist 42." assertEquals("Die Antwort ist 42.", StreamConsumer.visibleText("${reasoning}denke nach${reasoning}Die Antwort ist 42."))
assertEquals("Die Antwort ist 42.", StreamConsumer.visibleText(raw))
} }
@Test @Test
fun reasoning_thenContent_thenUsage() { fun reasoning_thenContent_thenUsage() {
val raw = "${reasoning}grübel${reasoning}Fertig.$usage{\"i\":1,\"o\":2}" assertEquals("Fertig.", StreamConsumer.visibleText("${reasoning}grübel${reasoning}Fertig.$usage{\"i\":1,\"o\":2}"))
assertEquals("Fertig.", StreamConsumer.visibleText(raw))
} }
@Test @Test
fun searchQueryAndSources_areClipped() { fun searchQueryAndSources_areClipped() {
val raw = "Hier das Wetter.${query}\"wetter\"${sources}[{\"url\":\"x\"}]$usage{\"i\":1,\"o\":2}" assertEquals("Hier das Wetter.", StreamConsumer.visibleText("Hier das Wetter.${query}\"wetter\"${sources}[{\"url\":\"x\"}]$usage{\"i\":1,\"o\":2}"))
assertEquals("Hier das Wetter.", StreamConsumer.visibleText(raw))
} }
@Test @Test
fun leadingToolEvents_areStripped() { fun leadingToolEvents_areStripped() {
val raw = "$tool{\"type\":\"thinking\"}\n${tool}{\"type\":\"start\",\"name\":\"x\"}\nDie Antwort." assertEquals("Die Antwort.", StreamConsumer.visibleText("$tool{\"type\":\"thinking\"}\n${tool}{\"type\":\"start\",\"name\":\"x\"}\nDie Antwort."))
assertEquals("Die Antwort.", StreamConsumer.visibleText(raw))
}
@Test
fun incompleteLeadingToolEvent_showsNothing() {
val raw = "$tool{\"type\":\"thi"
assertEquals("", StreamConsumer.visibleText(raw))
} }
@Test @Test
@ -66,121 +50,100 @@ class StreamConsumerTest {
assertEquals("", StreamConsumer.visibleText("")) assertEquals("", StreamConsumer.visibleText(""))
} }
@Test // ── Incremental parser — content ───────────────────────────────────────
fun reasoningOnly_noContentYet_returnsEmpty() {
val raw = "${reasoning}immer noch am denken"
assertEquals("", StreamConsumer.visibleText(raw))
}
// ── Incremental parser ─────────────────────────────────────────────────
// Each test feeds the same input in various chunking patterns to verify
// that the incremental result matches the batch result exactly.
@Test @Test
fun incremental_plainText_singleChunk() { fun incremental_plainText() {
val inc = StreamConsumer.Incremental() val inc = StreamConsumer.Incremental()
assertEquals("Hallo Welt", inc.append("Hallo Welt")) assertEquals("Hallo Welt", inc.append("Hallo Welt").content)
} }
@Test @Test
fun incremental_plainText_charByChar() { fun incremental_plainText_charByChar() {
val inc = StreamConsumer.Incremental() val inc = StreamConsumer.Incremental()
val input = "Hallo Welt" var state = inc.append("")
var result = "" for (c in "Hallo Welt") state = inc.append(c.toString())
for (c in input) result = inc.append(c.toString()) assertEquals("Hallo Welt", state.content)
assertEquals("Hallo Welt", result)
} }
@Test @Test
fun incremental_usageSentinel_isClipped() { fun incremental_usageSentinel_isClipped() {
val inc = StreamConsumer.Incremental() val inc = StreamConsumer.Incremental()
assertEquals("Antwort.", inc.append("Antwort.")) inc.append("Antwort.")
assertEquals("Antwort.", inc.append("$usage{\"i\":12,\"o\":34}")) assertEquals("Antwort.", inc.append("$usage{\"i\":12,\"o\":34}").content)
} }
@Test @Test
fun incremental_reasoning_isStripped() { fun incremental_reasoning_captured() {
val inc = StreamConsumer.Incremental() val inc = StreamConsumer.Incremental()
assertEquals("", inc.append("${reasoning}denke")) inc.append("${reasoning}denke nach")
assertEquals("", inc.append(" nach")) val state = inc.append("${reasoning}Die Antwort.")
assertEquals("Die ", inc.append("${reasoning}Die ")) assertEquals("Die Antwort.", state.content)
assertEquals("Die Antwort ist 42.", inc.append("Antwort ist 42.")) assertEquals("denke nach", state.reasoning)
} }
@Test @Test
fun incremental_reasoning_thenContent_thenUsage() { fun incremental_reasoning_thenContent_thenUsage() {
val inc = StreamConsumer.Incremental() val inc = StreamConsumer.Incremental()
inc.append("${reasoning}grübel${reasoning}") inc.append("${reasoning}grübel${reasoning}")
val result = inc.append("Fertig.$usage{\"i\":1,\"o\":2}") val state = inc.append("Fertig.$usage{\"i\":1,\"o\":2}")
assertEquals("Fertig.", result) assertEquals("Fertig.", state.content)
assertEquals("grübel", state.reasoning)
} }
@Test @Test
fun incremental_searchQueryAndSources_areClipped() { fun incremental_sources_parsed() {
val inc = StreamConsumer.Incremental() val inc = StreamConsumer.Incremental()
inc.append("Hier das Wetter.") val state = inc.append("Wetter.${query}\"berlin wetter\"${sources}[{\"title\":\"t\",\"url\":\"u\",\"snippet\":\"s\"}]$usage{}")
val result = inc.append("${query}\"wetter\"${sources}[{\"url\":\"x\"}]$usage{\"i\":1,\"o\":2}") assertEquals("Wetter.", state.content)
assertEquals("Hier das Wetter.", result) assertEquals("\"berlin wetter\"", state.query)
assertEquals(1, state.sources.size)
assertEquals("t", state.sources[0].title)
assertEquals("u", state.sources[0].url)
} }
@Test @Test
fun incremental_toolEvents_singleChunk() { fun incremental_toolEvents_parsed() {
val inc = StreamConsumer.Incremental() val inc = StreamConsumer.Incremental()
val raw = "$tool{\"type\":\"thinking\"}\n${tool}{\"type\":\"start\",\"name\":\"x\"}\nDie Antwort." val state = inc.append("$tool{\"type\":\"start\",\"name\":\"web_search\"}\n${tool}{\"type\":\"end\",\"name\":\"web_search\",\"elapsed\":120}\nAntwort.")
assertEquals("Die Antwort.", inc.append(raw)) assertEquals("Antwort.", state.content)
assertEquals(2, state.tools.size)
assertEquals("start", state.tools[0].type)
assertEquals("web_search", state.tools[0].name)
assertEquals("end", state.tools[1].type)
assertEquals(120L, state.tools[1].elapsed)
} }
@Test @Test
fun incremental_toolEvents_splitAcrossChunks() { fun incremental_toolEvents_splitAcrossChunks() {
val inc = StreamConsumer.Incremental() val inc = StreamConsumer.Incremental()
assertEquals("", inc.append("$tool{\"type\":\"thin")) inc.append("$tool{\"type\":\"sta")
assertEquals("", inc.append("king\"}\n$tool{\"type\":\"sta")) inc.append("rt\",\"name\":\"x\"}\n")
assertEquals("Die Antwort.", inc.append("rt\"}\nDie Antwort.")) val state = inc.append("Content.")
} assertEquals("Content.", state.content)
assertEquals(1, state.tools.size)
@Test
fun incremental_incompleteToolEvent_showsNothing() {
val inc = StreamConsumer.Incremental()
assertEquals("", inc.append("$tool{\"type\":\"thi"))
}
@Test
fun incremental_emptyAppend_returnsEmpty() {
val inc = StreamConsumer.Incremental()
assertEquals("", inc.append(""))
}
@Test
fun incremental_reasoningOnly_returnsEmpty() {
val inc = StreamConsumer.Incremental()
assertEquals("", inc.append("${reasoning}immer noch am denken"))
} }
@Test @Test
fun incremental_afterDone_ignoresMore() { fun incremental_afterDone_ignoresMore() {
val inc = StreamConsumer.Incremental() val inc = StreamConsumer.Incremental()
inc.append("Fertig.") inc.append("Fertig.")
val afterUsage = inc.append("${usage}{\"i\":1}") inc.append("${usage}{\"i\":1}")
assertEquals("Fertig.", afterUsage) assertEquals("Fertig.", inc.append("ignored").content)
assertEquals("Fertig.", inc.append("ignored trailing garbage"))
} }
@Test @Test
fun incremental_toolEvents_thenReasoning_thenContent() { fun incremental_fullStream_allSections() {
val full = "$tool{\"type\":\"start\",\"name\":\"t\"}\n${reasoning}grübel${reasoning}Die Antwort.${query}\"q\"${sources}[{\"title\":\"x\",\"url\":\"y\",\"snippet\":\"z\"}]$usage{}"
val inc = StreamConsumer.Incremental() val inc = StreamConsumer.Incremental()
assertEquals("", inc.append("$tool{\"type\":\"thinking\"}\n")) var state = inc.append("")
assertEquals("", inc.append("${reasoning}deep thought")) for (c in full) state = inc.append(c.toString())
assertEquals("42", inc.append("${reasoning}42")) assertEquals("Die Antwort.", state.content)
} assertEquals("grübel", state.reasoning)
assertEquals(1, state.tools.size)
@Test assertEquals("t", state.tools[0].name)
fun incremental_matchesBatch_fullStream() { assertEquals("\"q\"", state.query)
val full = "$tool{\"type\":\"thinking\"}\n${reasoning}grübel${reasoning}Die Antwort.${query}q${sources}s$usage{}" assertEquals(1, state.sources.size)
val expected = StreamConsumer.visibleText(full) assertEquals("x", state.sources[0].title)
val inc = StreamConsumer.Incremental()
var result = ""
for (c in full) result = inc.append(c.toString())
assertEquals(expected, result)
} }
} }