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

View file

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

View file

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

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>,
speed: ChatSpeed = ChatSpeed.Normal,
attachments: List<Attachment>? = null,
): Flow<String> = flow {
): Flow<StreamState> = flow {
val payload = json.encodeToString(
ChatRequest(
model = model,

View file

@ -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<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 {
/**
* 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().
*
* 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<ToolEvent>()
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<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) {
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
}

View file

@ -19,6 +19,10 @@
<string name="chat_error">Error</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 -->
<string name="error_session_expired">Session expired. Please sign in again.</string>
<string name="error_credits_exhausted">Credits exhausted.</string>

View file

@ -21,6 +21,10 @@
<string name="chat_error">Fehler</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 -->
<string name="error_session_expired">Sitzung abgelaufen. Bitte erneut anmelden.</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 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)
}
}