The backend terminal tool (tools/terminal_tool.py) returns a single merged 'output' string plus 'exit_code' and 'error' — never 'stdout'/'stderr'. ChatBubble was reading the non-existent 'stdout' key, so command output was silently dropped and only the Duration footer showed. - Parse 'output' as canonical; keep legacy 'stdout'/'stderr' fallback for backward compat. - Route terminal output into mainOutput so it renders like other tool bubbles. - Surface non-zero exit codes as an error line when no 'error' string is set. - Remove dead 'stderr' render branch. - Update ToolBubbleParsingTest to assert the real payload shape (incl. the exact issue-585 'echo hellp' sample) and add regression cases. Co-authored-by: hy4ri <hy4ri@users.noreply.github.com>
This commit is contained in:
1 parent
47258bd8b0
commit
71c1bdded9
2 files changed
+88
-33
No files matched your search
@@ -1643,31 +1643,40 @@ fun parseToolOutput(
|
||||
)
|
||||
}
|
||||
|
||||
// Check if this looks like a terminal result (check dataSource for backward compat)
|
||||
// Terminal result. The backend (tools/terminal_tool.py) emits a single
|
||||
// `output` string (stdout+stderr merged, ANSI-stripped), plus `exit_code`
|
||||
// and an `error` string on failure. Older payloads used `stdout`/`stderr`
|
||||
// — accept both for backward compat, but `output` is canonical.
|
||||
val hasOutput = dataSource.has("output")
|
||||
val hasStdout = dataSource.has("stdout")
|
||||
val hasStderr = dataSource.has("stderr")
|
||||
val hasExitCode = dataSource.has("exit_code") || dataSource.has("exitCode")
|
||||
|
||||
if (hasStdout || hasStderr || hasExitCode) {
|
||||
val stdout =
|
||||
dataSource
|
||||
.get("stdout")
|
||||
?.takeIf { !it.isJsonNull }
|
||||
?.asString
|
||||
?.takeIf { it.isNotEmpty() }
|
||||
val stderr =
|
||||
dataSource
|
||||
.get("stderr")
|
||||
?.takeIf { !it.isJsonNull }
|
||||
?.asString
|
||||
?.takeIf { it.isNotEmpty() }
|
||||
val exitCode = (dataSource.get("exit_code") ?: dataSource.get("exitCode"))?.takeIf { !it.isJsonNull }?.asInt
|
||||
if (hasOutput || hasStdout || hasStderr || hasExitCode) {
|
||||
val terminalOutput =
|
||||
(
|
||||
dataSource.get("output")?.takeIf { !it.isJsonNull }?.asString
|
||||
?: listOfNotNull(
|
||||
dataSource.get("stdout")?.takeIf { !it.isJsonNull }?.asString?.takeIf { it.isNotEmpty() },
|
||||
dataSource.get("stderr")?.takeIf { !it.isJsonNull }?.asString?.takeIf { it.isNotEmpty() },
|
||||
).joinToString("\n").takeIf { it.isNotEmpty() }
|
||||
)?.takeIf { it.isNotEmpty() }
|
||||
val exitCode = (dataSource.get("exit_code") ?: dataSource.get("exitCode"))?.toIntOrNull()
|
||||
val error =
|
||||
dataSource
|
||||
.get("error")
|
||||
?.takeIf { !it.isJsonNull }
|
||||
?.asString
|
||||
?.takeIf { it.isNotEmpty() }
|
||||
// Surface non-zero exit codes as an error line even when `error` is absent.
|
||||
val effectiveError =
|
||||
if (error != null) {
|
||||
error
|
||||
} else if (exitCode != null && exitCode != 0 && terminalOutput == null) {
|
||||
"exit code $exitCode"
|
||||
} else {
|
||||
null
|
||||
}
|
||||
val duration = obj.get("duration_s")?.takeIf { !it.isJsonNull }?.asDouble
|
||||
|
||||
ParsedToolData(
|
||||
@@ -1675,11 +1684,12 @@ fun parseToolOutput(
|
||||
args = args,
|
||||
result = dataSource.entrySet().associate { it.key to it.value.toString() },
|
||||
isTerminal = true,
|
||||
stdout = stdout,
|
||||
stderr = stderr,
|
||||
stdout = terminalOutput,
|
||||
stderr = null,
|
||||
exitCode = exitCode,
|
||||
error = error,
|
||||
error = effectiveError,
|
||||
summaryText = summaryText,
|
||||
mainOutput = terminalOutput,
|
||||
durationSec = duration,
|
||||
isRunning = isRunning,
|
||||
)
|
||||
@@ -1758,17 +1768,6 @@ private fun ExpandedToolContent(
|
||||
),
|
||||
)
|
||||
}
|
||||
parsed.stderr?.let {
|
||||
Text(
|
||||
text = stringResource(R.string.chat_tool_stderr, it),
|
||||
style =
|
||||
MaterialTheme.typography.bodySmall.copy(
|
||||
color = statusColors.error.copy(alpha = 0.9f),
|
||||
fontFamily = FontFamily.Monospace,
|
||||
fontSize = 11.sp,
|
||||
),
|
||||
)
|
||||
}
|
||||
parsed.error?.let {
|
||||
Text(
|
||||
text = stringResource(R.string.chat_tool_execution_error, it),
|
||||
@@ -2319,3 +2318,19 @@ private fun JsonObject.entrySet(): Set<Map.Entry<String, JsonElement>> = entries
|
||||
private fun JsonObject.keySet(): Set<String> = keys
|
||||
|
||||
private fun JsonArray.size(): Int = size
|
||||
|
||||
/**
|
||||
* Parse a numeric/string JSON primitive into an Int, tolerating backend quirks
|
||||
* (e.g. terminal `exit_code` arrives as a FLOAT `0.0` or a STRING). Returns null
|
||||
* if missing, null, or non-numeric — callers must not throw, or the whole tool
|
||||
* parse is caught and the bubble falls back to raw JSON.
|
||||
*/
|
||||
private fun JsonElement?.toIntOrNull(): Int? {
|
||||
if (this == null || this is JsonNull) return null
|
||||
return when (this) {
|
||||
is JsonPrimitive -> {
|
||||
content.toIntOrNull() ?: content.toDoubleOrNull()?.toInt()
|
||||
}
|
||||
else -> null
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user