Problem
When an agent turn fails with a RuntimeError, its text is sent to the client twice — as the message_rejected ack and as an error event — and the frontend renders both verbatim:
src/xagent/web/api/websocket.py: message = f"Runtime error: {str(e)}" in _handle_chat_message_unserialized and in handle_execute_task.
frontend/src/contexts/app-context-chat.tsx:882 reads the string unchanged and :5578 renders it as an assistant error bubble.
The same handler serves widget and share connections, so an anonymous visitor can receive whatever a runtime failure happened to interpolate — provider payloads, file paths, driver messages.
Why this is not just a bug fix
Surfacing that text is a deliberate, tested contract: tests/web/api/test_websocket_owner_actor.py:2260 asserts "inject failed" in rejected[0]["message"], i.e. an agent post_user_message failure must reach the sender. For an authenticated operator debugging their own task, that is useful. For an anonymous widget visitor, it is disclosure.
So the fix is not "redact it" — it is deciding who may see it, which is a product call:
- redact for public widget/share connections and keep the current text for authenticated owners, or
- introduce a
safe_for_display / stable error-code field on the wire and localize codes on the client, keeping raw text out of every path.
Already done
#1472 fixed the neighbouring half: incidental ValueError/KeyError/TypeError text is now replaced with CLIENT_SAFE_VALIDATION_ERROR, while messages genuinely written for the sender raise ClientVisibleValidationError and keep their wording (tests/web/api/test_websocket_client_safe_errors.py). The RuntimeError path was left untouched on purpose, because changing it means changing the pinned contract above.
Context
Raised as finding N3 in the #1472 review. Related: #1468, #1467.
Problem
When an agent turn fails with a
RuntimeError, its text is sent to the client twice — as themessage_rejectedack and as anerrorevent — and the frontend renders both verbatim:src/xagent/web/api/websocket.py:message = f"Runtime error: {str(e)}"in_handle_chat_message_unserializedand inhandle_execute_task.frontend/src/contexts/app-context-chat.tsx:882reads the string unchanged and:5578renders it as an assistant error bubble.The same handler serves widget and share connections, so an anonymous visitor can receive whatever a runtime failure happened to interpolate — provider payloads, file paths, driver messages.
Why this is not just a bug fix
Surfacing that text is a deliberate, tested contract:
tests/web/api/test_websocket_owner_actor.py:2260asserts"inject failed" in rejected[0]["message"], i.e. an agentpost_user_messagefailure must reach the sender. For an authenticated operator debugging their own task, that is useful. For an anonymous widget visitor, it is disclosure.So the fix is not "redact it" — it is deciding who may see it, which is a product call:
safe_for_display/ stable error-code field on the wire and localize codes on the client, keeping raw text out of every path.Already done
#1472 fixed the neighbouring half: incidental
ValueError/KeyError/TypeErrortext is now replaced withCLIENT_SAFE_VALIDATION_ERROR, while messages genuinely written for the sender raiseClientVisibleValidationErrorand keep their wording (tests/web/api/test_websocket_client_safe_errors.py). TheRuntimeErrorpath was left untouched on purpose, because changing it means changing the pinned contract above.Context
Raised as finding N3 in the #1472 review. Related: #1468, #1467.