Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions raven/tui_rpc/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down
18 changes: 12 additions & 6 deletions raven/tui_rpc/spine.py
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down Expand Up @@ -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).
Expand Down
20 changes: 20 additions & 0 deletions tests/test_tui_rpc_spine.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
3 changes: 2 additions & 1 deletion ui-tui/rpc-schema/openrpc.json
Original file line number Diff line number Diff line change
Expand Up @@ -1646,7 +1646,8 @@
"reason": {
"type": "string",
"enum": ["cancelled_by_client", "internal"]
}
},
"detail": { "type": "string" }
}
}
}
Expand Down
22 changes: 22 additions & 0 deletions ui-tui/src/__tests__/chatStream.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
9 changes: 6 additions & 3 deletions ui-tui/src/app/chatStream.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)}` })
Expand Down
1 change: 1 addition & 0 deletions ui-tui/src/rpc/generated.ts
Original file line number Diff line number Diff line change
Expand Up @@ -359,6 +359,7 @@ export interface ErrorEvent {
code: number;
message: string;
reason?: 'cancelled_by_client' | 'internal';
detail?: string;
};
}
/**
Expand Down
Loading