diff --git a/ui/litellm-dashboard/src/components/logs/detail/LogDetailContent.integration.test.tsx b/ui/litellm-dashboard/src/components/logs/detail/LogDetailContent.integration.test.tsx index 7f39a0d8fe5..7ed053bd478 100644 --- a/ui/litellm-dashboard/src/components/logs/detail/LogDetailContent.integration.test.tsx +++ b/ui/litellm-dashboard/src/components/logs/detail/LogDetailContent.integration.test.tsx @@ -1,4 +1,4 @@ -import { render, screen, within } from "@testing-library/react"; +import { fireEvent, render, screen, within } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import { describe, expect, it, vi } from "vitest"; import { GuardrailJumpLink, LogDetailContent } from "./LogDetailContent"; @@ -34,6 +34,33 @@ const createLogEntry = (overrides: Partial = {}): LogEntry => }) as LogEntry; describe("LogDetailContent", () => { + it("resets WebSocket pagination for a different log but preserves it when the same log refreshes", () => { + const response = { + results: Array.from({ length: 101 }, (_, index) => ({ + type: "response.completed", + response: { id: `resp_${index}`, output: [] }, + })), + }; + const log = createLogEntry({ request_id: "websocket-first", response }); + const { rerender } = render(); + fireEvent.click(screen.getByRole("button", { name: "Next turns" })); + expect(screen.getByText("Turns 51–100 of 101")).toBeInTheDocument(); + + rerender(); + expect(screen.getByText("Turns 51–100 of 101")).toBeInTheDocument(); + + rerender(); + expect(screen.getByText("Turns 1–50 of 101")).toBeInTheDocument(); + expect(screen.getByRole("button", { name: "Previous turns" })).toBeDisabled(); + expect(screen.queryByText("Turn 51 · Completed")).not.toBeInTheDocument(); + fireEvent.click(screen.getByRole("button", { name: "Next turns" })); + fireEvent.click(screen.getByRole("button", { name: "Next turns" })); + expect(screen.getByText("Turns 101–101 of 101")).toBeInTheDocument(); + + rerender(); + expect(screen.getByText("Turns 1–50 of 101")).toBeInTheDocument(); + }); + it("should render the component successfully", () => { render(); diff --git a/ui/litellm-dashboard/src/components/logs/detail/LogDetailContent.tsx b/ui/litellm-dashboard/src/components/logs/detail/LogDetailContent.tsx index f6c63582938..b2a2433dbaa 100644 --- a/ui/litellm-dashboard/src/components/logs/detail/LogDetailContent.tsx +++ b/ui/litellm-dashboard/src/components/logs/detail/LogDetailContent.tsx @@ -674,6 +674,7 @@ function RequestResponseSection({
{ + it("bounds rendered WebSocket turns and lets operators navigate all pages", () => { + const response = Array.from({ length: 101 }, (_, index) => ({ + type: "response.completed", + response: { id: `resp_${index}`, output: [] }, + })); + render(); + expect(screen.getAllByRole("region", { name: /^Turn / })).toHaveLength(50); + expect(screen.getByText("Turns 1–50 of 101")).toBeInTheDocument(); + expect(screen.getByRole("button", { name: "Previous turns" })).toBeDisabled(); + fireEvent.click(screen.getByRole("button", { name: "Next turns" })); + expect(screen.queryByText("Turn 1 · Completed")).not.toBeInTheDocument(); + expect(screen.getByText("Turn 51 · Completed")).toBeInTheDocument(); + expect(screen.getAllByRole("region", { name: /^Turn / })).toHaveLength(50); + fireEvent.click(screen.getByRole("button", { name: "Next turns" })); + expect(screen.getByText("Turns 101–101 of 101")).toBeInTheDocument(); + expect(screen.getAllByRole("region", { name: /^Turn / })).toHaveLength(1); + expect(screen.getByRole("button", { name: "Next turns" })).toBeDisabled(); + fireEvent.click(screen.getByRole("button", { name: "Previous turns" })); + expect(screen.getByText("Turn 51 · Completed")).toBeInTheDocument(); + }); + + 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( + , + ); + 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( + , + ); + 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" }], + }; + const response = { + choices: [{ message: { role: "assistant", content: "Hi there!" } }], + }; + + render(); + expect(screen.getByText("Hello")).toBeInTheDocument(); + expect(screen.getByText("Hi there!")).toBeInTheDocument(); + }); + + it("renders input when request is a bare messages array (cold storage payload)", () => { + const request = [{ role: "user", content: "Write me a poem" }]; + const response = { + choices: [{ message: { role: "assistant", content: "A quiet moment." } }], + }; + + render(); + expect(screen.getByText("Write me a poem")).toBeInTheDocument(); + expect(screen.getByText("A quiet moment.")).toBeInTheDocument(); + }); + + it("should render the realtime pretty view for realtime API responses", () => { + const request = {}; + const response = { + results: [ + { + type: "session.created", + session: { + id: "sess_123", + model: "gpt-4o-mini-realtime-preview", + voice: "alloy", + modalities: ["audio", "text"], + }, + }, + { + type: "response.done", + response: { + id: "resp_1", + status: "completed", + output: [ + { + id: "item_1", + role: "assistant", + type: "message", + content: [{ type: "audio", transcript: "Hello from realtime!" }], + }, + ], + }, + }, + ], + }; + + render(); + expect(screen.getByText("Session")).toBeInTheDocument(); + expect(screen.getByText("Hello from realtime!")).toBeInTheDocument(); + const modelElements = screen.getAllByText("gpt-4o-mini-realtime-preview"); + expect(modelElements.length).toBeGreaterThanOrEqual(1); + }); + + it("renders a Responses API log, whose body uses input/output instead of messages/choices", () => { + const request = { + model: "gpt-5.6", + input: [{ role: "user", content: "Reply with exactly: hello from responses api" }], + }; + const response = { + output: [ + { + id: "msg_070989277645d4ae", + role: "assistant", + type: "message", + status: "completed", + content: [{ text: "hello from responses api", type: "output_text", annotations: [] }], + }, + ], + }; + + render(); + expect(screen.getByText("Reply with exactly: hello from responses api")).toBeInTheDocument(); + expect(screen.getByText("hello from responses api")).toBeInTheDocument(); + expect(screen.queryByText("No response data available")).not.toBeInTheDocument(); + }); + + it("renders a Responses API tool call, whose output item is a function_call", () => { + const request = { + model: "gpt-5.6", + input: [{ role: "user", content: "What is the weather in San Francisco? Use the tool." }], + }; + const response = { + output: [ + { + id: "fc_08edf6c2312f1485", + name: "get_weather", + type: "function_call", + status: "completed", + call_id: "call_AtO0J9eNy5jgECXzBicMJM8W", + arguments: '{"city":"San Francisco"}', + }, + ], + }; + + render(); + expect(screen.getByText("What is the weather in San Francisco? Use the tool.")).toBeInTheDocument(); + expect(screen.getByText("get_weather")).toBeInTheDocument(); + expect(screen.queryByText("No response data available")).not.toBeInTheDocument(); + }); + + it("renders instructions as the system turn and a bare string input", () => { + const request = { model: "gpt-5.6", instructions: "You are terse.", input: "Say A" }; + const response = { + output: [{ type: "message", role: "assistant", content: [{ type: "output_text", text: "A" }] }], + }; + + render(); + expect(screen.getByText("You are terse.")).toBeInTheDocument(); + expect(screen.getByText("Say A")).toBeInTheDocument(); + expect(screen.getByText("A")).toBeInTheDocument(); + }); + + it("skips reasoning output items rather than rendering them as empty turns", () => { + const request = { input: [{ role: "user", content: "Think then answer" }] }; + const response = { + output: [ + { type: "reasoning", id: "rs_1", summary: [] }, + { type: "message", role: "assistant", content: [{ type: "output_text", text: "answered" }] }, + ], + }; + + render(); + expect(screen.getByText("answered")).toBeInTheDocument(); + expect(screen.queryByText("No response data available")).not.toBeInTheDocument(); + }); + + it("renders a Responses API follow-up turn carrying a prior function_call and its output", () => { + const request = { + input: [ + { role: "user", content: "What is the weather in San Francisco? Use the tool." }, + { + type: "function_call", + name: "get_weather", + call_id: "call_AtO0J9eNy5jgECXzBicMJM8W", + arguments: '{"city":"San Francisco"}', + }, + { type: "function_call_output", call_id: "call_AtO0J9eNy5jgECXzBicMJM8W", output: '{"temp":18}' }, + ], + }; + const response = { + output: [{ type: "message", role: "assistant", content: [{ type: "output_text", text: "It is 18 degrees." }] }], + }; + + render(); + expect(screen.getByText("It is 18 degrees.")).toBeInTheDocument(); + expect(screen.getByText('{"temp":18}')).toBeInTheDocument(); + expect(screen.getByText("TOOL")).toBeInTheDocument(); + }); + + it("maps the developer and legacy function roles onto the roles the drawer renders", () => { + const request = { + messages: [ + { role: "developer", content: "Stay terse." }, + { role: "user", content: "Weather?" }, + { role: "function", name: "get_weather", content: '{"temp":18}' }, + ], + }; + const response = { choices: [{ message: { role: "assistant", content: "18 degrees." } }] }; + + render(); + expect(screen.getByText("Stay terse.")).toBeInTheDocument(); + expect(screen.getByText("TOOL")).toBeInTheDocument(); + expect(screen.queryByText("FUNCTION")).not.toBeInTheDocument(); + }); + + it("still reports missing output when a Responses API log has an empty output array", () => { + const request = { input: [{ role: "user", content: "Hello" }] }; + + render(); + expect(screen.getByText("Hello")).toBeInTheDocument(); + expect(screen.getByText("No response data available")).toBeInTheDocument(); + }); + + it("should render standard view when response has results but no realtime events", () => { + const request = { + messages: [{ role: "user", content: "Test" }], + }; + const response = { + results: [{ type: "some.other.type" }], + choices: [{ message: { role: "assistant", content: "Reply" } }], + }; + + render(); + expect(screen.getByText("Test")).toBeInTheDocument(); + expect(screen.getByText("Reply")).toBeInTheDocument(); + }); +}); diff --git a/ui/litellm-dashboard/src/components/logs/detail/PrettyMessagesView.test.tsx b/ui/litellm-dashboard/src/components/logs/detail/PrettyMessagesView.test.tsx index 807aaeeae00..7282aa4d666 100644 --- a/ui/litellm-dashboard/src/components/logs/detail/PrettyMessagesView.test.tsx +++ b/ui/litellm-dashboard/src/components/logs/detail/PrettyMessagesView.test.tsx @@ -1,9 +1,84 @@ import React from "react"; -import { render, screen } from "@testing-library/react"; +import { fireEvent, render, screen } from "@testing-library/react"; import { describe, it, expect } from "vitest"; import { PrettyMessagesView } from "./PrettyMessagesView"; describe("PrettyMessagesView", () => { + it("bounds rendered WebSocket turns and lets operators navigate all pages", () => { + const response = Array.from({ length: 101 }, (_, index) => ({ + type: "response.completed", + response: { id: `resp_${index}`, output: [] }, + })); + render(); + expect(screen.getAllByRole("region", { name: /^Turn / })).toHaveLength(50); + expect(screen.getByText("Turns 1–50 of 101")).toBeInTheDocument(); + expect(screen.getByRole("button", { name: "Previous turns" })).toBeDisabled(); + fireEvent.click(screen.getByRole("button", { name: "Next turns" })); + expect(screen.queryByText("Turn 1 · Completed")).not.toBeInTheDocument(); + expect(screen.getByText("Turn 51 · Completed")).toBeInTheDocument(); + expect(screen.getAllByRole("region", { name: /^Turn / })).toHaveLength(50); + fireEvent.click(screen.getByRole("button", { name: "Next turns" })); + expect(screen.getByText("Turns 101–101 of 101")).toBeInTheDocument(); + expect(screen.getAllByRole("region", { name: /^Turn / })).toHaveLength(1); + expect(screen.getByRole("button", { name: "Next turns" })).toBeDisabled(); + fireEvent.click(screen.getByRole("button", { name: "Previous turns" })); + expect(screen.getByText("Turn 51 · Completed")).toBeInTheDocument(); + }); + + 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( + , + ); + 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( + , + ); + 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" }], diff --git a/ui/litellm-dashboard/src/components/logs/detail/PrettyMessagesView.tsx b/ui/litellm-dashboard/src/components/logs/detail/PrettyMessagesView.tsx index 7d7e1bca6e8..ea38ea51d38 100644 --- a/ui/litellm-dashboard/src/components/logs/detail/PrettyMessagesView.tsx +++ b/ui/litellm-dashboard/src/components/logs/detail/PrettyMessagesView.tsx @@ -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 (
@@ -33,11 +35,19 @@ export function PrettyMessagesView({ request, response, metrics }: PrettyMessage {/* Output Card */} - + {turns !== null ? ( + + ) : ( + + )}
); } diff --git a/ui/litellm-dashboard/src/components/logs/detail/ResponsesWebSocketPrettyView.tsx b/ui/litellm-dashboard/src/components/logs/detail/ResponsesWebSocketPrettyView.tsx new file mode 100644 index 00000000000..4035e6204f6 --- /dev/null +++ b/ui/litellm-dashboard/src/components/logs/detail/ResponsesWebSocketPrettyView.tsx @@ -0,0 +1,87 @@ +import { useState } from "react"; +import { toast } from "@/lib/toast"; +import { ResponsesWebSocketTurn } from "./prettyMessagesTypes"; +import { SectionHeader } from "./SectionHeader"; +import { SimpleMessageBlock } from "./SimpleMessageBlock"; +import { Button } from "@/components/ui/button"; + +const TURNS_PER_PAGE = 50; + +interface ResponsesWebSocketPrettyViewProps { + turns: readonly ResponsesWebSocketTurn[]; + completionTokens?: number; + outputCost?: number; +} + +export function ResponsesWebSocketPrettyView({ + turns, + completionTokens, + outputCost, +}: ResponsesWebSocketPrettyViewProps) { + const [isCollapsed, setIsCollapsed] = useState(false); + const [page, setPage] = useState(0); + const lastPage = Math.max(0, Math.ceil(turns.length / TURNS_PER_PAGE) - 1); + const currentPage = Math.min(page, lastPage); + const start = currentPage * TURNS_PER_PAGE; + const end = Math.min(start + TURNS_PER_PAGE, turns.length); + const copyOutput = async () => { + await navigator.clipboard.writeText(JSON.stringify(turns, null, 2)); + toast.success("Output copied"); + }; + return ( +
+ setIsCollapsed(!isCollapsed)} + /> + {!isCollapsed && ( +
+ {turns.slice(start, end).map((turn, index) => ( +
+

+ Turn {start + index + 1} · {turn.status} +

+ {turn.id &&
{turn.id}
} + {turn.detail &&

{turn.detail}

} + {turn.message ? ( + + ) : ( + No output recorded for this turn + )} +
+ ))} + {turns.length > TURNS_PER_PAGE && ( + + )} +
+ )} +
+ ); +} diff --git a/ui/litellm-dashboard/src/components/logs/detail/prettyMessagesTypes.ts b/ui/litellm-dashboard/src/components/logs/detail/prettyMessagesTypes.ts index 10f6cbe865d..47778e259b5 100644 --- a/ui/litellm-dashboard/src/components/logs/detail/prettyMessagesTypes.ts +++ b/ui/litellm-dashboard/src/components/logs/detail/prettyMessagesTypes.ts @@ -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; +} diff --git a/ui/litellm-dashboard/src/components/logs/detail/prettyMessagesUtils.test.ts b/ui/litellm-dashboard/src/components/logs/detail/prettyMessagesUtils.test.ts new file mode 100644 index 00000000000..e7881a6f5c8 --- /dev/null +++ b/ui/litellm-dashboard/src/components/logs/detail/prettyMessagesUtils.test.ts @@ -0,0 +1,86 @@ +import { describe, expect, it } from "vitest"; +import { parseResponsesWebSocketTurns } from "./prettyMessagesUtils"; + +describe("parseResponsesWebSocketTurns", () => { + it("reads event types a bounded number of times for a large session", () => { + const reads = { count: 0 }; + const events = Array.from({ length: 2000 }, (_, index) => + ["response.created", "response.completed", "response.completed"].map((type) => ({ + get type() { + reads.count += 1; + return type; + }, + response: { id: `resp_${index}`, output: [] }, + })), + ).flat(); + const turns = parseResponsesWebSocketTurns(events); + expect(turns).toHaveLength(2000); + expect(turns?.[1999].id).toBe("resp_1999"); + expect(reads.count).toBeLessThan(events.length * 20); + }); + + 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: "" }, + ]); + }); +}); diff --git a/ui/litellm-dashboard/src/components/logs/detail/prettyMessagesUtils.ts b/ui/litellm-dashboard/src/components/logs/detail/prettyMessagesUtils.ts index 1f4289e7c2f..a01382516c2 100644 --- a/ui/litellm-dashboard/src/components/logs/detail/prettyMessagesUtils.ts +++ b/ui/litellm-dashboard/src/components/logs/detail/prettyMessagesUtils.ts @@ -8,6 +8,7 @@ import { ParsedMessages, RequestPayload, ResponsePayload, + ResponsesWebSocketTurn, ToolCall, } from "./prettyMessagesTypes"; @@ -52,6 +53,57 @@ 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> = { + "response.completed": "Completed", + "response.incomplete": "Incomplete", + "response.failed": "Failed", + }; + const terminalIds = new Set( + events.flatMap((event) => + terminalTypes.includes(asString(event.type)) && isRecord(event.response) ? [asString(event.response.id)] : [], + ), + ); + const seenTerminalIds = new Set(); + return events.flatMap((event): ResponsesWebSocketTurn[] => { + const body = isRecord(event.response) ? event.response : {}; + const id = asString(body.id); + if (event.type === "response.created") { + const hasTerminal = terminalIds.has(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 && seenTerminalIds.has(id)) return []; + if (id) seenTerminalIds.add(id); + 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":