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) } }