fix(tui): render final event error when assistant output is empty (#14687)

This commit is contained in:
Vignesh Natarajan
2026-03-05 18:16:24 -08:00
parent 94fdee2eac
commit 6084c26d00
7 changed files with 56 additions and 2 deletions

View File

@@ -31,6 +31,7 @@ Docs: https://docs.openclaw.ai
- Gateway/chat streaming tool-boundary text retention: merge assistant delta segments into per-run chat buffers so pre-tool text is preserved in live chat deltas/finals when providers emit post-tool assistant segments as non-prefix snapshots. (#36957) Thanks @Datyedyeguy.
- TUI/model indicator freshness: prevent stale session snapshots from overwriting freshly patched model selection (and reset per-session freshness when switching session keys) so `/model` updates reflect immediately instead of lagging by one or more commands. (#21255) Thanks @kowza.
- TUI/final-error rendering fallback: when a chat `final` event has no renderable assistant content but includes envelope `errorMessage`, render the formatted error text instead of collapsing to `"(no output)"`, preserving actionable failure context in-session. (#14687) Thanks @Mquarmoc.
- OpenAI Codex OAuth/login hardening: fail OAuth completion early when the returned token is missing `api.responses.write`, and allow `openclaw models auth login --provider openai-codex` to use the built-in OAuth path even when no provider plugins are installed. (#36660) Thanks @driesvints.
- OpenAI Codex OAuth/scope request parity: augment the OAuth authorize URL with required API scopes (`api.responses.write`, `model.request`, `api.model.read`) before browser handoff so OAuth tokens include runtime model/request permissions expected by OpenAI API calls. (#24720) Thanks @Skippy-Gunboat.
- Gateway/remote WS break-glass hostname support: honor `OPENCLAW_ALLOW_INSECURE_PRIVATE_WS=1` for `ws://` hostname URLs (not only private IP literals) across onboarding validation and runtime gateway connection checks, while still rejecting public IP literals and non-unicast IPv6 endpoints. (#36930) Thanks @manju-rn.

View File

@@ -403,6 +403,26 @@ describe("tui-event-handlers: handleAgentEvent", () => {
expect(state.activeChatRunId).toBe("run-active");
});
it("renders final error text when chat final has no content but includes event errorMessage", () => {
const { state, chatLog, handleChatEvent } = createHandlersHarness({
state: { activeChatRunId: null },
});
handleChatEvent({
runId: "run-error-envelope",
sessionKey: state.currentSessionKey,
state: "final",
message: { content: [] },
errorMessage: '401 {"error":{"message":"Missing scopes: model.request"}}',
});
expect(chatLog.finalizeAssistant).toHaveBeenCalledTimes(1);
const [rendered] = chatLog.finalizeAssistant.mock.calls[0] ?? [];
expect(String(rendered)).toContain("HTTP 401");
expect(String(rendered)).toContain("Missing scopes: model.request");
expect(chatLog.dropAssistant).not.toHaveBeenCalledWith("run-error-envelope");
});
it("drops streaming assistant when chat final has no message", () => {
const { state, chatLog, handleChatEvent } = createHandlersHarness({
state: { activeChatRunId: null },

View File

@@ -202,7 +202,12 @@ export function createEventHandlers(context: EventHandlerContext) {
: ""
: "";
const finalText = streamAssembler.finalize(evt.runId, evt.message, state.showThinking);
const finalText = streamAssembler.finalize(
evt.runId,
evt.message,
state.showThinking,
evt.errorMessage,
);
const suppressEmptyExternalPlaceholder =
finalText === "(no output)" && !isLocalRunId?.(evt.runId);
if (suppressEmptyExternalPlaceholder) {

View File

@@ -142,6 +142,7 @@ export function sanitizeRenderableText(text: string): string {
export function resolveFinalAssistantText(params: {
finalText?: string | null;
streamedText?: string | null;
errorMessage?: string | null;
}) {
const finalText = params.finalText ?? "";
if (finalText.trim()) {
@@ -151,6 +152,10 @@ export function resolveFinalAssistantText(params: {
if (streamedText.trim()) {
return streamedText;
}
const errorMessage = params.errorMessage ?? "";
if (errorMessage.trim()) {
return formatRawAssistantErrorForUi(errorMessage);
}
return "(no output)";
}

View File

@@ -86,6 +86,18 @@ describe("TuiStreamAssembler", () => {
expect(finalText).toBe("Streamed");
});
it("falls back to event error message when final payload has no renderable text", () => {
const assembler = new TuiStreamAssembler();
const finalText = assembler.finalize(
"run-3-error",
{ role: "assistant", content: [] },
false,
'401 {"error":{"message":"Missing scopes: model.request"}}',
);
expect(finalText).toContain("HTTP 401");
expect(finalText).toContain("Missing scopes: model.request");
});
it("returns null when delta text is unchanged", () => {
const assembler = new TuiStreamAssembler();
const first = assembler.ingestDelta("run-4", messageWithContent([text("Repeat")]), false);

View File

@@ -174,7 +174,7 @@ export class TuiStreamAssembler {
return state.displayText;
}
finalize(runId: string, message: unknown, showThinking: boolean): string {
finalize(runId: string, message: unknown, showThinking: boolean, errorMessage?: string): string {
const state = this.getOrCreateRun(runId);
const streamedDisplayText = state.displayText;
const streamedTextBlocks = [...state.contentBlocks];
@@ -192,6 +192,7 @@ export class TuiStreamAssembler {
const finalText = resolveFinalAssistantText({
finalText: shouldKeepStreamedText ? streamedDisplayText : finalComposed,
streamedText: streamedDisplayText,
errorMessage,
});
this.runs.delete(runId);

View File

@@ -23,6 +23,16 @@ describe("resolveFinalAssistantText", () => {
}),
).toBe("All done");
});
it("falls back to formatted error text when final and streamed text are empty", () => {
expect(
resolveFinalAssistantText({
finalText: "",
streamedText: "",
errorMessage: '401 {"error":{"message":"Missing scopes: model.request"}}',
}),
).toContain("HTTP 401");
});
});
describe("tui slash commands", () => {