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))
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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 }
|
||||
|
|
|
|||
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>,
|
||||
speed: ChatSpeed = ChatSpeed.Normal,
|
||||
attachments: List<Attachment>? = null,
|
||||
): Flow<String> = flow {
|
||||
): Flow<StreamState> = flow {
|
||||
val payload = json.encodeToString(
|
||||
ChatRequest(
|
||||
model = model,
|
||||
|
|
|
|||
|
|
@ -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(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<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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue