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:
parent
736bb4c206
commit
76ab8bfde9
9 changed files with 423 additions and 162 deletions
|
|
@ -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)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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 }
|
||||||
|
|
|
||||||
215
app/src/main/java/dev/kaizen/app/chat/StreamBlocks.kt
Normal file
215
app/src/main/java/dev/kaizen/app/chat/StreamBlocks.kt
Normal 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,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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(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 {
|
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
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue