fix: prevent JSON event leaking to users and reload skills after AI creation
- OpenCode runtime: stop calling _collect_text fallback on non-text events (step_start, step_finish, etc.) - Both runtimes: guard raw stdout fallback to only apply for non-JSON output - main.py: reload skills after AI responses containing skill-related keywords - main.py: return friendly message instead of empty string for tool-only responses
This commit is contained in:
@@ -259,8 +259,11 @@ class ClaudeCodeRuntime:
|
|||||||
# Parse the output
|
# Parse the output
|
||||||
response_text, session_id, usage = self._parse_output(stdout)
|
response_text, session_id, usage = self._parse_output(stdout)
|
||||||
|
|
||||||
if not response_text:
|
if not response_text and stdout.strip():
|
||||||
response_text = stdout # Fallback to raw output
|
# Only fall back to raw output if it doesn't look like JSON events
|
||||||
|
# (which would leak internal lifecycle data to the user)
|
||||||
|
if not stdout.strip().startswith("{"):
|
||||||
|
response_text = stdout
|
||||||
|
|
||||||
# Store session mapping
|
# Store session mapping
|
||||||
if session_id and conversation_id:
|
if session_id and conversation_id:
|
||||||
@@ -420,7 +423,8 @@ class ClaudeCodeRuntime:
|
|||||||
try:
|
try:
|
||||||
event = json.loads(line)
|
event = json.loads(line)
|
||||||
if isinstance(event, dict):
|
if isinstance(event, dict):
|
||||||
if event.get("type") == "result":
|
event_type = event.get("type", "")
|
||||||
|
if event_type == "result":
|
||||||
text_parts.append(event.get("result", ""))
|
text_parts.append(event.get("result", ""))
|
||||||
session_id = event.get("session_id", session_id)
|
session_id = event.get("session_id", session_id)
|
||||||
usage = {
|
usage = {
|
||||||
@@ -430,7 +434,7 @@ class ClaudeCodeRuntime:
|
|||||||
"duration_api_ms": event.get("duration_api_ms", 0),
|
"duration_api_ms": event.get("duration_api_ms", 0),
|
||||||
"is_error": event.get("is_error", False),
|
"is_error": event.get("is_error", False),
|
||||||
}
|
}
|
||||||
elif event.get("type") == "assistant" and "message" in event:
|
elif event_type == "assistant" and "message" in event:
|
||||||
# Extract text from content blocks
|
# Extract text from content blocks
|
||||||
msg = event["message"]
|
msg = event["message"]
|
||||||
if "content" in msg:
|
if "content" in msg:
|
||||||
@@ -438,14 +442,33 @@ class ClaudeCodeRuntime:
|
|||||||
if block.get("type") == "text":
|
if block.get("type") == "text":
|
||||||
text_parts.append(block.get("text", ""))
|
text_parts.append(block.get("text", ""))
|
||||||
session_id = event.get("session_id", session_id)
|
session_id = event.get("session_id", session_id)
|
||||||
|
# Silently skip non-content events (step_start, step_finish,
|
||||||
|
# system, tool_use, tool_result, etc.) — these are internal
|
||||||
|
# lifecycle events that should never reach the user.
|
||||||
except json.JSONDecodeError:
|
except json.JSONDecodeError:
|
||||||
|
# Not JSON — could be plain text mixed in; only include if
|
||||||
|
# it doesn't look like a truncated JSON blob.
|
||||||
|
if not line.startswith("{") and not line.startswith("["):
|
||||||
|
text_parts.append(line)
|
||||||
continue
|
continue
|
||||||
|
|
||||||
if text_parts:
|
if text_parts:
|
||||||
return "\n".join(text_parts), session_id, usage
|
return "\n".join(text_parts), session_id, usage
|
||||||
|
|
||||||
# Fallback: treat as plain text
|
# Fallback: treat as plain text, but strip any JSON-like lines
|
||||||
return stdout, None, None
|
# to prevent raw event objects from leaking to the user.
|
||||||
|
plain_lines = []
|
||||||
|
for line in stdout.splitlines():
|
||||||
|
stripped = line.strip()
|
||||||
|
if stripped and not stripped.startswith("{"):
|
||||||
|
plain_lines.append(line)
|
||||||
|
if plain_lines:
|
||||||
|
return "\n".join(plain_lines), None, None
|
||||||
|
|
||||||
|
# Everything was JSON events with no extractable text
|
||||||
|
logger.warning("Claude output contained only non-content JSON events")
|
||||||
|
return "", None, None
|
||||||
|
|
||||||
|
|
||||||
# -------------------------------------------------------------------
|
# -------------------------------------------------------------------
|
||||||
# Validation
|
# Validation
|
||||||
|
|||||||
@@ -695,8 +695,11 @@ class OpenCodeRuntime:
|
|||||||
# Parse the output — mirrors OpenClaw's parseCliJson/parseCliJsonl
|
# Parse the output — mirrors OpenClaw's parseCliJson/parseCliJsonl
|
||||||
response_text = self._parse_cli_output(stdout)
|
response_text = self._parse_cli_output(stdout)
|
||||||
|
|
||||||
if not response_text:
|
if not response_text and stdout.strip():
|
||||||
response_text = stdout # fallback to raw output
|
# Only fall back to raw output if it doesn't look like JSON events
|
||||||
|
# (which would leak internal lifecycle data to the user)
|
||||||
|
if not stdout.strip().startswith("{"):
|
||||||
|
response_text = stdout
|
||||||
|
|
||||||
# Extract session ID if returned
|
# Extract session ID if returned
|
||||||
session_id = self._extract_session_id(stdout)
|
session_id = self._extract_session_id(stdout)
|
||||||
@@ -792,12 +795,17 @@ class OpenCodeRuntime:
|
|||||||
{"type":"text", "sessionID":"ses_...", "part":{"type":"text","text":"Hello!"}}
|
{"type":"text", "sessionID":"ses_...", "part":{"type":"text","text":"Hello!"}}
|
||||||
{"type":"step_finish","sessionID":"ses_...", "part":{"type":"step-finish",...}}
|
{"type":"step_finish","sessionID":"ses_...", "part":{"type":"step-finish",...}}
|
||||||
|
|
||||||
We extract text from events where type == "text" and part.text exists.
|
We extract text ONLY from "text" type events. All other event types
|
||||||
|
(step_start, step_finish, tool_use, tool_result, etc.) are internal
|
||||||
|
lifecycle events and must never be shown to the user.
|
||||||
"""
|
"""
|
||||||
if not stdout.strip():
|
if not stdout.strip():
|
||||||
return ""
|
return ""
|
||||||
|
|
||||||
# Parse JSONL lines — collect text from "text" type events
|
# Track whether we found any JSON at all (to distinguish JSONL from plain text)
|
||||||
|
found_json = False
|
||||||
|
|
||||||
|
# Parse JSONL lines — collect text from "text" type events only
|
||||||
lines = stdout.strip().split("\n")
|
lines = stdout.strip().split("\n")
|
||||||
texts = []
|
texts = []
|
||||||
for line in lines:
|
for line in lines:
|
||||||
@@ -806,6 +814,7 @@ class OpenCodeRuntime:
|
|||||||
continue
|
continue
|
||||||
try:
|
try:
|
||||||
event = json.loads(line)
|
event = json.loads(line)
|
||||||
|
found_json = True
|
||||||
if not isinstance(event, dict):
|
if not isinstance(event, dict):
|
||||||
continue
|
continue
|
||||||
|
|
||||||
@@ -819,19 +828,47 @@ class OpenCodeRuntime:
|
|||||||
texts.append(text)
|
texts.append(text)
|
||||||
continue
|
continue
|
||||||
|
|
||||||
# Fallback: try generic text extraction (for non-OpenCode formats)
|
# Also handle "result" type events (Claude JSON format)
|
||||||
text = self._collect_text(event)
|
if event_type == "result":
|
||||||
if text:
|
text = event.get("result", "")
|
||||||
texts.append(text)
|
if text:
|
||||||
|
texts.append(text)
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Also handle "assistant" type events (Claude stream-json)
|
||||||
|
if event_type == "assistant" and "message" in event:
|
||||||
|
msg = event["message"]
|
||||||
|
if "content" in msg:
|
||||||
|
for block in msg["content"]:
|
||||||
|
if block.get("type") == "text":
|
||||||
|
t = block.get("text", "")
|
||||||
|
if t:
|
||||||
|
texts.append(t)
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Skip all other event types silently (step_start, step_finish,
|
||||||
|
# tool_use, tool_result, system, etc.)
|
||||||
|
|
||||||
except json.JSONDecodeError:
|
except json.JSONDecodeError:
|
||||||
# Not JSON — might be plain text output (--format default)
|
# Not JSON — might be plain text output (--format default)
|
||||||
texts.append(line)
|
# Only include if we haven't seen JSON yet (pure plain text mode)
|
||||||
|
if not found_json:
|
||||||
|
texts.append(line)
|
||||||
|
|
||||||
if texts:
|
if texts:
|
||||||
return "\n".join(texts)
|
return "\n".join(texts)
|
||||||
|
|
||||||
# Final fallback to raw text
|
# If we parsed JSON events but found no text, the response was
|
||||||
|
# purely tool-use with no user-facing text. Return empty rather
|
||||||
|
# than leaking raw JSON events.
|
||||||
|
if found_json:
|
||||||
|
logger.warning(
|
||||||
|
"OpenCode output contained only non-text JSON events "
|
||||||
|
"(no user-facing text found)"
|
||||||
|
)
|
||||||
|
return ""
|
||||||
|
|
||||||
|
# Final fallback for non-JSON output
|
||||||
return stdout.strip()
|
return stdout.strip()
|
||||||
|
|
||||||
def _collect_text(self, value: Any) -> str:
|
def _collect_text(self, value: Any) -> str:
|
||||||
|
|||||||
17
main.py
17
main.py
@@ -299,6 +299,18 @@ def ai_handler(msg: IncomingMessage) -> str:
|
|||||||
# Parse and execute action tags (reminders, cron, spawn)
|
# Parse and execute action tags (reminders, cron, spawn)
|
||||||
reply_text = _process_action_tags(response.text, msg)
|
reply_text = _process_action_tags(response.text, msg)
|
||||||
|
|
||||||
|
# If the AI may have created/modified skills (via file tools), reload them
|
||||||
|
# so that `skill list` reflects the changes immediately.
|
||||||
|
if _skills and any(
|
||||||
|
kw in text_lower
|
||||||
|
for kw in ("skill", "create a skill", "new skill", "add a skill", "make a skill")
|
||||||
|
):
|
||||||
|
try:
|
||||||
|
_skills.reload()
|
||||||
|
logger.info("Skills reloaded after potential skill modification")
|
||||||
|
except Exception as e:
|
||||||
|
logger.debug(f"Skills reload after AI response failed: {e}")
|
||||||
|
|
||||||
# Log conversation to memory session log
|
# Log conversation to memory session log
|
||||||
if _memory:
|
if _memory:
|
||||||
try:
|
try:
|
||||||
@@ -311,6 +323,11 @@ def ai_handler(msg: IncomingMessage) -> str:
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.debug(f"Session logging failed: {e}")
|
logger.debug(f"Session logging failed: {e}")
|
||||||
|
|
||||||
|
# Guard against empty responses (e.g. when AI did tool-only work
|
||||||
|
# and the parser correctly stripped all non-text events)
|
||||||
|
if not reply_text or not reply_text.strip():
|
||||||
|
return "✅ Done — I processed that but had no text to show."
|
||||||
|
|
||||||
return reply_text
|
return reply_text
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user