From de9aff29c7a89d73d563c03ae09c4753cbe608f9 Mon Sep 17 00:00:00 2001 From: arelchan <1239372199@qq.com> Date: Wed, 22 Jul 2026 23:23:44 +0800 Subject: [PATCH] fix(tui): surface the real turn-failure detail, not just the code TurnFailed carries the underlying error (error=str(exc)), but the spine sink dropped it: emit_error was called with a hardcoded turn_failed / internal, so every failed turn -- missing dependency, provider error, node issue -- collapsed to the same opaque 'error: turn_failed (code=-32099)' with no way to tell them apart. Thread the failure detail through: add an optional detail field to the error event payload (openrpc schema + regenerated types + pydantic model), pass TurnFailed.error into emit_error, and append the first line of it to the tui error line. reason/code/message stay unchanged, so the cancelled-by-client path and existing consumers are unaffected. Closes #114 Co-Authored-By: Claude Opus 4.8 --- raven/tui_rpc/models.py | 1 + raven/tui_rpc/spine.py | 18 ++++++++++++------ tests/test_tui_rpc_spine.py | 20 ++++++++++++++++++++ ui-tui/rpc-schema/openrpc.json | 3 ++- ui-tui/src/__tests__/chatStream.test.ts | 22 ++++++++++++++++++++++ ui-tui/src/app/chatStream.ts | 9 ++++++--- ui-tui/src/rpc/generated.ts | 1 + 7 files changed, 64 insertions(+), 10 deletions(-) diff --git a/raven/tui_rpc/models.py b/raven/tui_rpc/models.py index f70a67b..1896a87 100644 --- a/raven/tui_rpc/models.py +++ b/raven/tui_rpc/models.py @@ -203,6 +203,7 @@ class ErrorEventPayload(_Strict): code: int message: str reason: Literal["cancelled_by_client", "internal"] | None = None + detail: str | None = None class ErrorEvent(_Strict): diff --git a/raven/tui_rpc/spine.py b/raven/tui_rpc/spine.py index afb84cd..9e9e288 100644 --- a/raven/tui_rpc/spine.py +++ b/raven/tui_rpc/spine.py @@ -182,11 +182,11 @@ async def emit_complete(self, conversation_id: str, turn_id: str | None, usage: {"type": "message.complete", "payload": {"turn_id": turn_id, "usage": usage}}, ) - async def emit_error(self, conversation_id: str, code: int, message: str, reason: str) -> None: - await self._emitter.emit( - conversation_id, - {"type": "error", "payload": {"code": code, "message": message, "reason": reason}}, - ) + async def emit_error(self, conversation_id: str, code: int, message: str, reason: str, detail: str = "") -> None: + payload: dict[str, Any] = {"code": code, "message": message, "reason": reason} + if detail: + payload["detail"] = detail + await self._emitter.emit(conversation_id, {"type": "error", "payload": payload}) def _make_tui_sink( @@ -237,7 +237,13 @@ async def sink(event: TurnEvent) -> None: # A cancelled turn's error is emitted by turn.cancel, not here, to # avoid a double error event. if not event.cancelled: - await outlet.emit_error(event.conversation_id, _TURN_FAILED_CODE, "turn_failed", "internal") + await outlet.emit_error( + event.conversation_id, + _TURN_FAILED_CODE, + "turn_failed", + "internal", + event.error or "", + ) return if isinstance(event, TurnStarted): # message.start is emitted by turn.send (it owns the turn_id). diff --git a/tests/test_tui_rpc_spine.py b/tests/test_tui_rpc_spine.py index 1e9c81f..0bbf4dd 100644 --- a/tests/test_tui_rpc_spine.py +++ b/tests/test_tui_rpc_spine.py @@ -244,6 +244,26 @@ async def test_outlet_emit_complete_and_error_shapes(): ] +async def test_outlet_emit_error_includes_detail_when_present(): + emitter = FakeEmitter() + outlet = TuiOutlet("tui", emitter) + await outlet.emit_error("tui:c1", -32099, "turn_failed", "internal", "No module named 'orjson'") + assert emitter.emitted == [ + ( + "tui:c1", + { + "type": "error", + "payload": { + "code": -32099, + "message": "turn_failed", + "reason": "internal", + "detail": "No module named 'orjson'", + }, + }, + ), + ] + + # --- build_tui: real Scheduler + DeliveryHub + TuiOutlet, only the edges faked --- # (faking the spine path would make the ordering / deadlock tests pass trivially; # message.complete-after-token and the empty-turn finalize only hold on the real diff --git a/ui-tui/rpc-schema/openrpc.json b/ui-tui/rpc-schema/openrpc.json index 6d3969c..790bab5 100644 --- a/ui-tui/rpc-schema/openrpc.json +++ b/ui-tui/rpc-schema/openrpc.json @@ -1646,7 +1646,8 @@ "reason": { "type": "string", "enum": ["cancelled_by_client", "internal"] - } + }, + "detail": { "type": "string" } } } } diff --git a/ui-tui/src/__tests__/chatStream.test.ts b/ui-tui/src/__tests__/chatStream.test.ts index a897fd8..554644e 100644 --- a/ui-tui/src/__tests__/chatStream.test.ts +++ b/ui-tui/src/__tests__/chatStream.test.ts @@ -312,6 +312,28 @@ describe('createChatStream — cancel preserves streamed content', () => { expect(assistant!.text).toContain('[interrupted]') }) + it('appends the failure detail to the error line when present', async () => { + const sysCalls: string[] = [] + const fake = makeFakeRpc() + const stream = createChatStream({ + appendMessage: () => {}, + rpcClient: fake, + sessionKey: 'tui:default', + sys: m => sysCalls.push(m) + }) + await stream.attach() + patchUiState({ busy: true }) + await stream.send('question') + + fake.__pushEvent({ type: 'message.start', payload: { turn_id: 'turn-1' } }) + fake.__pushEvent({ + type: 'error', + payload: { code: -32099, message: 'turn_failed', reason: 'internal', detail: "No module named 'orjson'" } + }) + + expect(sysCalls).toContain("error: turn_failed (code=-32099): No module named 'orjson'") + }) + it('keeps the streamed partial in the transcript on a local forceReset', async () => { const appended: Msg[] = [] const fake = makeFakeRpc() diff --git a/ui-tui/src/app/chatStream.ts b/ui-tui/src/app/chatStream.ts index 7f593b9..8070916 100644 --- a/ui-tui/src/app/chatStream.ts +++ b/ui-tui/src/app/chatStream.ts @@ -208,16 +208,19 @@ const onError = ( sys?: (msg: string) => void, appendMessage?: (msg: Msg) => void ): void => { - const { reason, message, code } = ev.payload + const { reason, message, code, detail } = ev.payload state.turnId = null if (reason === 'cancelled_by_client') { restoreInputPrompt(appendMessage, sys) return } // Non-cancellation error: surface a sys note, idle the turn, and reset - // the live anchor so the user can submit again. + // the live anchor so the user can submit again. Append the real failure + // detail (e.g. the underlying exception) when present, so a generic + // `turn_failed` code is not the only thing the user sees. if (sys) { - sys(`error: ${message} (code=${code})`) + const extra = detail ? `: ${detail.split('\n')[0].slice(0, 200)}` : '' + sys(`error: ${message} (code=${code})${extra}`) } turnController.recordError() patchUiState({ busy: false, status: `error: ${message.slice(0, 80)}` }) diff --git a/ui-tui/src/rpc/generated.ts b/ui-tui/src/rpc/generated.ts index 0969bf0..6e3a313 100644 --- a/ui-tui/src/rpc/generated.ts +++ b/ui-tui/src/rpc/generated.ts @@ -359,6 +359,7 @@ export interface ErrorEvent { code: number; message: string; reason?: 'cancelled_by_client' | 'internal'; + detail?: string; }; } /**