fix(ui): render native Responses WebSocket log output

This commit is contained in:
Brennan Butler 2026-09-20 08:34:28 +07:00
parent 93e39d5042
commit 74119b7c8f
6 changed files with 268 additions and 8 deletions

View file

@ -4,6 +4,60 @@ import { describe, it, expect } from "vitest";
import { PrettyMessagesView } from "./PrettyMessagesView";
describe("PrettyMessagesView", () => {
it.each([false, true])("renders separate WebSocket turns (wrapped: %s)", (wrapped) => {
const events = [
{ type: "response.created", response: { id: "resp_1", output: [] } },
{ type: "response.output_text.delta", delta: "First answer" },
{
type: "response.completed",
response: {
id: "resp_1",
output: [{ type: "message", content: [{ type: "output_text", text: "First answer" }] }],
},
},
{
type: "response.incomplete",
response: {
id: "resp_2",
incomplete_details: { reason: "max_output_tokens" },
output: [{ type: "function_call", call_id: "call_1", name: "get_weather", arguments: '{"city":"Hanoi"}' }],
},
},
];
render(
<PrettyMessagesView
request={{ input: "Check the weather" }}
response={wrapped ? { results: events, usage: {} } : events}
metrics={{ completion_tokens: 42, output_cost: 0.001 }}
/>,
);
expect(screen.getByText("Check the weather")).toBeInTheDocument();
expect(screen.getAllByText("First answer")).toHaveLength(1);
expect(screen.getByText("Turn 1 · Completed")).toBeInTheDocument();
expect(screen.getByText("Turn 2 · Incomplete")).toBeInTheDocument();
expect(screen.getByText("max_output_tokens")).toBeInTheDocument();
expect(screen.getByText("get_weather")).toBeInTheDocument();
expect(screen.getAllByText("Tokens: 42")).toHaveLength(1);
expect(screen.getAllByText("Cost: $0.001000")).toHaveLength(1);
});
it("shows failed and unfinished WebSocket turns without inventing output", () => {
render(
<PrettyMessagesView
request={{}}
response={{
results: [
{ type: "response.failed", response: { id: "resp_failed", error: { message: "Provider unavailable" } } },
{ type: "response.created", response: { id: "resp_unfinished" } },
],
}}
/>,
);
expect(screen.getByText("Turn 1 · Failed")).toBeInTheDocument();
expect(screen.getByText("Provider unavailable")).toBeInTheDocument();
expect(screen.getByText("Turn 2 · No terminal event recorded")).toBeInTheDocument();
});
it("should render the component for standard chat completions", () => {
const request = {
messages: [{ role: "user", content: "Hello" }],

View file

@ -4,14 +4,15 @@
* Detects realtime API responses and renders a specialized view.
*/
import { parseMessages } from "./prettyMessagesUtils";
import { parseMessages, parseResponsesWebSocketTurns } from "./prettyMessagesUtils";
import { ResponsesWebSocketPrettyView } from "./ResponsesWebSocketPrettyView";
import { InputCard } from "./InputCard";
import { OutputCard } from "./OutputCard";
import { isRealtimeResponse, RealtimePrettyView } from "./RealtimePrettyView";
interface PrettyMessagesViewProps {
request: any;
response: any;
request: unknown;
response: unknown;
metrics?: {
prompt_tokens?: number;
completion_tokens?: number;
@ -26,6 +27,7 @@ export function PrettyMessagesView({ request, response, metrics }: PrettyMessage
}
const { requestMessages, responseMessage } = parseMessages(request, response);
const turns = parseResponsesWebSocketTurns(response);
return (
<div>
@ -33,11 +35,19 @@ export function PrettyMessagesView({ request, response, metrics }: PrettyMessage
<InputCard messages={requestMessages} promptTokens={metrics?.prompt_tokens} inputCost={metrics?.input_cost} />
{/* Output Card */}
<OutputCard
message={responseMessage}
completionTokens={metrics?.completion_tokens}
outputCost={metrics?.output_cost}
/>
{turns !== null ? (
<ResponsesWebSocketPrettyView
turns={turns}
completionTokens={metrics?.completion_tokens}
outputCost={metrics?.output_cost}
/>
) : (
<OutputCard
message={responseMessage}
completionTokens={metrics?.completion_tokens}
outputCost={metrics?.output_cost}
/>
)}
</div>
);
}

View file

@ -0,0 +1,61 @@
import { useState } from "react";
import { toast } from "@/lib/toast";
import { ResponsesWebSocketTurn } from "./prettyMessagesTypes";
import { SectionHeader } from "./SectionHeader";
import { SimpleMessageBlock } from "./SimpleMessageBlock";
interface ResponsesWebSocketPrettyViewProps {
turns: readonly ResponsesWebSocketTurn[];
completionTokens?: number;
outputCost?: number;
}
export function ResponsesWebSocketPrettyView({
turns,
completionTokens,
outputCost,
}: ResponsesWebSocketPrettyViewProps) {
const [isCollapsed, setIsCollapsed] = useState(false);
const copyOutput = async () => {
await navigator.clipboard.writeText(JSON.stringify(turns, null, 2));
toast.success("Output copied");
};
return (
<div className="overflow-hidden rounded-md border border-border">
<SectionHeader
type="output"
tokens={completionTokens}
cost={outputCost}
onCopy={copyOutput}
isCollapsed={isCollapsed}
onToggleCollapse={() => setIsCollapsed(!isCollapsed)}
/>
{!isCollapsed && (
<div className="divide-y divide-border">
{turns.map((turn, index) => (
<section
key={`${turn.id}-${index}`}
className="px-4 py-3"
aria-label={`Turn ${index + 1} · ${turn.status}`}
>
<h4 className="mb-2 text-sm font-medium">
Turn {index + 1} · {turn.status}
</h4>
{turn.id && <div className="mb-2 break-all text-xs text-muted-foreground">{turn.id}</div>}
{turn.detail && <p className="mb-2 whitespace-pre-wrap text-sm">{turn.detail}</p>}
{turn.message ? (
<SimpleMessageBlock
label="ASSISTANT"
content={turn.message.content}
toolCalls={turn.message.toolCalls}
/>
) : (
<span className="text-[13px] italic text-muted-foreground">No output recorded for this turn</span>
)}
</section>
))}
</div>
)}
</div>
);
}

View file

@ -31,3 +31,10 @@ export interface ParsedMessages {
requestMessages: ParsedMessage[];
responseMessage: ParsedMessage | null;
}
export interface ResponsesWebSocketTurn {
id: string;
status: "Completed" | "Incomplete" | "Failed" | "Error" | "No terminal event recorded";
message: ParsedMessage | null;
detail: string;
}

View file

@ -0,0 +1,69 @@
import { describe, expect, it } from "vitest";
import { parseResponsesWebSocketTurns } from "./prettyMessagesUtils";
describe("parseResponsesWebSocketTurns", () => {
it.each([
null,
{},
[],
{ results: null },
{ choices: [] },
{ output: [] },
{ results: [{ type: "response.done" }] },
{ results: [null, "invalid", 42] },
])("leaves non-Responses payloads to their existing renderer: %j", (payload) => {
expect(parseResponsesWebSocketTurns(payload)).toBeNull();
});
it("uses terminal output once, skipping deltas, created snapshots, and duplicate terminal IDs", () => {
const response = {
id: "resp_1",
output: [{ type: "message", content: [{ type: "output_text", text: "Final text" }] }],
};
expect(
parseResponsesWebSocketTurns([
null,
{ type: "response.created", response },
{ type: "response.output_text.delta", delta: "Final text" },
{ type: "response.completed", response },
{ type: "response.completed", response },
]),
).toEqual([
{
id: "resp_1",
status: "Completed",
detail: "",
message: { role: "assistant", content: "Final text", toolCalls: undefined },
},
]);
});
it("keeps failed output and errors inspectable alongside an unfinished turn", () => {
const turns = parseResponsesWebSocketTurns({
results: [
{
type: "response.failed",
response: {
id: "failed",
error: { code: "server_error" },
output: [{ type: "message", content: [{ type: "output_text", text: "Partial answer" }] }],
},
},
{ type: "response.created", response: { id: "pending" } },
{ type: "error", error: { message: "Connection error" } },
],
});
expect(turns?.map(({ status, detail }) => ({ status, detail }))).toEqual([
{ status: "Failed", detail: "server_error" },
{ status: "No terminal event recorded", detail: "" },
{ status: "Error", detail: "Connection error" },
]);
expect(turns?.[0].message?.content).toBe("Partial answer");
});
it("does not invent output for malformed terminal payloads", () => {
expect(parseResponsesWebSocketTurns([{ type: "response.completed", response: null }])).toEqual([
{ id: "", status: "Completed", message: null, detail: "" },
]);
});
});

View file

@ -8,6 +8,7 @@ import {
ParsedMessages,
RequestPayload,
ResponsePayload,
ResponsesWebSocketTurn,
ToolCall,
} from "./prettyMessagesTypes";
@ -52,6 +53,64 @@ export const parseMessages = (request: unknown, response: unknown): ParsedMessag
responseMessage: parseResponseMessage(classifyResponse(response)),
});
export const parseResponsesWebSocketTurns = (response: unknown): ResponsesWebSocketTurn[] | null => {
const results: unknown = isRecord(response) ? response.results : response;
if (!Array.isArray(results)) return null;
const events = results.filter(isRecord);
if (
!events.some((event) =>
["response.created", "response.completed", "response.incomplete", "response.failed"].includes(
asString(event.type),
),
)
)
return null;
const terminalTypes = ["response.completed", "response.incomplete", "response.failed"];
const terminalStatuses: Readonly<Record<string, ResponsesWebSocketTurn["status"]>> = {
"response.completed": "Completed",
"response.incomplete": "Incomplete",
"response.failed": "Failed",
};
return events.flatMap((event, index): ResponsesWebSocketTurn[] => {
const body = isRecord(event.response) ? event.response : {};
const id = asString(body.id);
if (event.type === "response.created") {
const hasTerminal = events.some(
(other) =>
terminalTypes.includes(asString(other.type)) &&
isRecord(other.response) &&
asString(other.response.id) === id,
);
return hasTerminal ? [] : [{ id, status: "No terminal event recorded", message: null, detail: "" }];
}
if (event.type === "error") {
const error = isRecord(event.error) ? event.error : event;
return [{ id: "", status: "Error", message: null, detail: asString(error.message) || asString(error.code) }];
}
if (!terminalTypes.includes(asString(event.type))) return [];
if (
id &&
events
.slice(0, index)
.some(
(other) =>
terminalTypes.includes(asString(other.type)) && isRecord(other.response) && other.response.id === id,
)
)
return [];
const error = isRecord(body.error) ? body.error : {};
const incomplete = isRecord(body.incomplete_details) ? body.incomplete_details : {};
return [
{
id,
status: terminalStatuses[asString(event.type)],
message: parseResponseMessage(classifyResponse(body)),
detail: asString(error.message) || asString(error.code) || asString(incomplete.reason),
},
];
});
};
const parseRequestMessages = (payload: RequestPayload): ParsedMessage[] => {
switch (payload.kind) {
case "chat":