From 76ab8bfde9b949d0b3704b67afb3883fa06afffc Mon Sep 17 00:00:00 2001 From: Bruno Deanoz Date: Sun, 21 Jun 2026 15:35:46 +0200 Subject: [PATCH] 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 (title, url, snippet) - \x03 TOOL: parsed as List (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 instead of Flow. 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. --- .../dev/kaizen/app/chat/ChatComponents.kt | 12 + .../java/dev/kaizen/app/chat/ChatModels.kt | 6 + .../java/dev/kaizen/app/chat/ChatScreen.kt | 10 +- .../java/dev/kaizen/app/chat/StreamBlocks.kt | 215 ++++++++++++++++++ .../main/java/dev/kaizen/app/net/KaizenApi.kt | 2 +- .../java/dev/kaizen/app/net/StreamConsumer.kt | 175 +++++++++----- app/src/main/res/values-en/strings.xml | 4 + app/src/main/res/values/strings.xml | 4 + .../java/dev/kaizen/app/StreamConsumerTest.kt | 157 +++++-------- 9 files changed, 423 insertions(+), 162 deletions(-) create mode 100644 app/src/main/java/dev/kaizen/app/chat/StreamBlocks.kt diff --git a/app/src/main/java/dev/kaizen/app/chat/ChatComponents.kt b/app/src/main/java/dev/kaizen/app/chat/ChatComponents.kt index 99467f3..7365a2b 100644 --- a/app/src/main/java/dev/kaizen/app/chat/ChatComponents.kt +++ b/app/src/main/java/dev/kaizen/app/chat/ChatComponents.kt @@ -223,12 +223,24 @@ fun MessageRow(message: Message) { Spacer(Modifier.width(10.dp)) Box(Modifier.weight(1f).padding(top = 3.dp)) { Column { + if (message.tools.isNotEmpty()) { + ToolEventsBlock(message.tools, streaming = message.streaming) + Spacer(Modifier.height(8.dp)) + } if (hasAttachments) { AttachmentGrid(message.attachments) if (message.content.isNotBlank() || message.thinking) Spacer(Modifier.height(8.dp)) } if (message.thinking) TypingDots() 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) + } } } } diff --git a/app/src/main/java/dev/kaizen/app/chat/ChatModels.kt b/app/src/main/java/dev/kaizen/app/chat/ChatModels.kt index 6ad8e15..e20b3c2 100644 --- a/app/src/main/java/dev/kaizen/app/chat/ChatModels.kt +++ b/app/src/main/java/dev/kaizen/app/chat/ChatModels.kt @@ -13,6 +13,8 @@ import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.res.stringResource import dev.kaizen.app.R import dev.kaizen.app.net.Attachment +import dev.kaizen.app.net.SearchSource +import dev.kaizen.app.net.ToolEvent enum class Role { User, Assistant } @@ -24,6 +26,10 @@ data class Message( val thinking: Boolean = false, val wireId: String = java.util.UUID.randomUUID().toString(), val attachments: List = emptyList(), + val reasoning: String = "", + val tools: List = emptyList(), + val query: String = "", + val sources: List = emptyList(), ) data class Suggestion(val labelRes: Int, val promptRes: Int, val icon: ImageVector) diff --git a/app/src/main/java/dev/kaizen/app/chat/ChatScreen.kt b/app/src/main/java/dev/kaizen/app/chat/ChatScreen.kt index e3db922..c5ea12a 100644 --- a/app/src/main/java/dev/kaizen/app/chat/ChatScreen.kt +++ b/app/src/main/java/dev/kaizen/app/chat/ChatScreen.kt @@ -267,16 +267,20 @@ fun ChatScreen( KaizenApi.chat( cfg.baseUrl, cfg.token, cfg.model, history, cfg.speed, attachments = uploadedAtts.takeIf { it.isNotEmpty() }, - ).collect { visible -> + ).collect { state -> val idx = messages.indexOfLast { it.id == assistantId } if (idx < 0) return@collect - if (!sawContent && visible.isNotEmpty()) { + if (!sawContent && state.content.isNotEmpty()) { sawContent = true haptics.responseStart() } messages[idx] = messages[idx].copy( 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 } diff --git a/app/src/main/java/dev/kaizen/app/chat/StreamBlocks.kt b/app/src/main/java/dev/kaizen/app/chat/StreamBlocks.kt new file mode 100644 index 0000000..0764fc5 --- /dev/null +++ b/app/src/main/java/dev/kaizen/app/chat/StreamBlocks.kt @@ -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, 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, 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, + ) + } +} diff --git a/app/src/main/java/dev/kaizen/app/net/KaizenApi.kt b/app/src/main/java/dev/kaizen/app/net/KaizenApi.kt index 3af92c4..4a45e7b 100644 --- a/app/src/main/java/dev/kaizen/app/net/KaizenApi.kt +++ b/app/src/main/java/dev/kaizen/app/net/KaizenApi.kt @@ -350,7 +350,7 @@ object KaizenApi { messages: List, speed: ChatSpeed = ChatSpeed.Normal, attachments: List? = null, - ): Flow = flow { + ): Flow = flow { val payload = json.encodeToString( ChatRequest( model = model, diff --git a/app/src/main/java/dev/kaizen/app/net/StreamConsumer.kt b/app/src/main/java/dev/kaizen/app/net/StreamConsumer.kt index 91324b4..8158851 100644 --- a/app/src/main/java/dev/kaizen/app/net/StreamConsumer.kt +++ b/app/src/main/java/dev/kaizen/app/net/StreamConsumer.kt @@ -1,54 +1,54 @@ package dev.kaizen.app.net -/** - * Sentinel code points delimiting control sections in the `/api/v1/chat` stream. - * 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 SOURCES = 2.toChar() // -private val TOOL = 3.toChar() // -private val QUERY = 4.toChar() // +import kotlinx.serialization.Serializable +import kotlinx.serialization.json.Json + +private val USAGE = 0.toChar() +private val REASONING = 1.toChar() +private val SOURCES = 2.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 = emptyList(), + val query: String = "", + val sources: List = 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] [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 { - /** - * 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 { val rest = stripLeadingToolEvents(raw) ?: return "" - val segments = rest.split(REASONING) val sb = StringBuilder() for (i in segments.indices) { if (i % 2 == 0) sb.append(segments[i]) } var text = sb.toString() - val cut = listOf(text.indexOf(QUERY), text.indexOf(SOURCES), text.indexOf(USAGE)) - .filter { it != -1 } - .minOrNull() + .filter { it != -1 }.minOrNull() if (cut != null) text = text.substring(0, cut) - return text } @@ -56,38 +56,33 @@ object StreamConsumer { var rest = raw while (rest.isNotEmpty() && rest[0] == TOOL) { 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) } 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(N²). - * - * 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 { private val content = StringBuilder(256) + private val reasoningBuf = StringBuilder(256) private val toolBuffer = StringBuilder() + private val trailingBuf = StringBuilder() + private val tools = mutableListOf() private var pastToolEvents = false private var inReasoning = false + private var inTrailing = false private var done = false + private var trailingSentinel: Char? = null - /** - * Feed a chunk of decoded characters. Returns the current visible text. - * 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() + fun append(chars: CharArray, offset: Int, length: Int): StreamState { + if (done) return buildState() if (!pastToolEvents) { toolBuffer.append(chars, offset, length) + if (toolBuffer.isEmpty()) return buildState() val end = findToolEventsEnd(toolBuffer) - if (end == -1) return "" + if (end == -1) return buildState() + parseToolEvents(toolBuffer, 0, end) pastToolEvents = true processRange(toolBuffer, end, toolBuffer.length) toolBuffer.setLength(0) @@ -96,11 +91,63 @@ object StreamConsumer { processChars(chars, offset, length) } - return content.toString() + return buildState() } - /** Convenience overload for string input (tests). */ - fun append(chunk: String): String = append(chunk.toCharArray(), 0, chunk.length) + fun append(chunk: String): StreamState = 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 { + 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>(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(jsonStr)) + } catch (_: Exception) { } + pos = nl + 1 + } + } private fun processRange(buf: CharSequence, from: Int, to: Int) { for (i in from until to) { @@ -118,18 +165,24 @@ object StreamConsumer { } private fun processChar(c: Char) { - when (c) { - REASONING -> inReasoning = !inReasoning - USAGE, SOURCES, QUERY -> done = true - else -> if (!inReasoning) content.append(c) + when { + inTrailing -> trailingBuf.append(c) + c == REASONING -> inReasoning = !inReasoning + c == QUERY || c == SOURCES || c == USAGE -> { + inTrailing = true + trailingBuf.append(c) + } + inReasoning -> reasoningBuf.append(c) + else -> content.append(c) } } companion object { private fun findToolEventsEnd(buf: StringBuilder): Int { var pos = 0 - while (pos < buf.length && buf[pos] == TOOL) { - val nl = buf.indexOf("\n", pos) + val s = buf.toString() + while (pos < s.length && s[pos] == TOOL) { + val nl = s.indexOf('\n', pos) if (nl == -1) return -1 pos = nl + 1 } diff --git a/app/src/main/res/values-en/strings.xml b/app/src/main/res/values-en/strings.xml index 0e9d70d..faebf64 100644 --- a/app/src/main/res/values-en/strings.xml +++ b/app/src/main/res/values-en/strings.xml @@ -19,6 +19,10 @@ Error Open menu + + Reasoning + Sources + Session expired. Please sign in again. Credits exhausted. diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index bd0cc04..7866a1f 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -21,6 +21,10 @@ Fehler Menü öffnen + + Gedankengang + Quellen + Sitzung abgelaufen. Bitte erneut anmelden. Guthaben aufgebraucht. diff --git a/app/src/test/java/dev/kaizen/app/StreamConsumerTest.kt b/app/src/test/java/dev/kaizen/app/StreamConsumerTest.kt index 0de9144..6ab42ba 100644 --- a/app/src/test/java/dev/kaizen/app/StreamConsumerTest.kt +++ b/app/src/test/java/dev/kaizen/app/StreamConsumerTest.kt @@ -2,21 +2,16 @@ package dev.kaizen.app import dev.kaizen.app.net.StreamConsumer import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue 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 { - private val usage = 0.toChar() // - private val reasoning = 1.toChar() // - private val sources = 2.toChar() // - private val tool = 3.toChar() // - private val query = 4.toChar() // + private val usage = 0.toChar() + private val reasoning = 1.toChar() + private val sources = 2.toChar() + private val tool = 3.toChar() + private val query = 4.toChar() // ── Batch parser (visibleText) ────────────────────────────────────────── @@ -27,38 +22,27 @@ class StreamConsumerTest { @Test fun usageSentinel_isClipped() { - val raw = "Antwort.$usage{\"i\":12,\"o\":34}" - assertEquals("Antwort.", StreamConsumer.visibleText(raw)) + assertEquals("Antwort.", StreamConsumer.visibleText("Antwort.$usage{\"i\":12,\"o\":34}")) } @Test fun reasoning_isStripped_contentKept() { - val raw = "${reasoning}denke nach${reasoning}Die Antwort ist 42." - assertEquals("Die Antwort ist 42.", StreamConsumer.visibleText(raw)) + assertEquals("Die Antwort ist 42.", StreamConsumer.visibleText("${reasoning}denke nach${reasoning}Die Antwort ist 42.")) } @Test fun reasoning_thenContent_thenUsage() { - val raw = "${reasoning}grübel${reasoning}Fertig.$usage{\"i\":1,\"o\":2}" - assertEquals("Fertig.", StreamConsumer.visibleText(raw)) + assertEquals("Fertig.", StreamConsumer.visibleText("${reasoning}grübel${reasoning}Fertig.$usage{\"i\":1,\"o\":2}")) } @Test fun searchQueryAndSources_areClipped() { - val raw = "Hier das Wetter.${query}\"wetter\"${sources}[{\"url\":\"x\"}]$usage{\"i\":1,\"o\":2}" - assertEquals("Hier das Wetter.", StreamConsumer.visibleText(raw)) + assertEquals("Hier das Wetter.", StreamConsumer.visibleText("Hier das Wetter.${query}\"wetter\"${sources}[{\"url\":\"x\"}]$usage{\"i\":1,\"o\":2}")) } @Test fun leadingToolEvents_areStripped() { - val raw = "$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)) + assertEquals("Die Antwort.", StreamConsumer.visibleText("$tool{\"type\":\"thinking\"}\n${tool}{\"type\":\"start\",\"name\":\"x\"}\nDie Antwort.")) } @Test @@ -66,121 +50,100 @@ class StreamConsumerTest { assertEquals("", StreamConsumer.visibleText("")) } - @Test - 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. + // ── Incremental parser — content ─────────────────────────────────────── @Test - fun incremental_plainText_singleChunk() { + fun incremental_plainText() { val inc = StreamConsumer.Incremental() - assertEquals("Hallo Welt", inc.append("Hallo Welt")) + assertEquals("Hallo Welt", inc.append("Hallo Welt").content) } @Test fun incremental_plainText_charByChar() { val inc = StreamConsumer.Incremental() - val input = "Hallo Welt" - var result = "" - for (c in input) result = inc.append(c.toString()) - assertEquals("Hallo Welt", result) + var state = inc.append("") + for (c in "Hallo Welt") state = inc.append(c.toString()) + assertEquals("Hallo Welt", state.content) } @Test fun incremental_usageSentinel_isClipped() { val inc = StreamConsumer.Incremental() - assertEquals("Antwort.", inc.append("Antwort.")) - assertEquals("Antwort.", inc.append("$usage{\"i\":12,\"o\":34}")) + inc.append("Antwort.") + assertEquals("Antwort.", inc.append("$usage{\"i\":12,\"o\":34}").content) } @Test - fun incremental_reasoning_isStripped() { + fun incremental_reasoning_captured() { val inc = StreamConsumer.Incremental() - assertEquals("", inc.append("${reasoning}denke")) - assertEquals("", inc.append(" nach")) - assertEquals("Die ", inc.append("${reasoning}Die ")) - assertEquals("Die Antwort ist 42.", inc.append("Antwort ist 42.")) + inc.append("${reasoning}denke nach") + val state = inc.append("${reasoning}Die Antwort.") + assertEquals("Die Antwort.", state.content) + assertEquals("denke nach", state.reasoning) } @Test fun incremental_reasoning_thenContent_thenUsage() { val inc = StreamConsumer.Incremental() inc.append("${reasoning}grübel${reasoning}") - val result = inc.append("Fertig.$usage{\"i\":1,\"o\":2}") - assertEquals("Fertig.", result) + val state = inc.append("Fertig.$usage{\"i\":1,\"o\":2}") + assertEquals("Fertig.", state.content) + assertEquals("grübel", state.reasoning) } @Test - fun incremental_searchQueryAndSources_areClipped() { + fun incremental_sources_parsed() { val inc = StreamConsumer.Incremental() - inc.append("Hier das Wetter.") - val result = inc.append("${query}\"wetter\"${sources}[{\"url\":\"x\"}]$usage{\"i\":1,\"o\":2}") - assertEquals("Hier das Wetter.", result) + val state = inc.append("Wetter.${query}\"berlin wetter\"${sources}[{\"title\":\"t\",\"url\":\"u\",\"snippet\":\"s\"}]$usage{}") + assertEquals("Wetter.", state.content) + assertEquals("\"berlin wetter\"", state.query) + assertEquals(1, state.sources.size) + assertEquals("t", state.sources[0].title) + assertEquals("u", state.sources[0].url) } @Test - fun incremental_toolEvents_singleChunk() { + fun incremental_toolEvents_parsed() { val inc = StreamConsumer.Incremental() - val raw = "$tool{\"type\":\"thinking\"}\n${tool}{\"type\":\"start\",\"name\":\"x\"}\nDie Antwort." - assertEquals("Die Antwort.", inc.append(raw)) + val state = inc.append("$tool{\"type\":\"start\",\"name\":\"web_search\"}\n${tool}{\"type\":\"end\",\"name\":\"web_search\",\"elapsed\":120}\nAntwort.") + 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 fun incremental_toolEvents_splitAcrossChunks() { val inc = StreamConsumer.Incremental() - assertEquals("", inc.append("$tool{\"type\":\"thin")) - assertEquals("", inc.append("king\"}\n$tool{\"type\":\"sta")) - assertEquals("Die Antwort.", inc.append("rt\"}\nDie Antwort.")) - } - - @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")) + inc.append("$tool{\"type\":\"sta") + inc.append("rt\",\"name\":\"x\"}\n") + val state = inc.append("Content.") + assertEquals("Content.", state.content) + assertEquals(1, state.tools.size) } @Test fun incremental_afterDone_ignoresMore() { val inc = StreamConsumer.Incremental() inc.append("Fertig.") - val afterUsage = inc.append("${usage}{\"i\":1}") - assertEquals("Fertig.", afterUsage) - assertEquals("Fertig.", inc.append("ignored trailing garbage")) + inc.append("${usage}{\"i\":1}") + assertEquals("Fertig.", inc.append("ignored").content) } @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() - assertEquals("", inc.append("$tool{\"type\":\"thinking\"}\n")) - assertEquals("", inc.append("${reasoning}deep thought")) - assertEquals("42", inc.append("${reasoning}42")) - } - - @Test - fun incremental_matchesBatch_fullStream() { - val full = "$tool{\"type\":\"thinking\"}\n${reasoning}grübel${reasoning}Die Antwort.${query}q${sources}s$usage{}" - val expected = StreamConsumer.visibleText(full) - - val inc = StreamConsumer.Incremental() - var result = "" - for (c in full) result = inc.append(c.toString()) - assertEquals(expected, result) + var state = inc.append("") + for (c in full) state = inc.append(c.toString()) + assertEquals("Die Antwort.", state.content) + assertEquals("grübel", state.reasoning) + assertEquals(1, state.tools.size) + assertEquals("t", state.tools[0].name) + assertEquals("\"q\"", state.query) + assertEquals(1, state.sources.size) + assertEquals("x", state.sources[0].title) } }