mirror of
https://github.com/meta-llama/llama-stack.git
synced 2025-06-28 02:53:30 +00:00
feat(ui): add views for Responses (#2293)
# What does this PR do? * Add responses list and detail views * Refactored components to be shared as much as possible between chat completions and responses ## Test Plan <img width="2014" alt="image" src="https://github.com/user-attachments/assets/6dee12ea-8876-4351-a6eb-2338058466ef" /> <img width="2021" alt="image" src="https://github.com/user-attachments/assets/6c7c71b8-25b7-4199-9c57-6960be5580c8" /> added tests
This commit is contained in:
parent
6352078e4b
commit
56e5ddb39f
34 changed files with 3282 additions and 380 deletions
|
@ -0,0 +1,56 @@
|
|||
import { useFunctionCallGrouping } from "../hooks/function-call-grouping";
|
||||
import { ItemRenderer } from "../items/item-renderer";
|
||||
import { GroupedFunctionCallItemComponent } from "../items/grouped-function-call-item";
|
||||
import {
|
||||
isFunctionCallItem,
|
||||
isFunctionCallOutputItem,
|
||||
AnyResponseItem,
|
||||
} from "../utils/item-types";
|
||||
|
||||
interface GroupedItemsDisplayProps {
|
||||
items: AnyResponseItem[];
|
||||
keyPrefix: string;
|
||||
defaultRole?: string;
|
||||
}
|
||||
|
||||
export function GroupedItemsDisplay({
|
||||
items,
|
||||
keyPrefix,
|
||||
defaultRole = "unknown",
|
||||
}: GroupedItemsDisplayProps) {
|
||||
const groupedItems = useFunctionCallGrouping(items);
|
||||
|
||||
return (
|
||||
<>
|
||||
{groupedItems.map((groupedItem) => {
|
||||
// If this is a function call with an output, render the grouped component
|
||||
if (
|
||||
groupedItem.outputItem &&
|
||||
isFunctionCallItem(groupedItem.item) &&
|
||||
isFunctionCallOutputItem(groupedItem.outputItem)
|
||||
) {
|
||||
return (
|
||||
<GroupedFunctionCallItemComponent
|
||||
key={`${keyPrefix}-${groupedItem.index}`}
|
||||
functionCall={groupedItem.item}
|
||||
output={groupedItem.outputItem}
|
||||
index={groupedItem.index}
|
||||
keyPrefix={keyPrefix}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
// Otherwise, render the individual item
|
||||
return (
|
||||
<ItemRenderer
|
||||
key={`${keyPrefix}-${groupedItem.index}`}
|
||||
item={groupedItem.item}
|
||||
index={groupedItem.index}
|
||||
keyPrefix={keyPrefix}
|
||||
defaultRole={defaultRole}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
);
|
||||
}
|
|
@ -0,0 +1,92 @@
|
|||
import { useMemo } from "react";
|
||||
import {
|
||||
isFunctionCallOutputItem,
|
||||
AnyResponseItem,
|
||||
FunctionCallOutputItem,
|
||||
} from "../utils/item-types";
|
||||
|
||||
export interface GroupedItem {
|
||||
item: AnyResponseItem;
|
||||
index: number;
|
||||
outputItem?: AnyResponseItem;
|
||||
outputIndex?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook to group function calls with their corresponding outputs
|
||||
* @param items Array of items to group
|
||||
* @returns Array of grouped items with their outputs
|
||||
*/
|
||||
export function useFunctionCallGrouping(
|
||||
items: AnyResponseItem[],
|
||||
): GroupedItem[] {
|
||||
return useMemo(() => {
|
||||
const groupedItems: GroupedItem[] = [];
|
||||
const processedIndices = new Set<number>();
|
||||
|
||||
// Build a map of call_id to indices for function_call_output items
|
||||
const callIdToIndices = new Map<string, number[]>();
|
||||
|
||||
for (let i = 0; i < items.length; i++) {
|
||||
const item = items[i];
|
||||
if (isFunctionCallOutputItem(item)) {
|
||||
if (!callIdToIndices.has(item.call_id)) {
|
||||
callIdToIndices.set(item.call_id, []);
|
||||
}
|
||||
callIdToIndices.get(item.call_id)!.push(i);
|
||||
}
|
||||
}
|
||||
|
||||
// Process items and group function calls with their outputs
|
||||
for (let i = 0; i < items.length; i++) {
|
||||
if (processedIndices.has(i)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const currentItem = items[i];
|
||||
|
||||
if (
|
||||
currentItem.type === "function_call" &&
|
||||
"name" in currentItem &&
|
||||
"call_id" in currentItem
|
||||
) {
|
||||
const functionCallId = currentItem.call_id as string;
|
||||
let outputIndex = -1;
|
||||
let outputItem: FunctionCallOutputItem | null = null;
|
||||
|
||||
const relatedIndices = callIdToIndices.get(functionCallId) || [];
|
||||
for (const idx of relatedIndices) {
|
||||
const potentialOutput = items[idx];
|
||||
outputIndex = idx;
|
||||
outputItem = potentialOutput as FunctionCallOutputItem;
|
||||
break;
|
||||
}
|
||||
|
||||
if (outputItem && outputIndex !== -1) {
|
||||
// Group function call with its function_call_output
|
||||
groupedItems.push({
|
||||
item: currentItem,
|
||||
index: i,
|
||||
outputItem,
|
||||
outputIndex,
|
||||
});
|
||||
|
||||
// Mark both items as processed
|
||||
processedIndices.add(i);
|
||||
processedIndices.add(outputIndex);
|
||||
|
||||
// Matching function call and output found, skip to next item
|
||||
continue;
|
||||
}
|
||||
}
|
||||
// render normally
|
||||
groupedItems.push({
|
||||
item: currentItem,
|
||||
index: i,
|
||||
});
|
||||
processedIndices.add(i);
|
||||
}
|
||||
|
||||
return groupedItems;
|
||||
}, [items]);
|
||||
}
|
|
@ -0,0 +1,29 @@
|
|||
import {
|
||||
MessageBlock,
|
||||
ToolCallBlock,
|
||||
} from "@/components/ui/message-components";
|
||||
import { FunctionCallItem } from "../utils/item-types";
|
||||
|
||||
interface FunctionCallItemProps {
|
||||
item: FunctionCallItem;
|
||||
index: number;
|
||||
keyPrefix: string;
|
||||
}
|
||||
|
||||
export function FunctionCallItemComponent({
|
||||
item,
|
||||
index,
|
||||
keyPrefix,
|
||||
}: FunctionCallItemProps) {
|
||||
const name = item.name || "unknown";
|
||||
const args = item.arguments || "{}";
|
||||
const formattedFunctionCall = `${name}(${args})`;
|
||||
|
||||
return (
|
||||
<MessageBlock
|
||||
key={`${keyPrefix}-${index}`}
|
||||
label="Function Call"
|
||||
content={<ToolCallBlock>{formattedFunctionCall}</ToolCallBlock>}
|
||||
/>
|
||||
);
|
||||
}
|
37
llama_stack/ui/components/responses/items/generic-item.tsx
Normal file
37
llama_stack/ui/components/responses/items/generic-item.tsx
Normal file
|
@ -0,0 +1,37 @@
|
|||
import {
|
||||
MessageBlock,
|
||||
ToolCallBlock,
|
||||
} from "@/components/ui/message-components";
|
||||
import { BaseItem } from "../utils/item-types";
|
||||
|
||||
interface GenericItemProps {
|
||||
item: BaseItem;
|
||||
index: number;
|
||||
keyPrefix: string;
|
||||
}
|
||||
|
||||
export function GenericItemComponent({
|
||||
item,
|
||||
index,
|
||||
keyPrefix,
|
||||
}: GenericItemProps) {
|
||||
// Handle other types like function calls, tool outputs, etc.
|
||||
const itemData = item as Record<string, unknown>;
|
||||
|
||||
const content = itemData.content
|
||||
? typeof itemData.content === "string"
|
||||
? itemData.content
|
||||
: JSON.stringify(itemData.content, null, 2)
|
||||
: JSON.stringify(itemData, null, 2);
|
||||
|
||||
const label = keyPrefix === "input" ? "Input" : "Output";
|
||||
|
||||
return (
|
||||
<MessageBlock
|
||||
key={`${keyPrefix}-${index}`}
|
||||
label={label}
|
||||
labelDetail={`(${itemData.type})`}
|
||||
content={<ToolCallBlock>{content}</ToolCallBlock>}
|
||||
/>
|
||||
);
|
||||
}
|
|
@ -0,0 +1,54 @@
|
|||
import {
|
||||
MessageBlock,
|
||||
ToolCallBlock,
|
||||
} from "@/components/ui/message-components";
|
||||
import { FunctionCallItem, FunctionCallOutputItem } from "../utils/item-types";
|
||||
|
||||
interface GroupedFunctionCallItemProps {
|
||||
functionCall: FunctionCallItem;
|
||||
output: FunctionCallOutputItem;
|
||||
index: number;
|
||||
keyPrefix: string;
|
||||
}
|
||||
|
||||
export function GroupedFunctionCallItemComponent({
|
||||
functionCall,
|
||||
output,
|
||||
index,
|
||||
keyPrefix,
|
||||
}: GroupedFunctionCallItemProps) {
|
||||
const name = functionCall.name || "unknown";
|
||||
const args = functionCall.arguments || "{}";
|
||||
|
||||
// Extract the output content from function_call_output
|
||||
let outputContent = "";
|
||||
if (output.output) {
|
||||
outputContent =
|
||||
typeof output.output === "string"
|
||||
? output.output
|
||||
: JSON.stringify(output.output);
|
||||
} else {
|
||||
outputContent = JSON.stringify(output, null, 2);
|
||||
}
|
||||
|
||||
const functionCallContent = (
|
||||
<div>
|
||||
<div className="mb-2">
|
||||
<span className="text-sm text-gray-600">Arguments</span>
|
||||
<ToolCallBlock>{`${name}(${args})`}</ToolCallBlock>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-sm text-gray-600">Output</span>
|
||||
<ToolCallBlock>{outputContent}</ToolCallBlock>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<MessageBlock
|
||||
key={`${keyPrefix}-${index}`}
|
||||
label="Function Call"
|
||||
content={functionCallContent}
|
||||
/>
|
||||
);
|
||||
}
|
6
llama_stack/ui/components/responses/items/index.ts
Normal file
6
llama_stack/ui/components/responses/items/index.ts
Normal file
|
@ -0,0 +1,6 @@
|
|||
export { MessageItemComponent } from "./message-item";
|
||||
export { FunctionCallItemComponent } from "./function-call-item";
|
||||
export { WebSearchItemComponent } from "./web-search-item";
|
||||
export { GenericItemComponent } from "./generic-item";
|
||||
export { GroupedFunctionCallItemComponent } from "./grouped-function-call-item";
|
||||
export { ItemRenderer } from "./item-renderer";
|
60
llama_stack/ui/components/responses/items/item-renderer.tsx
Normal file
60
llama_stack/ui/components/responses/items/item-renderer.tsx
Normal file
|
@ -0,0 +1,60 @@
|
|||
import {
|
||||
isMessageItem,
|
||||
isFunctionCallItem,
|
||||
isWebSearchCallItem,
|
||||
AnyResponseItem,
|
||||
} from "../utils/item-types";
|
||||
import { MessageItemComponent } from "./message-item";
|
||||
import { FunctionCallItemComponent } from "./function-call-item";
|
||||
import { WebSearchItemComponent } from "./web-search-item";
|
||||
import { GenericItemComponent } from "./generic-item";
|
||||
|
||||
interface ItemRendererProps {
|
||||
item: AnyResponseItem;
|
||||
index: number;
|
||||
keyPrefix: string;
|
||||
defaultRole?: string;
|
||||
}
|
||||
|
||||
export function ItemRenderer({
|
||||
item,
|
||||
index,
|
||||
keyPrefix,
|
||||
defaultRole = "unknown",
|
||||
}: ItemRendererProps) {
|
||||
if (isMessageItem(item)) {
|
||||
return (
|
||||
<MessageItemComponent
|
||||
item={item}
|
||||
index={index}
|
||||
keyPrefix={keyPrefix}
|
||||
defaultRole={defaultRole}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (isFunctionCallItem(item)) {
|
||||
return (
|
||||
<FunctionCallItemComponent
|
||||
item={item}
|
||||
index={index}
|
||||
keyPrefix={keyPrefix}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (isWebSearchCallItem(item)) {
|
||||
return (
|
||||
<WebSearchItemComponent item={item} index={index} keyPrefix={keyPrefix} />
|
||||
);
|
||||
}
|
||||
|
||||
// Fallback to generic item for unknown types
|
||||
return (
|
||||
<GenericItemComponent
|
||||
item={item as any}
|
||||
index={index}
|
||||
keyPrefix={keyPrefix}
|
||||
/>
|
||||
);
|
||||
}
|
41
llama_stack/ui/components/responses/items/message-item.tsx
Normal file
41
llama_stack/ui/components/responses/items/message-item.tsx
Normal file
|
@ -0,0 +1,41 @@
|
|||
import { MessageBlock } from "@/components/ui/message-components";
|
||||
import { MessageItem } from "../utils/item-types";
|
||||
|
||||
interface MessageItemProps {
|
||||
item: MessageItem;
|
||||
index: number;
|
||||
keyPrefix: string;
|
||||
defaultRole?: string;
|
||||
}
|
||||
|
||||
export function MessageItemComponent({
|
||||
item,
|
||||
index,
|
||||
keyPrefix,
|
||||
defaultRole = "unknown",
|
||||
}: MessageItemProps) {
|
||||
let content = "";
|
||||
|
||||
if (typeof item.content === "string") {
|
||||
content = item.content;
|
||||
} else if (Array.isArray(item.content)) {
|
||||
content = item.content
|
||||
.map((c) => {
|
||||
return c.type === "input_text" || c.type === "output_text"
|
||||
? c.text
|
||||
: JSON.stringify(c);
|
||||
})
|
||||
.join(" ");
|
||||
}
|
||||
|
||||
const role = item.role || defaultRole;
|
||||
const label = role.charAt(0).toUpperCase() + role.slice(1);
|
||||
|
||||
return (
|
||||
<MessageBlock
|
||||
key={`${keyPrefix}-${index}`}
|
||||
label={label}
|
||||
content={content}
|
||||
/>
|
||||
);
|
||||
}
|
|
@ -0,0 +1,28 @@
|
|||
import {
|
||||
MessageBlock,
|
||||
ToolCallBlock,
|
||||
} from "@/components/ui/message-components";
|
||||
import { WebSearchCallItem } from "../utils/item-types";
|
||||
|
||||
interface WebSearchItemProps {
|
||||
item: WebSearchCallItem;
|
||||
index: number;
|
||||
keyPrefix: string;
|
||||
}
|
||||
|
||||
export function WebSearchItemComponent({
|
||||
item,
|
||||
index,
|
||||
keyPrefix,
|
||||
}: WebSearchItemProps) {
|
||||
const formattedWebSearch = `web_search_call(status: ${item.status})`;
|
||||
|
||||
return (
|
||||
<MessageBlock
|
||||
key={`${keyPrefix}-${index}`}
|
||||
label="Function Call"
|
||||
labelDetail="(Web Search)"
|
||||
content={<ToolCallBlock>{formattedWebSearch}</ToolCallBlock>}
|
||||
/>
|
||||
);
|
||||
}
|
777
llama_stack/ui/components/responses/responses-detail.test.tsx
Normal file
777
llama_stack/ui/components/responses/responses-detail.test.tsx
Normal file
|
@ -0,0 +1,777 @@
|
|||
import React from "react";
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import "@testing-library/jest-dom";
|
||||
import { ResponseDetailView } from "./responses-detail";
|
||||
import { OpenAIResponse, InputItemListResponse } from "@/lib/types";
|
||||
|
||||
describe("ResponseDetailView", () => {
|
||||
const defaultProps = {
|
||||
response: null,
|
||||
inputItems: null,
|
||||
isLoading: false,
|
||||
isLoadingInputItems: false,
|
||||
error: null,
|
||||
inputItemsError: null,
|
||||
id: "test_id",
|
||||
};
|
||||
|
||||
describe("Loading State", () => {
|
||||
test("renders loading skeleton when isLoading is true", () => {
|
||||
const { container } = render(
|
||||
<ResponseDetailView {...defaultProps} isLoading={true} />,
|
||||
);
|
||||
|
||||
// Check for skeleton elements
|
||||
const skeletons = container.querySelectorAll('[data-slot="skeleton"]');
|
||||
expect(skeletons.length).toBeGreaterThan(0);
|
||||
|
||||
// The title is replaced by a skeleton when loading, so we shouldn't expect the text
|
||||
});
|
||||
});
|
||||
|
||||
describe("Error State", () => {
|
||||
test("renders error message when error prop is provided", () => {
|
||||
const errorMessage = "Network Error";
|
||||
render(
|
||||
<ResponseDetailView
|
||||
{...defaultProps}
|
||||
error={{ name: "Error", message: errorMessage }}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByText("Responses Details")).toBeInTheDocument();
|
||||
// The error message is split across elements, so we check for parts
|
||||
expect(
|
||||
screen.getByText(/Error loading details for ID/),
|
||||
).toBeInTheDocument();
|
||||
expect(screen.getByText(/test_id/)).toBeInTheDocument();
|
||||
expect(screen.getByText(/Network Error/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("renders default error message when error.message is not available", () => {
|
||||
render(
|
||||
<ResponseDetailView
|
||||
{...defaultProps}
|
||||
error={{ name: "Error", message: "" }}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(
|
||||
screen.getByText(/Error loading details for ID/),
|
||||
).toBeInTheDocument();
|
||||
expect(screen.getByText(/test_id/)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe("Not Found State", () => {
|
||||
test("renders not found message when response is null and not loading/error", () => {
|
||||
render(<ResponseDetailView {...defaultProps} response={null} />);
|
||||
|
||||
expect(screen.getByText("Responses Details")).toBeInTheDocument();
|
||||
// The message is split across elements
|
||||
expect(screen.getByText(/No details found for ID:/)).toBeInTheDocument();
|
||||
expect(screen.getByText(/test_id/)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe("Response Data Rendering", () => {
|
||||
const mockResponse: OpenAIResponse = {
|
||||
id: "resp_123",
|
||||
object: "response",
|
||||
created_at: 1710000000,
|
||||
model: "llama-test-model",
|
||||
status: "completed",
|
||||
output: [
|
||||
{
|
||||
type: "message",
|
||||
role: "assistant",
|
||||
content: "Test response output",
|
||||
},
|
||||
],
|
||||
input: [
|
||||
{
|
||||
type: "message",
|
||||
role: "user",
|
||||
content: "Test input message",
|
||||
},
|
||||
],
|
||||
temperature: 0.7,
|
||||
top_p: 0.9,
|
||||
parallel_tool_calls: true,
|
||||
previous_response_id: "prev_resp_456",
|
||||
};
|
||||
|
||||
test("renders response data with input and output sections", () => {
|
||||
render(<ResponseDetailView {...defaultProps} response={mockResponse} />);
|
||||
|
||||
// Check main sections
|
||||
expect(screen.getByText("Responses Details")).toBeInTheDocument();
|
||||
expect(screen.getByText("Input")).toBeInTheDocument();
|
||||
expect(screen.getByText("Output")).toBeInTheDocument();
|
||||
|
||||
// Check input content
|
||||
expect(screen.getByText("Test input message")).toBeInTheDocument();
|
||||
expect(screen.getByText("User")).toBeInTheDocument();
|
||||
|
||||
// Check output content
|
||||
expect(screen.getByText("Test response output")).toBeInTheDocument();
|
||||
expect(screen.getByText("Assistant")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("renders properties sidebar with all response metadata", () => {
|
||||
render(<ResponseDetailView {...defaultProps} response={mockResponse} />);
|
||||
|
||||
// Check properties - use regex to handle text split across elements
|
||||
expect(screen.getByText(/Created/)).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByText(new Date(1710000000 * 1000).toLocaleString()),
|
||||
).toBeInTheDocument();
|
||||
|
||||
// Check for the specific ID label (not Previous Response ID)
|
||||
expect(
|
||||
screen.getByText((content, element) => {
|
||||
return element?.tagName === "STRONG" && content === "ID:";
|
||||
}),
|
||||
).toBeInTheDocument();
|
||||
expect(screen.getByText("resp_123")).toBeInTheDocument();
|
||||
|
||||
expect(screen.getByText(/Model/)).toBeInTheDocument();
|
||||
expect(screen.getByText("llama-test-model")).toBeInTheDocument();
|
||||
|
||||
expect(screen.getByText(/Status/)).toBeInTheDocument();
|
||||
expect(screen.getByText("completed")).toBeInTheDocument();
|
||||
|
||||
expect(screen.getByText(/Temperature/)).toBeInTheDocument();
|
||||
expect(screen.getByText("0.7")).toBeInTheDocument();
|
||||
|
||||
expect(screen.getByText(/Top P/)).toBeInTheDocument();
|
||||
expect(screen.getByText("0.9")).toBeInTheDocument();
|
||||
|
||||
expect(screen.getByText(/Parallel Tool Calls/)).toBeInTheDocument();
|
||||
expect(screen.getByText("Yes")).toBeInTheDocument();
|
||||
|
||||
expect(screen.getByText(/Previous Response ID/)).toBeInTheDocument();
|
||||
expect(screen.getByText("prev_resp_456")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("handles optional properties correctly", () => {
|
||||
const minimalResponse: OpenAIResponse = {
|
||||
id: "resp_minimal",
|
||||
object: "response",
|
||||
created_at: 1710000000,
|
||||
model: "test-model",
|
||||
status: "completed",
|
||||
output: [],
|
||||
input: [],
|
||||
};
|
||||
|
||||
render(
|
||||
<ResponseDetailView {...defaultProps} response={minimalResponse} />,
|
||||
);
|
||||
|
||||
// Should show required properties
|
||||
expect(screen.getByText("resp_minimal")).toBeInTheDocument();
|
||||
expect(screen.getByText("test-model")).toBeInTheDocument();
|
||||
expect(screen.getByText("completed")).toBeInTheDocument();
|
||||
|
||||
// Should not show optional properties
|
||||
expect(screen.queryByText("Temperature")).not.toBeInTheDocument();
|
||||
expect(screen.queryByText("Top P")).not.toBeInTheDocument();
|
||||
expect(screen.queryByText("Parallel Tool Calls")).not.toBeInTheDocument();
|
||||
expect(
|
||||
screen.queryByText("Previous Response ID"),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("renders error information when response has error", () => {
|
||||
const errorResponse: OpenAIResponse = {
|
||||
...mockResponse,
|
||||
error: {
|
||||
code: "invalid_request",
|
||||
message: "The request was invalid",
|
||||
},
|
||||
};
|
||||
|
||||
render(<ResponseDetailView {...defaultProps} response={errorResponse} />);
|
||||
|
||||
// The error is shown in the properties sidebar, not as a separate "Error" label
|
||||
expect(
|
||||
screen.getByText("invalid_request: The request was invalid"),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe("Input Items Handling", () => {
|
||||
const mockResponse: OpenAIResponse = {
|
||||
id: "resp_123",
|
||||
object: "response",
|
||||
created_at: 1710000000,
|
||||
model: "test-model",
|
||||
status: "completed",
|
||||
output: [{ type: "message", role: "assistant", content: "output" }],
|
||||
input: [{ type: "message", role: "user", content: "fallback input" }],
|
||||
};
|
||||
|
||||
test("shows loading state for input items", () => {
|
||||
render(
|
||||
<ResponseDetailView
|
||||
{...defaultProps}
|
||||
response={mockResponse}
|
||||
isLoadingInputItems={true}
|
||||
/>,
|
||||
);
|
||||
|
||||
// Check for skeleton loading in input items section
|
||||
const { container } = render(
|
||||
<ResponseDetailView
|
||||
{...defaultProps}
|
||||
response={mockResponse}
|
||||
isLoadingInputItems={true}
|
||||
/>,
|
||||
);
|
||||
|
||||
const skeletons = container.querySelectorAll('[data-slot="skeleton"]');
|
||||
expect(skeletons.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
test("shows error message for input items with fallback", () => {
|
||||
render(
|
||||
<ResponseDetailView
|
||||
{...defaultProps}
|
||||
response={mockResponse}
|
||||
inputItemsError={{
|
||||
name: "Error",
|
||||
message: "Failed to load input items",
|
||||
}}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(
|
||||
screen.getByText(
|
||||
"Error loading input items: Failed to load input items",
|
||||
),
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByText("Falling back to response input data."),
|
||||
).toBeInTheDocument();
|
||||
|
||||
// Should still show fallback input data
|
||||
expect(screen.getByText("fallback input")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("uses input items data when available", () => {
|
||||
const mockInputItems: InputItemListResponse = {
|
||||
object: "list",
|
||||
data: [
|
||||
{
|
||||
type: "message",
|
||||
role: "user",
|
||||
content: "input from items API",
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
render(
|
||||
<ResponseDetailView
|
||||
{...defaultProps}
|
||||
response={mockResponse}
|
||||
inputItems={mockInputItems}
|
||||
/>,
|
||||
);
|
||||
|
||||
// Should show input items data, not response.input
|
||||
expect(screen.getByText("input from items API")).toBeInTheDocument();
|
||||
expect(screen.queryByText("fallback input")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("falls back to response.input when input items is empty", () => {
|
||||
const emptyInputItems: InputItemListResponse = {
|
||||
object: "list",
|
||||
data: [],
|
||||
};
|
||||
|
||||
render(
|
||||
<ResponseDetailView
|
||||
{...defaultProps}
|
||||
response={mockResponse}
|
||||
inputItems={emptyInputItems}
|
||||
/>,
|
||||
);
|
||||
|
||||
// Should show fallback input data
|
||||
expect(screen.getByText("fallback input")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("shows no input message when no data available", () => {
|
||||
const responseWithoutInput: OpenAIResponse = {
|
||||
...mockResponse,
|
||||
input: [],
|
||||
};
|
||||
|
||||
render(
|
||||
<ResponseDetailView
|
||||
{...defaultProps}
|
||||
response={responseWithoutInput}
|
||||
inputItems={null}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByText("No input data available.")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe("Input Display Components", () => {
|
||||
test("renders string content input correctly", () => {
|
||||
const mockResponse: OpenAIResponse = {
|
||||
id: "resp_123",
|
||||
object: "response",
|
||||
created_at: 1710000000,
|
||||
model: "test-model",
|
||||
status: "completed",
|
||||
output: [],
|
||||
input: [
|
||||
{
|
||||
type: "message",
|
||||
role: "user",
|
||||
content: "Simple string input",
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
render(<ResponseDetailView {...defaultProps} response={mockResponse} />);
|
||||
|
||||
expect(screen.getByText("Simple string input")).toBeInTheDocument();
|
||||
expect(screen.getByText("User")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("renders array content input correctly", () => {
|
||||
const mockResponse: OpenAIResponse = {
|
||||
id: "resp_123",
|
||||
object: "response",
|
||||
created_at: 1710000000,
|
||||
model: "test-model",
|
||||
status: "completed",
|
||||
output: [],
|
||||
input: [
|
||||
{
|
||||
type: "message",
|
||||
role: "user",
|
||||
content: [
|
||||
{ type: "input_text", text: "First part" },
|
||||
{ type: "output_text", text: "Second part" },
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
render(<ResponseDetailView {...defaultProps} response={mockResponse} />);
|
||||
|
||||
expect(screen.getByText("First part Second part")).toBeInTheDocument();
|
||||
expect(screen.getByText("User")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("renders non-message input types correctly", () => {
|
||||
const mockResponse: OpenAIResponse = {
|
||||
id: "resp_123",
|
||||
object: "response",
|
||||
created_at: 1710000000,
|
||||
model: "test-model",
|
||||
status: "completed",
|
||||
output: [],
|
||||
input: [
|
||||
{
|
||||
type: "function_call",
|
||||
content: "function call content",
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
render(<ResponseDetailView {...defaultProps} response={mockResponse} />);
|
||||
|
||||
expect(screen.getByText("function call content")).toBeInTheDocument();
|
||||
// Use getAllByText to find the specific "Input" with the type detail
|
||||
const inputElements = screen.getAllByText("Input");
|
||||
expect(inputElements.length).toBeGreaterThan(0);
|
||||
expect(screen.getByText("(function_call)")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("handles input with object content", () => {
|
||||
const mockResponse: OpenAIResponse = {
|
||||
id: "resp_123",
|
||||
object: "response",
|
||||
created_at: 1710000000,
|
||||
model: "test-model",
|
||||
status: "completed",
|
||||
output: [],
|
||||
input: [
|
||||
{
|
||||
type: "custom_type",
|
||||
content: JSON.stringify({ key: "value", nested: { data: "test" } }),
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
render(<ResponseDetailView {...defaultProps} response={mockResponse} />);
|
||||
|
||||
// Should show JSON stringified content (without quotes around keys in the rendered output)
|
||||
expect(screen.getByText(/key.*value/)).toBeInTheDocument();
|
||||
// Use getAllByText to find the specific "Input" with the type detail
|
||||
const inputElements = screen.getAllByText("Input");
|
||||
expect(inputElements.length).toBeGreaterThan(0);
|
||||
expect(screen.getByText("(custom_type)")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("renders function call input correctly", () => {
|
||||
const mockResponse: OpenAIResponse = {
|
||||
id: "resp_123",
|
||||
object: "response",
|
||||
created_at: 1710000000,
|
||||
model: "test-model",
|
||||
status: "completed",
|
||||
output: [],
|
||||
input: [
|
||||
{
|
||||
type: "function_call",
|
||||
id: "call_456",
|
||||
status: "completed",
|
||||
name: "input_function",
|
||||
arguments: '{"param": "value"}',
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
render(<ResponseDetailView {...defaultProps} response={mockResponse} />);
|
||||
|
||||
expect(
|
||||
screen.getByText('input_function({"param": "value"})'),
|
||||
).toBeInTheDocument();
|
||||
expect(screen.getByText("Function Call")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("renders web search call input correctly", () => {
|
||||
const mockResponse: OpenAIResponse = {
|
||||
id: "resp_123",
|
||||
object: "response",
|
||||
created_at: 1710000000,
|
||||
model: "test-model",
|
||||
status: "completed",
|
||||
output: [],
|
||||
input: [
|
||||
{
|
||||
type: "web_search_call",
|
||||
id: "search_789",
|
||||
status: "completed",
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
render(<ResponseDetailView {...defaultProps} response={mockResponse} />);
|
||||
|
||||
expect(
|
||||
screen.getByText("web_search_call(status: completed)"),
|
||||
).toBeInTheDocument();
|
||||
expect(screen.getByText("Function Call")).toBeInTheDocument();
|
||||
expect(screen.getByText("(Web Search)")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe("Output Display Components", () => {
|
||||
test("renders message output with string content", () => {
|
||||
const mockResponse: OpenAIResponse = {
|
||||
id: "resp_123",
|
||||
object: "response",
|
||||
created_at: 1710000000,
|
||||
model: "test-model",
|
||||
status: "completed",
|
||||
output: [
|
||||
{
|
||||
type: "message",
|
||||
role: "assistant",
|
||||
content: "Simple string output",
|
||||
},
|
||||
],
|
||||
input: [],
|
||||
};
|
||||
|
||||
render(<ResponseDetailView {...defaultProps} response={mockResponse} />);
|
||||
|
||||
expect(screen.getByText("Simple string output")).toBeInTheDocument();
|
||||
expect(screen.getByText("Assistant")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("renders message output with array content", () => {
|
||||
const mockResponse: OpenAIResponse = {
|
||||
id: "resp_123",
|
||||
object: "response",
|
||||
created_at: 1710000000,
|
||||
model: "test-model",
|
||||
status: "completed",
|
||||
output: [
|
||||
{
|
||||
type: "message",
|
||||
role: "assistant",
|
||||
content: [
|
||||
{ type: "output_text", text: "First output" },
|
||||
{ type: "input_text", text: "Second output" },
|
||||
],
|
||||
},
|
||||
],
|
||||
input: [],
|
||||
};
|
||||
|
||||
render(<ResponseDetailView {...defaultProps} response={mockResponse} />);
|
||||
|
||||
expect(
|
||||
screen.getByText("First output Second output"),
|
||||
).toBeInTheDocument();
|
||||
expect(screen.getByText("Assistant")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("renders function call output correctly", () => {
|
||||
const mockResponse: OpenAIResponse = {
|
||||
id: "resp_123",
|
||||
object: "response",
|
||||
created_at: 1710000000,
|
||||
model: "test-model",
|
||||
status: "completed",
|
||||
output: [
|
||||
{
|
||||
type: "function_call",
|
||||
id: "call_123",
|
||||
status: "completed",
|
||||
name: "search_function",
|
||||
arguments: '{"query": "test"}',
|
||||
},
|
||||
],
|
||||
input: [],
|
||||
};
|
||||
|
||||
render(<ResponseDetailView {...defaultProps} response={mockResponse} />);
|
||||
|
||||
expect(
|
||||
screen.getByText('search_function({"query": "test"})'),
|
||||
).toBeInTheDocument();
|
||||
expect(screen.getByText("Function Call")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("renders function call output without arguments", () => {
|
||||
const mockResponse: OpenAIResponse = {
|
||||
id: "resp_123",
|
||||
object: "response",
|
||||
created_at: 1710000000,
|
||||
model: "test-model",
|
||||
status: "completed",
|
||||
output: [
|
||||
{
|
||||
type: "function_call",
|
||||
id: "call_123",
|
||||
status: "completed",
|
||||
name: "simple_function",
|
||||
},
|
||||
],
|
||||
input: [],
|
||||
};
|
||||
|
||||
render(<ResponseDetailView {...defaultProps} response={mockResponse} />);
|
||||
|
||||
expect(screen.getByText("simple_function({})")).toBeInTheDocument();
|
||||
expect(screen.getByText(/Function Call/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("renders web search call output correctly", () => {
|
||||
const mockResponse: OpenAIResponse = {
|
||||
id: "resp_123",
|
||||
object: "response",
|
||||
created_at: 1710000000,
|
||||
model: "test-model",
|
||||
status: "completed",
|
||||
output: [
|
||||
{
|
||||
type: "web_search_call",
|
||||
id: "search_123",
|
||||
status: "completed",
|
||||
},
|
||||
],
|
||||
input: [],
|
||||
};
|
||||
|
||||
render(<ResponseDetailView {...defaultProps} response={mockResponse} />);
|
||||
|
||||
expect(
|
||||
screen.getByText("web_search_call(status: completed)"),
|
||||
).toBeInTheDocument();
|
||||
expect(screen.getByText(/Function Call/)).toBeInTheDocument();
|
||||
expect(screen.getByText("(Web Search)")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("renders unknown output types with JSON fallback", () => {
|
||||
const mockResponse: OpenAIResponse = {
|
||||
id: "resp_123",
|
||||
object: "response",
|
||||
created_at: 1710000000,
|
||||
model: "test-model",
|
||||
status: "completed",
|
||||
output: [
|
||||
{
|
||||
type: "unknown_type",
|
||||
custom_field: "custom_value",
|
||||
data: { nested: "object" },
|
||||
} as any,
|
||||
],
|
||||
input: [],
|
||||
};
|
||||
|
||||
render(<ResponseDetailView {...defaultProps} response={mockResponse} />);
|
||||
|
||||
// Should show JSON stringified content
|
||||
expect(
|
||||
screen.getByText(/custom_field.*custom_value/),
|
||||
).toBeInTheDocument();
|
||||
expect(screen.getByText("(unknown_type)")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("shows no output message when output array is empty", () => {
|
||||
const mockResponse: OpenAIResponse = {
|
||||
id: "resp_123",
|
||||
object: "response",
|
||||
created_at: 1710000000,
|
||||
model: "test-model",
|
||||
status: "completed",
|
||||
output: [],
|
||||
input: [],
|
||||
};
|
||||
|
||||
render(<ResponseDetailView {...defaultProps} response={mockResponse} />);
|
||||
|
||||
expect(screen.getByText("No output data available.")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("groups function call with its output correctly", () => {
|
||||
const mockResponse: OpenAIResponse = {
|
||||
id: "resp_123",
|
||||
object: "response",
|
||||
created_at: 1710000000,
|
||||
model: "test-model",
|
||||
status: "completed",
|
||||
output: [
|
||||
{
|
||||
type: "function_call",
|
||||
id: "call_123",
|
||||
status: "completed",
|
||||
name: "get_weather",
|
||||
arguments: '{"city": "Tokyo"}',
|
||||
},
|
||||
{
|
||||
type: "message",
|
||||
role: "assistant",
|
||||
call_id: "call_123",
|
||||
content: "sunny and warm",
|
||||
} as any, // Using any to bypass the type restriction for this test
|
||||
],
|
||||
input: [],
|
||||
};
|
||||
|
||||
render(<ResponseDetailView {...defaultProps} response={mockResponse} />);
|
||||
|
||||
// Should show the function call and message as separate items (not grouped)
|
||||
expect(screen.getByText("Function Call")).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByText('get_weather({"city": "Tokyo"})'),
|
||||
).toBeInTheDocument();
|
||||
expect(screen.getByText("Assistant")).toBeInTheDocument();
|
||||
expect(screen.getByText("sunny and warm")).toBeInTheDocument();
|
||||
|
||||
// Should NOT have the grouped "Arguments" and "Output" labels
|
||||
expect(screen.queryByText("Arguments")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("groups function call with function_call_output correctly", () => {
|
||||
const mockResponse: OpenAIResponse = {
|
||||
id: "resp_123",
|
||||
object: "response",
|
||||
created_at: 1710000000,
|
||||
model: "test-model",
|
||||
status: "completed",
|
||||
output: [
|
||||
{
|
||||
type: "function_call",
|
||||
call_id: "call_123",
|
||||
status: "completed",
|
||||
name: "get_weather",
|
||||
arguments: '{"city": "Tokyo"}',
|
||||
},
|
||||
{
|
||||
type: "function_call_output",
|
||||
id: "fc_68364957013081...",
|
||||
status: "completed",
|
||||
call_id: "call_123",
|
||||
output: "sunny and warm",
|
||||
} as any, // Using any to bypass the type restriction for this test
|
||||
],
|
||||
input: [],
|
||||
};
|
||||
|
||||
render(<ResponseDetailView {...defaultProps} response={mockResponse} />);
|
||||
|
||||
// Should show the function call grouped with its clean output
|
||||
expect(screen.getByText("Function Call")).toBeInTheDocument();
|
||||
expect(screen.getByText("Arguments")).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByText('get_weather({"city": "Tokyo"})'),
|
||||
).toBeInTheDocument();
|
||||
// Use getAllByText since there are multiple "Output" elements (card title and output label)
|
||||
const outputElements = screen.getAllByText("Output");
|
||||
expect(outputElements.length).toBeGreaterThan(0);
|
||||
expect(screen.getByText("sunny and warm")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe("Edge Cases and Error Handling", () => {
|
||||
test("handles missing role in message input", () => {
|
||||
const mockResponse: OpenAIResponse = {
|
||||
id: "resp_123",
|
||||
object: "response",
|
||||
created_at: 1710000000,
|
||||
model: "test-model",
|
||||
status: "completed",
|
||||
output: [],
|
||||
input: [
|
||||
{
|
||||
type: "message",
|
||||
content: "Message without role",
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
render(<ResponseDetailView {...defaultProps} response={mockResponse} />);
|
||||
|
||||
expect(screen.getByText("Message without role")).toBeInTheDocument();
|
||||
expect(screen.getByText("Unknown")).toBeInTheDocument(); // Default role
|
||||
});
|
||||
|
||||
test("handles missing name in function call output", () => {
|
||||
const mockResponse: OpenAIResponse = {
|
||||
id: "resp_123",
|
||||
object: "response",
|
||||
created_at: 1710000000,
|
||||
model: "test-model",
|
||||
status: "completed",
|
||||
output: [
|
||||
{
|
||||
type: "function_call",
|
||||
id: "call_123",
|
||||
status: "completed",
|
||||
},
|
||||
],
|
||||
input: [],
|
||||
};
|
||||
|
||||
render(<ResponseDetailView {...defaultProps} response={mockResponse} />);
|
||||
|
||||
// When name is missing, it falls back to JSON.stringify of the entire output
|
||||
const functionCallElements = screen.getAllByText(/function_call/);
|
||||
expect(functionCallElements.length).toBeGreaterThan(0);
|
||||
expect(screen.getByText(/call_123/)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
171
llama_stack/ui/components/responses/responses-detail.tsx
Normal file
171
llama_stack/ui/components/responses/responses-detail.tsx
Normal file
|
@ -0,0 +1,171 @@
|
|||
"use client";
|
||||
|
||||
import { OpenAIResponse, InputItemListResponse } from "@/lib/types";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import {
|
||||
DetailLoadingView,
|
||||
DetailErrorView,
|
||||
DetailNotFoundView,
|
||||
DetailLayout,
|
||||
PropertiesCard,
|
||||
PropertyItem,
|
||||
} from "@/components/layout/detail-layout";
|
||||
import { GroupedItemsDisplay } from "./grouping/grouped-items-display";
|
||||
|
||||
interface ResponseDetailViewProps {
|
||||
response: OpenAIResponse | null;
|
||||
inputItems: InputItemListResponse | null;
|
||||
isLoading: boolean;
|
||||
isLoadingInputItems: boolean;
|
||||
error: Error | null;
|
||||
inputItemsError: Error | null;
|
||||
id: string;
|
||||
}
|
||||
|
||||
export function ResponseDetailView({
|
||||
response,
|
||||
inputItems,
|
||||
isLoading,
|
||||
isLoadingInputItems,
|
||||
error,
|
||||
inputItemsError,
|
||||
id,
|
||||
}: ResponseDetailViewProps) {
|
||||
const title = "Responses Details";
|
||||
|
||||
if (error) {
|
||||
return <DetailErrorView title={title} id={id} error={error} />;
|
||||
}
|
||||
|
||||
if (isLoading) {
|
||||
return <DetailLoadingView title={title} />;
|
||||
}
|
||||
|
||||
if (!response) {
|
||||
return <DetailNotFoundView title={title} id={id} />;
|
||||
}
|
||||
|
||||
// Main content cards
|
||||
const mainContent = (
|
||||
<>
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Input</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{/* Show loading state for input items */}
|
||||
{isLoadingInputItems ? (
|
||||
<div className="space-y-2">
|
||||
<Skeleton className="h-4 w-full" />
|
||||
<Skeleton className="h-4 w-3/4" />
|
||||
<Skeleton className="h-4 w-1/2" />
|
||||
</div>
|
||||
) : inputItemsError ? (
|
||||
<div className="text-red-500 text-sm">
|
||||
Error loading input items: {inputItemsError.message}
|
||||
<br />
|
||||
<span className="text-gray-500 text-xs">
|
||||
Falling back to response input data.
|
||||
</span>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{/* Display input items if available, otherwise fall back to response.input */}
|
||||
{(() => {
|
||||
const dataToDisplay =
|
||||
inputItems?.data && inputItems.data.length > 0
|
||||
? inputItems.data
|
||||
: response.input;
|
||||
|
||||
if (dataToDisplay && dataToDisplay.length > 0) {
|
||||
return (
|
||||
<GroupedItemsDisplay
|
||||
items={dataToDisplay}
|
||||
keyPrefix="input"
|
||||
defaultRole="unknown"
|
||||
/>
|
||||
);
|
||||
} else {
|
||||
return (
|
||||
<p className="text-gray-500 italic text-sm">
|
||||
No input data available.
|
||||
</p>
|
||||
);
|
||||
}
|
||||
})()}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Output</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{response.output?.length > 0 ? (
|
||||
<GroupedItemsDisplay
|
||||
items={response.output}
|
||||
keyPrefix="output"
|
||||
defaultRole="assistant"
|
||||
/>
|
||||
) : (
|
||||
<p className="text-gray-500 italic text-sm">
|
||||
No output data available.
|
||||
</p>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</>
|
||||
);
|
||||
|
||||
// Properties sidebar
|
||||
const sidebar = (
|
||||
<PropertiesCard>
|
||||
<PropertyItem
|
||||
label="Created"
|
||||
value={new Date(response.created_at * 1000).toLocaleString()}
|
||||
/>
|
||||
<PropertyItem label="ID" value={response.id} />
|
||||
<PropertyItem label="Model" value={response.model} />
|
||||
<PropertyItem label="Status" value={response.status} hasBorder />
|
||||
{response.temperature && (
|
||||
<PropertyItem
|
||||
label="Temperature"
|
||||
value={response.temperature}
|
||||
hasBorder
|
||||
/>
|
||||
)}
|
||||
{response.top_p && <PropertyItem label="Top P" value={response.top_p} />}
|
||||
{response.parallel_tool_calls && (
|
||||
<PropertyItem
|
||||
label="Parallel Tool Calls"
|
||||
value={response.parallel_tool_calls ? "Yes" : "No"}
|
||||
/>
|
||||
)}
|
||||
{response.previous_response_id && (
|
||||
<PropertyItem
|
||||
label="Previous Response ID"
|
||||
value={
|
||||
<span className="text-xs">{response.previous_response_id}</span>
|
||||
}
|
||||
hasBorder
|
||||
/>
|
||||
)}
|
||||
{response.error && (
|
||||
<PropertyItem
|
||||
label="Error"
|
||||
value={
|
||||
<span className="text-red-900 font-medium">
|
||||
{response.error.code}: {response.error.message}
|
||||
</span>
|
||||
}
|
||||
className="pt-1 mt-1 border-t border-red-200"
|
||||
/>
|
||||
)}
|
||||
</PropertiesCard>
|
||||
);
|
||||
|
||||
return (
|
||||
<DetailLayout title={title} mainContent={mainContent} sidebar={sidebar} />
|
||||
);
|
||||
}
|
537
llama_stack/ui/components/responses/responses-table.test.tsx
Normal file
537
llama_stack/ui/components/responses/responses-table.test.tsx
Normal file
|
@ -0,0 +1,537 @@
|
|||
import React from "react";
|
||||
import { render, screen, fireEvent } from "@testing-library/react";
|
||||
import "@testing-library/jest-dom";
|
||||
import { ResponsesTable } from "./responses-table";
|
||||
import { OpenAIResponse } from "@/lib/types";
|
||||
|
||||
// Mock next/navigation
|
||||
const mockPush = jest.fn();
|
||||
jest.mock("next/navigation", () => ({
|
||||
useRouter: () => ({
|
||||
push: mockPush,
|
||||
}),
|
||||
}));
|
||||
|
||||
// Mock helper functions
|
||||
jest.mock("@/lib/truncate-text");
|
||||
|
||||
// Import the mocked functions
|
||||
import { truncateText as originalTruncateText } from "@/lib/truncate-text";
|
||||
|
||||
// Cast to jest.Mock for typings
|
||||
const truncateText = originalTruncateText as jest.Mock;
|
||||
|
||||
describe("ResponsesTable", () => {
|
||||
const defaultProps = {
|
||||
data: [] as OpenAIResponse[],
|
||||
isLoading: false,
|
||||
error: null,
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
// Reset all mocks before each test
|
||||
mockPush.mockClear();
|
||||
truncateText.mockClear();
|
||||
|
||||
// Default pass-through implementation
|
||||
truncateText.mockImplementation((text: string | undefined) => text);
|
||||
});
|
||||
|
||||
test("renders without crashing with default props", () => {
|
||||
render(<ResponsesTable {...defaultProps} />);
|
||||
expect(screen.getByText("No responses found.")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("click on a row navigates to the correct URL", () => {
|
||||
const mockResponse: OpenAIResponse = {
|
||||
id: "resp_123",
|
||||
object: "response",
|
||||
created_at: Math.floor(Date.now() / 1000),
|
||||
model: "llama-test-model",
|
||||
status: "completed",
|
||||
output: [
|
||||
{
|
||||
type: "message",
|
||||
role: "assistant",
|
||||
content: "Test output",
|
||||
},
|
||||
],
|
||||
input: [
|
||||
{
|
||||
type: "message",
|
||||
role: "user",
|
||||
content: "Test input",
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
render(<ResponsesTable {...defaultProps} data={[mockResponse]} />);
|
||||
|
||||
const row = screen.getByText("Test input").closest("tr");
|
||||
if (row) {
|
||||
fireEvent.click(row);
|
||||
expect(mockPush).toHaveBeenCalledWith("/logs/responses/resp_123");
|
||||
} else {
|
||||
throw new Error('Row with "Test input" not found for router mock test.');
|
||||
}
|
||||
});
|
||||
|
||||
describe("Loading State", () => {
|
||||
test("renders skeleton UI when isLoading is true", () => {
|
||||
const { container } = render(
|
||||
<ResponsesTable {...defaultProps} isLoading={true} />,
|
||||
);
|
||||
|
||||
// Check for skeleton in the table caption
|
||||
const tableCaption = container.querySelector("caption");
|
||||
expect(tableCaption).toBeInTheDocument();
|
||||
if (tableCaption) {
|
||||
const captionSkeleton = tableCaption.querySelector(
|
||||
'[data-slot="skeleton"]',
|
||||
);
|
||||
expect(captionSkeleton).toBeInTheDocument();
|
||||
}
|
||||
|
||||
// Check for skeletons in the table body cells
|
||||
const tableBody = container.querySelector("tbody");
|
||||
expect(tableBody).toBeInTheDocument();
|
||||
if (tableBody) {
|
||||
const bodySkeletons = tableBody.querySelectorAll(
|
||||
'[data-slot="skeleton"]',
|
||||
);
|
||||
expect(bodySkeletons.length).toBeGreaterThan(0);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("Error State", () => {
|
||||
test("renders error message when error prop is provided", () => {
|
||||
const errorMessage = "Network Error";
|
||||
render(
|
||||
<ResponsesTable
|
||||
{...defaultProps}
|
||||
error={{ name: "Error", message: errorMessage }}
|
||||
/>,
|
||||
);
|
||||
expect(
|
||||
screen.getByText(`Error fetching data: ${errorMessage}`),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("renders default error message when error.message is not available", () => {
|
||||
render(
|
||||
<ResponsesTable
|
||||
{...defaultProps}
|
||||
error={{ name: "Error", message: "" }}
|
||||
/>,
|
||||
);
|
||||
expect(
|
||||
screen.getByText("Error fetching data: An unknown error occurred"),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("renders default error message when error prop is an object without message", () => {
|
||||
render(<ResponsesTable {...defaultProps} error={{} as Error} />);
|
||||
expect(
|
||||
screen.getByText("Error fetching data: An unknown error occurred"),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe("Empty State", () => {
|
||||
test('renders "No responses found." and no table when data array is empty', () => {
|
||||
render(<ResponsesTable data={[]} isLoading={false} error={null} />);
|
||||
expect(screen.getByText("No responses found.")).toBeInTheDocument();
|
||||
|
||||
// Ensure that the table structure is NOT rendered in the empty state
|
||||
const table = screen.queryByRole("table");
|
||||
expect(table).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe("Data Rendering", () => {
|
||||
test("renders table caption, headers, and response data correctly", () => {
|
||||
const mockResponses = [
|
||||
{
|
||||
id: "resp_1",
|
||||
object: "response" as const,
|
||||
created_at: 1710000000,
|
||||
model: "llama-test-model",
|
||||
status: "completed",
|
||||
output: [
|
||||
{
|
||||
type: "message" as const,
|
||||
role: "assistant" as const,
|
||||
content: "Test output",
|
||||
},
|
||||
],
|
||||
input: [
|
||||
{
|
||||
type: "message",
|
||||
role: "user",
|
||||
content: "Test input",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "resp_2",
|
||||
object: "response" as const,
|
||||
created_at: 1710001000,
|
||||
model: "llama-another-model",
|
||||
status: "completed",
|
||||
output: [
|
||||
{
|
||||
type: "message" as const,
|
||||
role: "assistant" as const,
|
||||
content: "Another output",
|
||||
},
|
||||
],
|
||||
input: [
|
||||
{
|
||||
type: "message",
|
||||
role: "user",
|
||||
content: "Another input",
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
render(
|
||||
<ResponsesTable data={mockResponses} isLoading={false} error={null} />,
|
||||
);
|
||||
|
||||
// Table caption
|
||||
expect(
|
||||
screen.getByText("A list of your recent responses."),
|
||||
).toBeInTheDocument();
|
||||
|
||||
// Table headers
|
||||
expect(screen.getByText("Input")).toBeInTheDocument();
|
||||
expect(screen.getByText("Output")).toBeInTheDocument();
|
||||
expect(screen.getByText("Model")).toBeInTheDocument();
|
||||
expect(screen.getByText("Created")).toBeInTheDocument();
|
||||
|
||||
// Data rows
|
||||
expect(screen.getByText("Test input")).toBeInTheDocument();
|
||||
expect(screen.getByText("Test output")).toBeInTheDocument();
|
||||
expect(screen.getByText("llama-test-model")).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByText(new Date(1710000000 * 1000).toLocaleString()),
|
||||
).toBeInTheDocument();
|
||||
|
||||
expect(screen.getByText("Another input")).toBeInTheDocument();
|
||||
expect(screen.getByText("Another output")).toBeInTheDocument();
|
||||
expect(screen.getByText("llama-another-model")).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByText(new Date(1710001000 * 1000).toLocaleString()),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe("Input Text Extraction", () => {
|
||||
test("extracts text from string content", () => {
|
||||
const mockResponse: OpenAIResponse = {
|
||||
id: "resp_string",
|
||||
object: "response",
|
||||
created_at: 1710000000,
|
||||
model: "test-model",
|
||||
status: "completed",
|
||||
output: [{ type: "message", role: "assistant", content: "output" }],
|
||||
input: [
|
||||
{
|
||||
type: "message",
|
||||
role: "user",
|
||||
content: "Simple string input",
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
render(
|
||||
<ResponsesTable data={[mockResponse]} isLoading={false} error={null} />,
|
||||
);
|
||||
expect(screen.getByText("Simple string input")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("extracts text from array content with input_text type", () => {
|
||||
const mockResponse: OpenAIResponse = {
|
||||
id: "resp_array",
|
||||
object: "response",
|
||||
created_at: 1710000000,
|
||||
model: "test-model",
|
||||
status: "completed",
|
||||
output: [{ type: "message", role: "assistant", content: "output" }],
|
||||
input: [
|
||||
{
|
||||
type: "message",
|
||||
role: "user",
|
||||
content: [
|
||||
{ type: "input_text", text: "Array input text" },
|
||||
{ type: "input_text", text: "Should not be used" },
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
render(
|
||||
<ResponsesTable data={[mockResponse]} isLoading={false} error={null} />,
|
||||
);
|
||||
expect(screen.getByText("Array input text")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("returns empty string when no message input found", () => {
|
||||
const mockResponse: OpenAIResponse = {
|
||||
id: "resp_no_input",
|
||||
object: "response",
|
||||
created_at: 1710000000,
|
||||
model: "test-model",
|
||||
status: "completed",
|
||||
output: [{ type: "message", role: "assistant", content: "output" }],
|
||||
input: [
|
||||
{
|
||||
type: "other_type",
|
||||
content: "Not a message",
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const { container } = render(
|
||||
<ResponsesTable data={[mockResponse]} isLoading={false} error={null} />,
|
||||
);
|
||||
|
||||
// Find the input cell (first cell in the data row) and verify it's empty
|
||||
const inputCell = container.querySelector("tbody tr td:first-child");
|
||||
expect(inputCell).toBeInTheDocument();
|
||||
expect(inputCell).toHaveTextContent("");
|
||||
});
|
||||
});
|
||||
|
||||
describe("Output Text Extraction", () => {
|
||||
test("extracts text from string message content", () => {
|
||||
const mockResponse: OpenAIResponse = {
|
||||
id: "resp_string_output",
|
||||
object: "response",
|
||||
created_at: 1710000000,
|
||||
model: "test-model",
|
||||
status: "completed",
|
||||
output: [
|
||||
{
|
||||
type: "message",
|
||||
role: "assistant",
|
||||
content: "Simple string output",
|
||||
},
|
||||
],
|
||||
input: [{ type: "message", content: "input" }],
|
||||
};
|
||||
|
||||
render(
|
||||
<ResponsesTable data={[mockResponse]} isLoading={false} error={null} />,
|
||||
);
|
||||
expect(screen.getByText("Simple string output")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("extracts text from array message content with output_text type", () => {
|
||||
const mockResponse: OpenAIResponse = {
|
||||
id: "resp_array_output",
|
||||
object: "response",
|
||||
created_at: 1710000000,
|
||||
model: "test-model",
|
||||
status: "completed",
|
||||
output: [
|
||||
{
|
||||
type: "message",
|
||||
role: "assistant",
|
||||
content: [
|
||||
{ type: "output_text", text: "Array output text" },
|
||||
{ type: "output_text", text: "Should not be used" },
|
||||
],
|
||||
},
|
||||
],
|
||||
input: [{ type: "message", content: "input" }],
|
||||
};
|
||||
|
||||
render(
|
||||
<ResponsesTable data={[mockResponse]} isLoading={false} error={null} />,
|
||||
);
|
||||
expect(screen.getByText("Array output text")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("formats function call output", () => {
|
||||
const mockResponse: OpenAIResponse = {
|
||||
id: "resp_function_call",
|
||||
object: "response",
|
||||
created_at: 1710000000,
|
||||
model: "test-model",
|
||||
status: "completed",
|
||||
output: [
|
||||
{
|
||||
type: "function_call",
|
||||
id: "call_123",
|
||||
status: "completed",
|
||||
name: "search_function",
|
||||
arguments: '{"query": "test"}',
|
||||
},
|
||||
],
|
||||
input: [{ type: "message", content: "input" }],
|
||||
};
|
||||
|
||||
render(
|
||||
<ResponsesTable data={[mockResponse]} isLoading={false} error={null} />,
|
||||
);
|
||||
expect(
|
||||
screen.getByText('search_function({"query": "test"})'),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("formats function call output without arguments", () => {
|
||||
const mockResponse: OpenAIResponse = {
|
||||
id: "resp_function_no_args",
|
||||
object: "response",
|
||||
created_at: 1710000000,
|
||||
model: "test-model",
|
||||
status: "completed",
|
||||
output: [
|
||||
{
|
||||
type: "function_call",
|
||||
id: "call_123",
|
||||
status: "completed",
|
||||
name: "simple_function",
|
||||
},
|
||||
],
|
||||
input: [{ type: "message", content: "input" }],
|
||||
};
|
||||
|
||||
render(
|
||||
<ResponsesTable data={[mockResponse]} isLoading={false} error={null} />,
|
||||
);
|
||||
expect(screen.getByText("simple_function({})")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("formats web search call output", () => {
|
||||
const mockResponse: OpenAIResponse = {
|
||||
id: "resp_web_search",
|
||||
object: "response",
|
||||
created_at: 1710000000,
|
||||
model: "test-model",
|
||||
status: "completed",
|
||||
output: [
|
||||
{
|
||||
type: "web_search_call",
|
||||
id: "search_123",
|
||||
status: "completed",
|
||||
},
|
||||
],
|
||||
input: [{ type: "message", content: "input" }],
|
||||
};
|
||||
|
||||
render(
|
||||
<ResponsesTable data={[mockResponse]} isLoading={false} error={null} />,
|
||||
);
|
||||
expect(
|
||||
screen.getByText("web_search_call(status: completed)"),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("falls back to JSON.stringify for unknown tool call types", () => {
|
||||
const mockResponse: OpenAIResponse = {
|
||||
id: "resp_unknown_tool",
|
||||
object: "response",
|
||||
created_at: 1710000000,
|
||||
model: "test-model",
|
||||
status: "completed",
|
||||
output: [
|
||||
{
|
||||
type: "unknown_call",
|
||||
id: "unknown_123",
|
||||
status: "completed",
|
||||
custom_field: "custom_value",
|
||||
} as any,
|
||||
],
|
||||
input: [{ type: "message", content: "input" }],
|
||||
};
|
||||
|
||||
render(
|
||||
<ResponsesTable data={[mockResponse]} isLoading={false} error={null} />,
|
||||
);
|
||||
// Should contain the JSON stringified version
|
||||
expect(screen.getByText(/unknown_call/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("falls back to JSON.stringify for entire output when no message or tool call found", () => {
|
||||
const mockResponse: OpenAIResponse = {
|
||||
id: "resp_fallback",
|
||||
object: "response",
|
||||
created_at: 1710000000,
|
||||
model: "test-model",
|
||||
status: "completed",
|
||||
output: [
|
||||
{
|
||||
type: "unknown_type",
|
||||
data: "some data",
|
||||
} as any,
|
||||
],
|
||||
input: [{ type: "message", content: "input" }],
|
||||
};
|
||||
|
||||
render(
|
||||
<ResponsesTable data={[mockResponse]} isLoading={false} error={null} />,
|
||||
);
|
||||
// Should contain the JSON stringified version of the output array
|
||||
expect(screen.getByText(/unknown_type/)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe("Text Truncation", () => {
|
||||
test("truncates long input and output text", () => {
|
||||
// Specific mock implementation for this test
|
||||
truncateText.mockImplementation(
|
||||
(text: string | undefined, maxLength?: number) => {
|
||||
const defaultTestMaxLength = 10;
|
||||
const effectiveMaxLength = maxLength ?? defaultTestMaxLength;
|
||||
return typeof text === "string" && text.length > effectiveMaxLength
|
||||
? text.slice(0, effectiveMaxLength) + "..."
|
||||
: text;
|
||||
},
|
||||
);
|
||||
|
||||
const longInput =
|
||||
"This is a very long input message that should be truncated.";
|
||||
const longOutput =
|
||||
"This is a very long output message that should also be truncated.";
|
||||
|
||||
const mockResponse: OpenAIResponse = {
|
||||
id: "resp_trunc",
|
||||
object: "response",
|
||||
created_at: 1710002000,
|
||||
model: "llama-trunc-model",
|
||||
status: "completed",
|
||||
output: [
|
||||
{
|
||||
type: "message",
|
||||
role: "assistant",
|
||||
content: longOutput,
|
||||
},
|
||||
],
|
||||
input: [
|
||||
{
|
||||
type: "message",
|
||||
role: "user",
|
||||
content: longInput,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
render(
|
||||
<ResponsesTable data={[mockResponse]} isLoading={false} error={null} />,
|
||||
);
|
||||
|
||||
// The truncated text should be present for both input and output
|
||||
const truncatedTexts = screen.getAllByText(
|
||||
longInput.slice(0, 10) + "...",
|
||||
);
|
||||
expect(truncatedTexts.length).toBe(2); // one for input, one for output
|
||||
truncatedTexts.forEach((textElement) =>
|
||||
expect(textElement).toBeInTheDocument(),
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
117
llama_stack/ui/components/responses/responses-table.tsx
Normal file
117
llama_stack/ui/components/responses/responses-table.tsx
Normal file
|
@ -0,0 +1,117 @@
|
|||
"use client";
|
||||
|
||||
import {
|
||||
OpenAIResponse,
|
||||
ResponseInput,
|
||||
ResponseInputMessageContent,
|
||||
} from "@/lib/types";
|
||||
import { LogsTable, LogTableRow } from "@/components/logs/logs-table";
|
||||
import {
|
||||
isMessageInput,
|
||||
isMessageItem,
|
||||
isFunctionCallItem,
|
||||
isWebSearchCallItem,
|
||||
MessageItem,
|
||||
FunctionCallItem,
|
||||
WebSearchCallItem,
|
||||
} from "./utils/item-types";
|
||||
|
||||
interface ResponsesTableProps {
|
||||
data: OpenAIResponse[];
|
||||
isLoading: boolean;
|
||||
error: Error | null;
|
||||
}
|
||||
|
||||
function getInputText(response: OpenAIResponse): string {
|
||||
const firstInput = response.input.find(isMessageInput);
|
||||
if (firstInput) {
|
||||
return extractContentFromItem(firstInput);
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
function getOutputText(response: OpenAIResponse): string {
|
||||
const firstMessage = response.output.find((item) =>
|
||||
isMessageItem(item as any),
|
||||
);
|
||||
if (firstMessage) {
|
||||
const content = extractContentFromItem(firstMessage as MessageItem);
|
||||
if (content) {
|
||||
return content;
|
||||
}
|
||||
}
|
||||
|
||||
const functionCall = response.output.find((item) =>
|
||||
isFunctionCallItem(item as any),
|
||||
);
|
||||
if (functionCall) {
|
||||
return formatFunctionCall(functionCall as FunctionCallItem);
|
||||
}
|
||||
|
||||
const webSearchCall = response.output.find((item) =>
|
||||
isWebSearchCallItem(item as any),
|
||||
);
|
||||
if (webSearchCall) {
|
||||
return formatWebSearchCall(webSearchCall as WebSearchCallItem);
|
||||
}
|
||||
|
||||
return JSON.stringify(response.output);
|
||||
}
|
||||
|
||||
function extractContentFromItem(item: {
|
||||
content?: string | ResponseInputMessageContent[];
|
||||
}): string {
|
||||
if (!item.content) {
|
||||
return "";
|
||||
}
|
||||
|
||||
if (typeof item.content === "string") {
|
||||
return item.content;
|
||||
} else if (Array.isArray(item.content)) {
|
||||
const textContent = item.content.find(
|
||||
(c: ResponseInputMessageContent) =>
|
||||
c.type === "input_text" || c.type === "output_text",
|
||||
);
|
||||
return textContent?.text || "";
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
function formatFunctionCall(functionCall: FunctionCallItem): string {
|
||||
const args = functionCall.arguments || "{}";
|
||||
const name = functionCall.name || "unknown";
|
||||
return `${name}(${args})`;
|
||||
}
|
||||
|
||||
function formatWebSearchCall(webSearchCall: WebSearchCallItem): string {
|
||||
return `web_search_call(status: ${webSearchCall.status})`;
|
||||
}
|
||||
|
||||
function formatResponseToRow(response: OpenAIResponse): LogTableRow {
|
||||
return {
|
||||
id: response.id,
|
||||
input: getInputText(response),
|
||||
output: getOutputText(response),
|
||||
model: response.model,
|
||||
createdTime: new Date(response.created_at * 1000).toLocaleString(),
|
||||
detailPath: `/logs/responses/${response.id}`,
|
||||
};
|
||||
}
|
||||
|
||||
export function ResponsesTable({
|
||||
data,
|
||||
isLoading,
|
||||
error,
|
||||
}: ResponsesTableProps) {
|
||||
const formattedData = data.map(formatResponseToRow);
|
||||
|
||||
return (
|
||||
<LogsTable
|
||||
data={formattedData}
|
||||
isLoading={isLoading}
|
||||
error={error}
|
||||
caption="A list of your recent responses."
|
||||
emptyMessage="No responses found."
|
||||
/>
|
||||
);
|
||||
}
|
61
llama_stack/ui/components/responses/utils/item-types.ts
Normal file
61
llama_stack/ui/components/responses/utils/item-types.ts
Normal file
|
@ -0,0 +1,61 @@
|
|||
/**
|
||||
* Type guards for different item types in responses
|
||||
*/
|
||||
|
||||
import type {
|
||||
ResponseInput,
|
||||
ResponseOutput,
|
||||
ResponseMessage,
|
||||
ResponseToolCall,
|
||||
} from "@/lib/types";
|
||||
|
||||
export interface BaseItem {
|
||||
type: string;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
export type MessageItem = ResponseMessage;
|
||||
export type FunctionCallItem = ResponseToolCall & { type: "function_call" };
|
||||
export type WebSearchCallItem = ResponseToolCall & { type: "web_search_call" };
|
||||
export type FunctionCallOutputItem = BaseItem & {
|
||||
type: "function_call_output";
|
||||
call_id: string;
|
||||
output?: string | object;
|
||||
};
|
||||
|
||||
export type AnyResponseItem =
|
||||
| ResponseInput
|
||||
| ResponseOutput
|
||||
| FunctionCallOutputItem;
|
||||
|
||||
export function isMessageInput(
|
||||
item: ResponseInput,
|
||||
): item is ResponseInput & { type: "message" } {
|
||||
return item.type === "message";
|
||||
}
|
||||
|
||||
export function isMessageItem(item: AnyResponseItem): item is MessageItem {
|
||||
return item.type === "message" && "content" in item;
|
||||
}
|
||||
|
||||
export function isFunctionCallItem(
|
||||
item: AnyResponseItem,
|
||||
): item is FunctionCallItem {
|
||||
return item.type === "function_call" && "name" in item;
|
||||
}
|
||||
|
||||
export function isWebSearchCallItem(
|
||||
item: AnyResponseItem,
|
||||
): item is WebSearchCallItem {
|
||||
return item.type === "web_search_call";
|
||||
}
|
||||
|
||||
export function isFunctionCallOutputItem(
|
||||
item: AnyResponseItem,
|
||||
): item is FunctionCallOutputItem {
|
||||
return (
|
||||
item.type === "function_call_output" &&
|
||||
"call_id" in item &&
|
||||
typeof (item as any).call_id === "string"
|
||||
);
|
||||
}
|
Loading…
Add table
Add a link
Reference in a new issue