Python: Improve DevUI, add Context Inspector view as new tab under traces (#2742)

* Improve DevUI, add Context Inspector view as new tab under traces

* fix mypy errors

* fix: Handle stale MCP connections in DevUI executor

MCP tools can become stale when HTTP streaming responses end - the underlying
stdio streams close but `is_connected` remains True. This causes subsequent
requests to fail with `ClosedResourceError`.

Add `_ensure_mcp_connections()` to detect and reconnect stale MCP tools before
agent execution. This is a workaround for an upstream Agent Framework issue
where connection state isn't properly tracked.

Fixes MCP tools failing on second HTTP request in DevUI.

fixes  #1476 #1515 #2865

* fix #1572 report import dependency errors more clearly

* Ensure there is streaming toggle where users can select streaming vs non streaming mode in devui . Fixes .NET: [Python] DevUI tool call rendering in non-streaming mode?

* remove unused dead code

* improve ux - workflows with agents show a chat component in execution timelien, also ensure magentic final output shows correctly

* update ui build

* update devui to use instrumentation instead of tracing, other instrumentation and type/instance check fixes
This commit is contained in:
Victor Dibia
2026-01-07 00:26:08 -08:00
committed by GitHub
Unverified
parent db283cd396
commit 2e1189ca65
36 changed files with 7430 additions and 1662 deletions
@@ -270,6 +270,7 @@ export function AgentView({ selectedAgent, onDebugEvent }: AgentViewProps) {
const conversationUsage = useDevUIStore((state) => state.conversationUsage);
const pendingApprovals = useDevUIStore((state) => state.pendingApprovals);
const oaiMode = useDevUIStore((state) => state.oaiMode);
const streamingEnabled = useDevUIStore((state) => state.streamingEnabled);
// Get conversation actions from Zustand (only the ones we actually use)
const setCurrentConversation = useDevUIStore((state) => state.setCurrentConversation);
@@ -570,6 +571,7 @@ export function AgentView({ selectedAgent, onDebugEvent }: AgentViewProps) {
let allItems: unknown[] = [];
let hasMore = true;
let after: string | undefined = undefined;
let storedTraces: unknown[] = [];
while (hasMore) {
const result = await apiClient.listConversationItems(
@@ -578,7 +580,12 @@ export function AgentView({ selectedAgent, onDebugEvent }: AgentViewProps) {
);
allItems = allItems.concat(result.data);
hasMore = result.has_more;
// Capture traces from metadata (only need from one response, they accumulate)
if (result.metadata?.traces && result.metadata.traces.length > 0) {
storedTraces = result.metadata.traces;
}
// Get the last item's ID for pagination
if (hasMore && result.data.length > 0) {
const lastItem = result.data[result.data.length - 1] as { id?: string };
@@ -590,6 +597,21 @@ export function AgentView({ selectedAgent, onDebugEvent }: AgentViewProps) {
setChatItems(allItems as import("@/types/openai").ConversationItem[]);
setIsStreaming(false);
// Restore stored traces as debug events for context inspection
if (storedTraces.length > 0) {
// Clear any previous debug events first
onDebugEvent("clear");
for (const trace of storedTraces) {
// Convert stored trace back to ResponseTraceComplete event format
const traceEvent: ExtendedResponseStreamEvent = {
type: "response.trace.completed",
data: trace as Record<string, unknown>,
sequence_number: 0, // Not used for display
};
onDebugEvent(traceEvent);
}
}
// Check for incomplete stream and resume if needed
const state = loadStreamingState(mostRecent.id);
@@ -724,6 +746,9 @@ export function AgentView({ selectedAgent, onDebugEvent }: AgentViewProps) {
useDevUIStore.setState({ conversationUsage: { total_tokens: 0, message_count: 0 } });
accumulatedTextRef.current = "";
// Clear debug panel for fresh conversation
onDebugEvent("clear");
// Update localStorage cache with new conversation
const cachedKey = `devui_convs_${selectedAgent.id}`;
const updated = [newConversation, ...availableConversations];
@@ -736,7 +761,7 @@ export function AgentView({ selectedAgent, onDebugEvent }: AgentViewProps) {
type: "conversation_creation_error",
});
}
}, [selectedAgent, setCurrentConversation, setAvailableConversations, setChatItems, setIsStreaming]);
}, [selectedAgent, onDebugEvent, setCurrentConversation, setAvailableConversations, setChatItems, setIsStreaming]);
// Handle conversation deletion
const handleDeleteConversation = useCallback(
@@ -843,6 +868,7 @@ export function AgentView({ selectedAgent, onDebugEvent }: AgentViewProps) {
let allItems: unknown[] = [];
let hasMore = true;
let after: string | undefined = undefined;
let storedTraces: unknown[] = [];
while (hasMore) {
const result = await apiClient.listConversationItems(conversationId, {
@@ -851,7 +877,12 @@ export function AgentView({ selectedAgent, onDebugEvent }: AgentViewProps) {
});
allItems = allItems.concat(result.data);
hasMore = result.has_more;
// Capture traces from metadata (only need from one response, they accumulate)
if (result.metadata?.traces && result.metadata.traces.length > 0) {
storedTraces = result.metadata.traces;
}
// Get the last item's ID for pagination
if (hasMore && result.data.length > 0) {
const lastItem = result.data[result.data.length - 1] as { id?: string };
@@ -865,6 +896,19 @@ export function AgentView({ selectedAgent, onDebugEvent }: AgentViewProps) {
setChatItems(items);
setIsStreaming(false);
// Restore stored traces as debug events for context inspection
if (storedTraces.length > 0) {
for (const trace of storedTraces) {
// Convert stored trace back to ResponseTraceComplete event format
const traceEvent: ExtendedResponseStreamEvent = {
type: "response.trace.completed",
data: trace as Record<string, unknown>,
sequence_number: 0, // Not used for display
};
onDebugEvent(traceEvent);
}
}
// Calculate usage from loaded items
useDevUIStore.setState({
conversationUsage: {
@@ -1249,13 +1293,15 @@ export function AgentView({ selectedAgent, onDebugEvent }: AgentViewProps) {
// Handle function calls as separate conversation items
if (item.type === "function_call") {
// Type assertion for function call - narrows from union type
const funcCall = item as import("@/types/openai").ResponseFunctionToolCall;
const functionCallItem: import("@/types/openai").ConversationFunctionCall = {
id: item.id || `call-${Date.now()}`,
id: funcCall.id || `call-${Date.now()}`,
type: "function_call",
name: item.name,
arguments: item.arguments || "",
call_id: item.call_id,
status: (item.status === "failed" || item.status === "cancelled" ? "incomplete" : item.status) || "in_progress",
name: funcCall.name,
arguments: funcCall.arguments || "",
call_id: funcCall.call_id,
status: funcCall.status || "in_progress",
created_at: Math.floor(Date.now() / 1000),
};
@@ -1414,6 +1460,209 @@ export function AgentView({ selectedAgent, onDebugEvent }: AgentViewProps) {
[selectedAgent, currentConversation, onDebugEvent, setChatItems, setIsStreaming, setCurrentConversation, setAvailableConversations, setPendingApprovals, updateConversationUsage, createAbortSignal, resetCancelling]
);
// Handle non-streaming message sending
const handleSendMessageSync = useCallback(
async (request: RunAgentRequest) => {
if (!selectedAgent) return;
// Check if this is a function approval response (internal, don't show in chat)
const isApprovalResponse = request.input.some(
(inputItem) =>
inputItem.type === "message" &&
Array.isArray(inputItem.content) &&
inputItem.content.some((c) => c.type === "function_approval_response")
);
// Extract content from OpenAI format to create ConversationMessage
const messageContent: import("@/types/openai").MessageContent[] = [];
// Parse OpenAI ResponseInputParam to extract content
for (const inputItem of request.input) {
if (inputItem.type === "message" && Array.isArray(inputItem.content)) {
for (const contentItem of inputItem.content) {
if (contentItem.type === "input_text") {
messageContent.push({
type: "text",
text: contentItem.text,
});
} else if (contentItem.type === "input_image") {
messageContent.push({
type: "input_image",
image_url: contentItem.image_url || "",
detail: "auto",
});
} else if (contentItem.type === "input_file") {
const fileItem = contentItem as import("@/types/agent-framework").ResponseInputFileParam;
messageContent.push({
type: "input_file",
file_data: fileItem.file_data,
filename: fileItem.filename,
});
}
}
}
}
// Capture timestamp once for both user and assistant messages
const messageTimestamp = Math.floor(Date.now() / 1000); // Unix seconds
// Only add user message to UI if it's not an approval response (internal messages)
if (!isApprovalResponse && messageContent.length > 0) {
const userMessage: import("@/types/openai").ConversationMessage = {
id: `user-${Date.now()}`,
type: "message",
role: "user",
content: messageContent,
status: "completed",
created_at: messageTimestamp,
};
setChatItems([...useDevUIStore.getState().chatItems, userMessage]);
}
// Show loading state (but not streaming indicator)
setIsSubmitting(true);
try {
// If no conversation selected, create one automatically
let conversationToUse = currentConversation;
if (!conversationToUse) {
try {
conversationToUse = await apiClient.createConversation({
agent_id: selectedAgent.id,
});
setCurrentConversation(conversationToUse);
setAvailableConversations([conversationToUse, ...useDevUIStore.getState().availableConversations]);
setConversationError(null);
} catch (error) {
const errorMessage = error instanceof Error ? error.message : "Failed to create conversation";
setConversationError({
message: errorMessage,
type: "conversation_creation_error",
});
setIsSubmitting(false);
return;
}
}
// Call non-streaming API
const response = await apiClient.runAgentSync(selectedAgent.id, {
input: request.input,
conversation_id: conversationToUse?.id,
});
// Extract content from response output
const assistantContent: import("@/types/openai").MessageContent[] = [];
const toolCalls: import("@/types/openai").ConversationFunctionCall[] = [];
const toolResults: import("@/types/openai").ConversationFunctionCallOutput[] = [];
if (response.output) {
for (const outputItem of response.output) {
if (outputItem.type === "message") {
// Extract message content
const msgItem = outputItem as import("@/types/openai").ResponseOutputMessage;
if (msgItem.content) {
for (const content of msgItem.content) {
if (content.type === "output_text") {
assistantContent.push({
type: "text",
text: (content as { text: string }).text,
} as import("@/types/openai").MessageTextContent);
} else if (content.type === "output_image") {
assistantContent.push(content as unknown as import("@/types/openai").MessageOutputImage);
} else if (content.type === "output_file") {
assistantContent.push(content as unknown as import("@/types/openai").MessageOutputFile);
} else if (content.type === "output_data") {
assistantContent.push(content as unknown as import("@/types/openai").MessageOutputData);
}
}
}
} else if (outputItem.type === "function_call") {
const funcCall = outputItem as unknown as import("@/types/openai").ResponseFunctionToolCall;
toolCalls.push({
id: funcCall.id || `call-${Date.now()}`,
type: "function_call",
name: funcCall.name,
arguments: funcCall.arguments || "",
call_id: funcCall.call_id,
status: funcCall.status || "completed",
created_at: messageTimestamp,
});
} else if (outputItem.type === "function_call_output") {
const resultItem = outputItem as unknown as { call_id: string; output: string };
toolResults.push({
id: `result-${Date.now()}`,
type: "function_call_output",
call_id: resultItem.call_id,
output: resultItem.output,
status: "completed",
created_at: messageTimestamp,
});
}
}
}
// Create assistant message with all content
const assistantMessage: import("@/types/openai").ConversationMessage = {
id: `assistant-${Date.now()}`,
type: "message",
role: "assistant",
content: assistantContent,
status: "completed",
created_at: messageTimestamp,
usage: response.usage ? {
input_tokens: response.usage.input_tokens,
output_tokens: response.usage.output_tokens,
total_tokens: response.usage.total_tokens,
} : undefined,
};
// Add all items to chat
const currentItems = useDevUIStore.getState().chatItems;
const newItems: import("@/types/openai").ConversationItem[] = [
...currentItems,
assistantMessage,
...toolCalls,
...toolResults,
];
setChatItems(newItems);
// Update conversation-level usage stats
if (response.usage) {
updateConversationUsage(response.usage.total_tokens);
}
// Send debug event with response completed
onDebugEvent({
type: "response.completed",
response: response,
sequence_number: 0,
} as ExtendedResponseStreamEvent);
} catch (error) {
// Show error message
const errorMessage = error instanceof Error ? error.message : "Failed to get response";
const assistantMessage: import("@/types/openai").ConversationMessage = {
id: `assistant-${Date.now()}`,
type: "message",
role: "assistant",
content: [{
type: "text",
text: `Error: ${errorMessage}`,
} as import("@/types/openai").MessageTextContent],
status: "incomplete",
created_at: messageTimestamp,
};
const currentItems = useDevUIStore.getState().chatItems;
setChatItems([...currentItems, assistantMessage]);
} finally {
setIsSubmitting(false);
}
},
[selectedAgent, currentConversation, onDebugEvent, setChatItems, setCurrentConversation, setAvailableConversations, updateConversationUsage, setIsSubmitting]
);
// Handle message submission from ChatMessageInput
const handleChatInputSubmit = async (content: import("@/types/agent-framework").ResponseInputContent[]) => {
@@ -1435,11 +1684,17 @@ export function AgentView({ selectedAgent, onDebugEvent }: AgentViewProps) {
},
];
// Use pure OpenAI format
await handleSendMessage({
const request = {
input: openaiInput,
conversation_id: currentConversation?.id,
});
};
// Use streaming or non-streaming based on setting
if (streamingEnabled) {
await handleSendMessage(request);
} else {
await handleSendMessageSync(request);
}
} finally {
setIsSubmitting(false);
}
@@ -0,0 +1,949 @@
/**
* ContextInspector - Token usage visualization and context analysis
*
* Features:
* - Stacked bar chart showing input/output tokens per turn
* - Composition view showing what fills the context (system, user, assistant, tools)
* - Per-turn vs cumulative modes
* - Summary statistics (total, average, peak)
* - Pure CSS visualization (no external charting library)
*/
import { useState, useMemo } from "react";
import { useDevUIStore } from "@/stores/devuiStore";
import {
BarChart3,
Layers,
Info,
ChevronDown,
ChevronRight,
} from "lucide-react";
import { Badge } from "@/components/ui/badge";
import { Checkbox } from "@/components/ui/checkbox";
import { ScrollArea } from "@/components/ui/scroll-area";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@/components/ui/tooltip";
import type { ExtendedResponseStreamEvent } from "@/types";
import {
TraceAttributes,
type TypedTraceAttributes,
type TraceMessage,
parseTraceMessages,
isTextPart,
isToolCallPart,
isToolResultPart,
} from "@/types/openai";
// Trace data interface matching debug-panel types
interface TraceEventData {
operation_name?: string;
duration_ms?: number;
status?: string;
attributes?: TypedTraceAttributes;
span_id?: string;
trace_id?: string;
parent_span_id?: string | null;
start_time?: number;
end_time?: number;
entity_id?: string;
response_id?: string | null;
}
// Context composition breakdown
interface ContextComposition {
system: number; // character count
user: number;
assistant: number;
toolCalls: number; // function definitions + arguments
toolResults: number; // function outputs
total: number;
}
// Turn data extracted from traces
interface TurnData {
response_id: string;
timestamp: number;
input_tokens: number;
output_tokens: number;
total_tokens: number;
model?: string;
entity_id?: string;
duration_ms: number;
composition: ContextComposition;
}
// Props for the component
interface ContextInspectorProps {
events: ExtendedResponseStreamEvent[];
}
// Parse message content to extract composition using typed TraceMessage format
function parseComposition(messagesJson: string | unknown): ContextComposition {
const composition: ContextComposition = {
system: 0,
user: 0,
assistant: 0,
toolCalls: 0,
toolResults: 0,
total: 0,
};
try {
// Use the typed parser for string input
let messages: TraceMessage[];
if (typeof messagesJson === "string") {
messages = parseTraceMessages(messagesJson);
} else if (Array.isArray(messagesJson)) {
messages = messagesJson as TraceMessage[];
} else {
return composition;
}
for (const message of messages) {
if (!message || typeof message !== "object") continue;
const role = message.role;
const parts = message.parts;
// Calculate character count for this message
let charCount = 0;
// Handle parts array (Agent Framework format)
// Using type guards for type-safe access to part properties
if (Array.isArray(parts)) {
for (const part of parts) {
if (!part || typeof part !== "object") continue;
if (isTextPart(part)) {
// Text content can be in either 'content' or 'text' field
const text = part.content || part.text || "";
charCount += text.length;
} else if (isToolCallPart(part)) {
// Tool call includes name and arguments
const name = part.name || "";
const args = part.arguments || "";
composition.toolCalls += name.length + args.length;
} else if (isToolResultPart(part)) {
// Tool result - check both 'result' and 'response' fields
const result = part.result || part.response || "";
composition.toolResults += result.length;
}
}
}
// Categorize by role
if (role === "system") {
composition.system += charCount;
} else if (role === "user") {
composition.user += charCount;
} else if (role === "assistant") {
composition.assistant += charCount;
} else if (role === "tool") {
composition.toolResults += charCount;
}
}
composition.total =
composition.system +
composition.user +
composition.assistant +
composition.toolCalls +
composition.toolResults;
} catch {
// Parsing failed, return empty composition
}
return composition;
}
// Extract turn data from trace events
function extractTurnData(events: ExtendedResponseStreamEvent[]): TurnData[] {
const traceEvents = events.filter(e => e.type === "response.trace.completed");
// Group by response_id
const byResponseId = new Map<string, TraceEventData[]>();
for (const event of traceEvents) {
if (!("data" in event)) continue;
const data = event.data as TraceEventData;
const responseId = data.response_id || "unknown";
if (!byResponseId.has(responseId)) {
byResponseId.set(responseId, []);
}
byResponseId.get(responseId)!.push(data);
}
const turns: TurnData[] = [];
for (const [responseId, traces] of byResponseId) {
let inputTokens = 0;
let outputTokens = 0;
let model: string | undefined;
let timestamp = Date.now() / 1000;
let entity_id: string | undefined;
let totalDuration = 0;
let composition: ContextComposition = {
system: 0, user: 0, assistant: 0, toolCalls: 0, toolResults: 0, total: 0
};
for (const trace of traces) {
const attrs = trace.attributes || {};
// Get token counts using typed attribute keys
const traceInput = attrs[TraceAttributes.INPUT_TOKENS];
const traceOutput = attrs[TraceAttributes.OUTPUT_TOKENS];
if (traceInput !== undefined) {
inputTokens += Number(traceInput);
}
if (traceOutput !== undefined) {
outputTokens += Number(traceOutput);
}
// Get model using typed attribute key
if (attrs[TraceAttributes.MODEL]) {
model = String(attrs[TraceAttributes.MODEL]);
}
// Get timestamp
if (trace.start_time && trace.start_time < timestamp) {
timestamp = trace.start_time;
}
// Get entity_id
if (trace.entity_id) {
entity_id = trace.entity_id;
}
// Sum durations
if (trace.duration_ms) {
totalDuration += Number(trace.duration_ms);
}
// Parse composition from input messages using typed attribute key
const inputMessages = attrs[TraceAttributes.INPUT_MESSAGES];
if (inputMessages && composition.total === 0) {
composition = parseComposition(inputMessages);
}
// Also check for system instructions using typed attribute key
const systemInstructions = attrs[TraceAttributes.SYSTEM_INSTRUCTIONS];
if (systemInstructions && typeof systemInstructions === "string" && composition.system === 0) {
composition.system = systemInstructions.length;
composition.total += systemInstructions.length;
}
}
// Only include turns that have token data
if (inputTokens > 0 || outputTokens > 0) {
turns.push({
response_id: responseId,
timestamp,
input_tokens: inputTokens,
output_tokens: outputTokens,
total_tokens: inputTokens + outputTokens,
model,
entity_id,
duration_ms: totalDuration,
composition,
});
}
}
// Sort by timestamp (oldest first)
turns.sort((a, b) => a.timestamp - b.timestamp);
return turns;
}
// Calculate summary stats
function calculateStats(turns: TurnData[]) {
if (turns.length === 0) {
return {
totalInput: 0,
totalOutput: 0,
totalTokens: 0,
avgInput: 0,
avgOutput: 0,
avgTotal: 0,
peakInput: 0,
peakOutput: 0,
peakTotal: 0,
turnCount: 0,
};
}
const totalInput = turns.reduce((sum, t) => sum + t.input_tokens, 0);
const totalOutput = turns.reduce((sum, t) => sum + t.output_tokens, 0);
const totalTokens = totalInput + totalOutput;
const peakInput = Math.max(...turns.map(t => t.input_tokens));
const peakOutput = Math.max(...turns.map(t => t.output_tokens));
const peakTotal = Math.max(...turns.map(t => t.total_tokens));
return {
totalInput,
totalOutput,
totalTokens,
avgInput: Math.round(totalInput / turns.length),
avgOutput: Math.round(totalOutput / turns.length),
avgTotal: Math.round(totalTokens / turns.length),
peakInput,
peakOutput,
peakTotal,
turnCount: turns.length,
};
}
// Aggregate composition across all turns
function aggregateComposition(turns: TurnData[]): ContextComposition {
return turns.reduce(
(acc, turn) => ({
system: acc.system + turn.composition.system,
user: acc.user + turn.composition.user,
assistant: acc.assistant + turn.composition.assistant,
toolCalls: acc.toolCalls + turn.composition.toolCalls,
toolResults: acc.toolResults + turn.composition.toolResults,
total: acc.total + turn.composition.total,
}),
{ system: 0, user: 0, assistant: 0, toolCalls: 0, toolResults: 0, total: 0 }
);
}
// Format large numbers with K suffix
function formatTokenCount(n: number): string {
if (n >= 1000) {
return `${(n / 1000).toFixed(1)}k`;
}
return String(n);
}
// Color constants - single source of truth for all visualizations
const SEGMENT_COLORS = {
// Token segments
input: "bg-blue-500 dark:bg-blue-600",
output: "bg-emerald-500 dark:bg-emerald-600",
// Composition segments
system: "bg-purple-500 dark:bg-purple-600",
user: "bg-blue-500 dark:bg-blue-600",
assistant: "bg-emerald-500 dark:bg-emerald-600",
toolCalls: "bg-amber-500 dark:bg-amber-600",
toolResults: "bg-orange-500 dark:bg-orange-600",
} as const;
// Segment definition for the unified bar component
interface BarSegment {
key: string;
value: number;
color: string;
label: string;
}
// Unified segmented bar component with tooltips
// Replaces both TokenBar and CompositionBar for consistency and maintainability
function SegmentedBar({
segments,
maxValue,
height = 20,
renderLabel,
}: {
segments: BarSegment[];
maxValue: number;
height?: number;
renderLabel?: (total: number, segments: BarSegment[]) => React.ReactNode;
}) {
const total = segments.reduce((sum, s) => sum + s.value, 0);
if (total === 0) {
return (
<div className="flex items-center gap-2 w-full">
<div
className="rounded bg-muted/30 flex-1"
style={{ height: `${height}px` }}
/>
</div>
);
}
// When maxValue is 0, use full width (100%) - focus on ratios within the bar
// When maxValue > 0, scale relative to max - focus on size comparison
const widthPercent = maxValue > 0 ? (total / maxValue) * 100 : 100;
// Pre-compute segment metadata for tooltips
const segmentsWithMeta = segments
.filter(s => s.value > 0)
.map(seg => ({
...seg,
percent: Math.round((seg.value / total) * 100),
}));
return (
<div className="flex items-center gap-2 w-full">
<div
className="relative rounded overflow-hidden bg-muted/30 flex-1"
style={{ height: `${height}px` }}
>
<TooltipProvider delayDuration={150}>
<div
className="h-full flex transition-all duration-300"
style={{ width: `${widthPercent}%` }}
>
{segmentsWithMeta.map((seg) => (
<Tooltip key={seg.key}>
<TooltipTrigger asChild>
<div
className={`h-full ${seg.color} transition-all duration-150 hover:brightness-110 hover:scale-y-[1.15] origin-bottom cursor-default`}
style={{ width: `${(seg.value / total) * 100}%` }}
/>
</TooltipTrigger>
<TooltipContent side="top" className="text-xs">
<div className="flex items-center gap-1.5">
<div className={`w-2 h-2 rounded-sm ${seg.color} flex-shrink-0`} />
<span className="font-medium">{seg.label}</span>
<span className="opacity-80">{formatTokenCount(seg.value)} ({seg.percent}%)</span>
</div>
</TooltipContent>
</Tooltip>
))}
</div>
</TooltipProvider>
</div>
{renderLabel?.(total, segments)}
</div>
);
}
// Helper to create token segments (input/output)
function createTokenSegments(input: number, output: number): BarSegment[] {
return [
{ key: "input", value: input, color: SEGMENT_COLORS.input, label: "Input" },
{ key: "output", value: output, color: SEGMENT_COLORS.output, label: "Output" },
];
}
// Helper to create composition segments
function createCompositionSegments(composition: ContextComposition): BarSegment[] {
return [
{ key: "system", value: composition.system, color: SEGMENT_COLORS.system, label: "System" },
{ key: "user", value: composition.user, color: SEGMENT_COLORS.user, label: "User" },
{ key: "assistant", value: composition.assistant, color: SEGMENT_COLORS.assistant, label: "Assistant" },
{ key: "toolCalls", value: composition.toolCalls, color: SEGMENT_COLORS.toolCalls, label: "Tool Calls" },
{ key: "toolResults", value: composition.toolResults, color: SEGMENT_COLORS.toolResults, label: "Tool Results" },
];
}
// Composition breakdown list
function CompositionBreakdown({
composition,
className = "",
}: {
composition: ContextComposition;
className?: string;
}) {
const { system, user, assistant, toolCalls, toolResults, total } = composition;
if (total === 0) {
return (
<div className={`text-xs text-muted-foreground ${className}`}>
No composition data available
</div>
);
}
const items = [
{ label: "System", value: system, color: SEGMENT_COLORS.system },
{ label: "User", value: user, color: SEGMENT_COLORS.user },
{ label: "Assistant", value: assistant, color: SEGMENT_COLORS.assistant },
{ label: "Tool Calls", value: toolCalls, color: SEGMENT_COLORS.toolCalls },
{ label: "Tool Results", value: toolResults, color: SEGMENT_COLORS.toolResults },
].filter(item => item.value > 0);
return (
<div className={`space-y-1.5 ${className}`}>
{items.map((item) => {
const percent = Math.round((item.value / total) * 100);
return (
<div key={item.label} className="flex items-center gap-2 text-xs">
<div className={`w-2 h-2 rounded-sm ${item.color}`} />
<span className="text-muted-foreground w-20">{item.label}</span>
<div className="flex-1 h-3 bg-muted/30 rounded overflow-hidden">
<div
className={`h-full ${item.color} transition-all duration-300`}
style={{ width: `${percent}%` }}
/>
</div>
<span className="font-mono w-10 text-right text-muted-foreground">
{percent}%
</span>
</div>
);
})}
</div>
);
}
// Turn row component
function TurnRow({
turn,
index,
maxValue,
maxCompositionValue,
cumulativeInput,
cumulativeOutput,
cumulativeComposition,
showCumulative,
viewMode,
}: {
turn: TurnData;
index: number;
maxValue: number;
maxCompositionValue: number;
cumulativeInput: number;
cumulativeOutput: number;
cumulativeComposition: ContextComposition;
showCumulative: boolean;
viewMode: "tokens" | "composition";
}) {
const [isExpanded, setIsExpanded] = useState(false);
const displayInput = showCumulative ? cumulativeInput : turn.input_tokens;
const displayOutput = showCumulative ? cumulativeOutput : turn.output_tokens;
const displayComposition = showCumulative ? cumulativeComposition : turn.composition;
const timestamp = new Date(turn.timestamp * 1000).toLocaleTimeString([], {
hour: "2-digit",
minute: "2-digit",
second: "2-digit",
});
return (
<div className="border-b border-muted/50 last:border-0">
<div
className="flex items-center gap-3 py-2 px-2 hover:bg-muted/30 cursor-pointer transition-colors"
onClick={() => setIsExpanded(!isExpanded)}
>
{/* Turn number */}
<div className="w-6 h-6 rounded-full bg-muted flex items-center justify-center text-xs font-medium flex-shrink-0">
{index + 1}
</div>
{/* Bar */}
<div className="flex-1 min-w-0">
{viewMode === "tokens" ? (
<SegmentedBar
segments={createTokenSegments(displayInput, displayOutput)}
maxValue={maxValue}
height={20}
renderLabel={(_, segs) => (
<div className="flex items-center gap-1 text-xs font-mono text-muted-foreground min-w-[80px] justify-end">
<span className="text-blue-600 dark:text-blue-400">{formatTokenCount(segs[0]?.value || 0)}</span>
<span>/</span>
<span className="text-emerald-600 dark:text-emerald-400">{formatTokenCount(segs[1]?.value || 0)}</span>
</div>
)}
/>
) : (
<SegmentedBar
segments={createCompositionSegments(displayComposition)}
maxValue={maxCompositionValue}
height={20}
renderLabel={(total) => (
<div className="text-xs font-mono text-muted-foreground min-w-[50px] text-right">
{formatTokenCount(Math.round(total / 4))}~
</div>
)}
/>
)}
</div>
{/* Expand icon */}
<div className="text-muted-foreground flex-shrink-0">
{isExpanded ? (
<ChevronDown className="h-4 w-4" />
) : (
<ChevronRight className="h-4 w-4" />
)}
</div>
</div>
{/* Expanded details */}
{isExpanded && (
<div className="pb-3">
{/* Connector line */}
<div className="flex items-start gap-3 px-2">
<div className="w-6 flex justify-center flex-shrink-0">
<div className="w-px h-full bg-muted" />
</div>
<div className="flex-1 min-w-0">
{/* L-connector and composition */}
<div className="flex items-start gap-2">
<div className="text-muted-foreground text-xs mt-1"></div>
<div className="flex-1 space-y-3">
{/* Basic info */}
<div className="grid grid-cols-2 gap-x-4 gap-y-1 text-xs text-muted-foreground">
<div>Time: <span className="font-mono text-foreground">{timestamp}</span></div>
<div>Duration: <span className="font-mono text-foreground">{turn.duration_ms.toFixed(0)}ms</span></div>
{turn.model && (
<div>Model: <span className="font-mono text-foreground">{turn.model}</span></div>
)}
{turn.entity_id && (
<div>Entity: <span className="font-mono text-foreground">{turn.entity_id}</span></div>
)}
</div>
{/* Token counts - shown in tokens mode */}
{viewMode === "tokens" && (
<div className="flex gap-4 text-xs">
<div>
<span className="text-blue-600 dark:text-blue-400">Input:</span>{" "}
<span className="font-mono">{turn.input_tokens.toLocaleString()}</span>
</div>
<div>
<span className="text-emerald-600 dark:text-emerald-400">Output:</span>{" "}
<span className="font-mono">{turn.output_tokens.toLocaleString()}</span>
</div>
<div>
<span className="text-muted-foreground">Total:</span>{" "}
<span className="font-mono">{turn.total_tokens.toLocaleString()}</span>
</div>
</div>
)}
{/* Composition breakdown - shown in composition mode */}
{viewMode === "composition" && turn.composition.total > 0 && (
<div>
<div className="text-xs text-muted-foreground mb-2 flex items-center gap-1">
<Info className="h-3 w-3" />
Context Composition (estimated from ~{formatTokenCount(Math.round(turn.composition.total / 4))} tokens)
</div>
<CompositionBreakdown composition={turn.composition} />
</div>
)}
</div>
</div>
</div>
</div>
</div>
)}
</div>
);
}
// Summary stats card
function StatCard({
label,
value,
icon: Icon,
color = "default",
}: {
label: string;
value: string | number;
icon: typeof BarChart3;
color?: "default" | "blue" | "green";
}) {
const colorClass = {
default: "text-muted-foreground",
blue: "text-blue-600 dark:text-blue-400",
green: "text-emerald-600 dark:text-emerald-400",
}[color];
return (
<div className="flex items-center gap-2 p-2 bg-muted/30 rounded">
<Icon className={`h-4 w-4 ${colorClass}`} />
<div className="flex-1 min-w-0">
<div className="text-xs text-muted-foreground truncate">{label}</div>
<div className="font-mono text-sm font-medium">{value}</div>
</div>
</div>
);
}
// Main component
export function ContextInspector({ events }: ContextInspectorProps) {
// Use persisted store state instead of local useState
const viewMode = useDevUIStore((state) => state.contextInspectorViewMode);
const setViewMode = useDevUIStore((state) => state.setContextInspectorViewMode);
const showCumulative = useDevUIStore((state) => state.contextInspectorCumulative);
const setShowCumulative = useDevUIStore((state) => state.setContextInspectorCumulative);
// Extract turn data from traces
const turns = useMemo(() => extractTurnData(events), [events]);
// Calculate stats
const stats = useMemo(() => calculateStats(turns), [turns]);
// Aggregate composition
const totalComposition = useMemo(() => aggregateComposition(turns), [turns]);
// Calculate max value for bar scaling (tokens)
// In non-cumulative mode, use 0 to signal full-width bars (focus on ratios)
// In cumulative mode, scale relative to total (focus on growth)
const maxValue = useMemo(() => {
if (turns.length === 0) return 0;
if (showCumulative) {
return stats.totalTokens;
} else {
// Return 0 to signal "use full width" - each bar shows its own ratio
return 0;
}
}, [turns, showCumulative, stats.totalTokens]);
// Calculate max value for composition bar scaling
// Same logic: full-width in non-cumulative, scaled in cumulative
const maxCompositionValue = useMemo(() => {
if (turns.length === 0) return 0;
if (showCumulative) {
return totalComposition.total;
} else {
// Return 0 to signal "use full width"
return 0;
}
}, [turns, showCumulative, totalComposition.total]);
// Calculate cumulative values for tokens and composition
const cumulativeData = useMemo(() => {
let cumInput = 0;
let cumOutput = 0;
let cumComposition: ContextComposition = {
system: 0, user: 0, assistant: 0, toolCalls: 0, toolResults: 0, total: 0
};
return turns.map(t => {
cumInput += t.input_tokens;
cumOutput += t.output_tokens;
cumComposition = {
system: cumComposition.system + t.composition.system,
user: cumComposition.user + t.composition.user,
assistant: cumComposition.assistant + t.composition.assistant,
toolCalls: cumComposition.toolCalls + t.composition.toolCalls,
toolResults: cumComposition.toolResults + t.composition.toolResults,
total: cumComposition.total + t.composition.total,
};
return {
input: cumInput,
output: cumOutput,
composition: { ...cumComposition }
};
});
}, [turns]);
// No data state
if (turns.length === 0) {
return (
<div className="flex flex-col items-center text-center p-6 pt-9">
<BarChart3 className="h-8 w-8 text-muted-foreground mb-3" />
<div className="text-sm font-medium mb-1">No Data</div>
<div className="text-xs text-muted-foreground max-w-[200px]">
Run{" "}
<span className="font-mono bg-accent/10 px-1 rounded">
devui --instrumentation
</span>{" "}
and start a conversation.
</div>
</div>
);
}
return (
<div className="h-full flex flex-col">
{/* Header */}
<div className="p-3 border-b flex-shrink-0 space-y-2">
{/* Title row */}
<div className="flex items-center justify-between gap-2">
<div className="flex items-center gap-2">
<BarChart3 className="h-4 w-4" />
<span className="font-medium text-sm">Context Inspector</span>
<Badge variant="outline" className="text-xs">
{turns.length} turn{turns.length !== 1 ? "s" : ""}
</Badge>
</div>
{/* Cumulative checkbox */}
<label className="flex items-center gap-1.5 text-xs text-muted-foreground cursor-pointer">
<Checkbox
checked={showCumulative}
onCheckedChange={(checked) => setShowCumulative(checked === true)}
className="h-3.5 w-3.5"
/>
<span>Cumulative</span>
</label>
</div>
{/* View mode segmented control */}
<div className="flex items-center bg-muted rounded-md p-1">
<button
onClick={() => setViewMode("tokens")}
className={`flex-1 px-3 py-1.5 text-xs rounded transition-colors ${
viewMode === "tokens"
? "bg-background shadow-sm font-medium"
: "text-muted-foreground hover:text-foreground"
}`}
>
Tokens
</button>
<button
onClick={() => setViewMode("composition")}
className={`flex-1 px-3 py-1.5 text-xs rounded transition-colors ${
viewMode === "composition"
? "bg-background shadow-sm font-medium"
: "text-muted-foreground hover:text-foreground"
}`}
>
Composition
</button>
</div>
{/* View mode description */}
<div className="text-xs text-muted-foreground">
{viewMode === "tokens"
? "Token usage per turn"
: "Context breakdown by message type (chars)"}
</div>
</div>
<ScrollArea className="flex-1">
<div className="p-3 space-y-4">
{/* Legend */}
<div className="flex items-center gap-4 text-xs px-1 flex-wrap">
{viewMode === "tokens" ? (
<>
<div className="flex items-center gap-1.5">
<div className={`w-3 h-3 rounded ${SEGMENT_COLORS.input}`} />
<span className="text-muted-foreground">Input ()</span>
</div>
<div className="flex items-center gap-1.5">
<div className={`w-3 h-3 rounded ${SEGMENT_COLORS.output}`} />
<span className="text-muted-foreground">Output ()</span>
</div>
</>
) : (
<>
<div className="flex items-center gap-1.5">
<div className={`w-2.5 h-2.5 rounded-sm ${SEGMENT_COLORS.system}`} />
<span className="text-muted-foreground">System</span>
</div>
<div className="flex items-center gap-1.5">
<div className={`w-2.5 h-2.5 rounded-sm ${SEGMENT_COLORS.user}`} />
<span className="text-muted-foreground">User</span>
</div>
<div className="flex items-center gap-1.5">
<div className={`w-2.5 h-2.5 rounded-sm ${SEGMENT_COLORS.assistant}`} />
<span className="text-muted-foreground">Assistant</span>
</div>
<div className="flex items-center gap-1.5">
<div className={`w-2.5 h-2.5 rounded-sm ${SEGMENT_COLORS.toolCalls}`} />
<span className="text-muted-foreground">Tools</span>
</div>
<div className="flex items-center gap-1.5">
<div className={`w-2.5 h-2.5 rounded-sm ${SEGMENT_COLORS.toolResults}`} />
<span className="text-muted-foreground">Results</span>
</div>
</>
)}
<div className="flex-1" />
<div className="flex items-center gap-1 text-muted-foreground">
<Info className="h-3 w-3" />
<span>Click for details</span>
</div>
</div>
{/* Turn bars */}
<div className="border rounded-lg overflow-hidden">
{turns.map((turn, index) => (
<TurnRow
key={turn.response_id}
turn={turn}
index={index}
maxValue={maxValue}
maxCompositionValue={maxCompositionValue}
cumulativeInput={cumulativeData[index]?.input || 0}
cumulativeOutput={cumulativeData[index]?.output || 0}
cumulativeComposition={cumulativeData[index]?.composition || turn.composition}
showCumulative={showCumulative}
viewMode={viewMode}
/>
))}
</div>
{/* Session summary */}
<div className="border rounded-lg overflow-hidden">
<div className="p-3 bg-muted/30 border-b">
<span className="text-xs font-medium">Session Summary</span>
</div>
<div className="p-3 space-y-3">
{/* Token summary cards */}
<div className="grid grid-cols-3 gap-2">
<StatCard
label="Total Tokens"
value={formatTokenCount(stats.totalTokens)}
icon={Layers}
/>
<StatCard
label="Input"
value={formatTokenCount(stats.totalInput)}
icon={BarChart3}
color="blue"
/>
<StatCard
label="Output"
value={formatTokenCount(stats.totalOutput)}
icon={BarChart3}
color="green"
/>
</div>
{/* Per-turn statistics (only for multi-turn sessions) */}
{turns.length > 1 && (
<div className="grid grid-cols-2 gap-x-4 gap-y-1 text-xs pt-2 border-t border-muted/50">
<div className="flex justify-between">
<span className="text-muted-foreground">Avg per turn:</span>
<span className="font-mono">{formatTokenCount(stats.avgTotal)}</span>
</div>
<div className="flex justify-between">
<span className="text-muted-foreground">Peak turn:</span>
<span className="font-mono">{formatTokenCount(stats.peakTotal)}</span>
</div>
<div className="flex justify-between">
<span className="text-muted-foreground">Avg input:</span>
<span className="font-mono text-blue-600 dark:text-blue-400">{formatTokenCount(stats.avgInput)}</span>
</div>
<div className="flex justify-between">
<span className="text-muted-foreground">Avg output:</span>
<span className="font-mono text-emerald-600 dark:text-emerald-400">{formatTokenCount(stats.avgOutput)}</span>
</div>
</div>
)}
{/* Total composition */}
{totalComposition.total > 0 && (
<div className="pt-3 border-t border-muted/50">
<div className="flex items-start gap-2">
<div className="text-muted-foreground text-xs mt-0.5"></div>
<div className="flex-1">
<div className="text-xs text-muted-foreground mb-2 flex items-center gap-1">
<Info className="h-3 w-3" />
Total Composition (all turns)
</div>
<CompositionBreakdown composition={totalComposition} />
</div>
</div>
</div>
)}
</div>
</div>
</div>
</ScrollArea>
</div>
);
}
@@ -133,10 +133,10 @@ function useBase64ToBlobUrl(data: string | undefined, mimeType: string): string
function FileContentRenderer({ content, className }: ContentRendererProps) {
const [isExpanded, setIsExpanded] = useState(true);
if (content.type !== "input_file" && content.type !== "output_file") return null;
const fileUrl = content.file_url || content.file_data;
const filename = content.filename || "file";
// Determine file properties (must be before hooks for conditional logic)
const isFileContent = content.type === "input_file" || content.type === "output_file";
const fileUrl = isFileContent ? (content.file_url || content.file_data) : undefined;
const filename = isFileContent ? (content.filename || "file") : undefined;
// Determine file type from filename or data URI
const isPdf = filename?.toLowerCase().endsWith(".pdf") || fileUrl?.includes("application/pdf");
@@ -144,9 +144,13 @@ function FileContentRenderer({ content, className }: ContentRendererProps) {
// Convert base64 to blob URL for PDFs (better browser compatibility)
// Use file_data (raw base64) if available, otherwise try file_url
const pdfData = isPdf ? (content.file_data || content.file_url) : undefined;
// Hook must be called unconditionally - pass undefined if not a PDF
const pdfData = (isFileContent && isPdf) ? (content.file_data || content.file_url) : undefined;
const pdfBlobUrl = useBase64ToBlobUrl(pdfData, 'application/pdf');
// Early return after all hooks
if (!isFileContent) return null;
// Use blob URL if available, otherwise fall back to original URL
const effectivePdfUrl = pdfBlobUrl || fileUrl;
@@ -299,9 +303,12 @@ function DataContentRenderer({ content, className }: ContentRendererProps) {
// Function approval request renderer - compact version
function FunctionApprovalRequestRenderer({ content, className }: ContentRendererProps) {
// Hooks must be called unconditionally
const [isExpanded, setIsExpanded] = useState(false);
// Early return after hooks
if (content.type !== "function_approval_request") return null;
const [isExpanded, setIsExpanded] = useState(false);
const { status, function_call } = content;
// Status styling - compact
@@ -23,7 +23,7 @@ import {
} from "lucide-react";
import { cn } from "@/lib/utils";
import { apiClient } from "@/services/api";
import type { CheckpointItem, WorkflowSession } from "@/types";
import type { CheckpointItem, WorkflowSession, FullCheckpoint, PendingRequestInfoEvent } from "@/types";
interface CheckpointInfoModalProps {
session: WorkflowSession | null;
@@ -39,7 +39,7 @@ export function CheckpointInfoModal({
onOpenChange,
}: CheckpointInfoModalProps) {
const [selectedCheckpointId, setSelectedCheckpointId] = useState<string | null>(null);
const [fullCheckpoint, setFullCheckpoint] = useState<any>(null);
const [fullCheckpoint, setFullCheckpoint] = useState<FullCheckpoint | null>(null);
const [loading, setLoading] = useState(false);
const [jsonExpanded, setJsonExpanded] = useState(true);
@@ -68,7 +68,7 @@ export function CheckpointInfoModal({
session.conversation_id,
`checkpoint_${selectedCheckpointId}`
);
setFullCheckpoint((item as CheckpointItem).metadata?.full_checkpoint);
setFullCheckpoint((item as CheckpointItem).metadata?.full_checkpoint ?? null);
} catch (error) {
console.error("Failed to load checkpoint:", error);
setFullCheckpoint(null);
@@ -276,7 +276,7 @@ export function CheckpointInfoModal({
)}
{/* Messages */}
{messageExecutors.length > 0 && (
{messageExecutors.length > 0 && fullCheckpoint && (
<div>
<div className="text-sm font-medium mb-3 flex items-center gap-2">
<MessageSquare className="h-4 w-4" />
@@ -311,7 +311,7 @@ export function CheckpointInfoModal({
</div>
<div className="space-y-2">
{Object.entries(fullCheckpoint.pending_request_info_events).map(
([reqId, reqData]: [string, any]) => (
([reqId, reqData]: [string, PendingRequestInfoEvent]) => (
<div
key={reqId}
className="bg-muted/50 border border-border p-3 rounded-lg"
@@ -9,6 +9,8 @@ import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { HilTimelineItem } from "./hil-timeline-item";
import { RunWorkflowButton } from "./run-workflow-button";
import { ChatMessageInput } from "@/components/ui/chat-message-input";
import { isChatMessageSchema } from "@/utils/workflow-utils";
import {
Loader2,
CheckCircle,
@@ -21,6 +23,7 @@ import {
Square,
} from "lucide-react";
import type { ExtendedResponseStreamEvent, JSONSchemaProperty } from "@/types";
import type { ResponseInputContent } from "@/types/agent-framework";
import type { ExecutorState } from "./executor-node";
import { truncateText } from "@/utils/workflow-utils";
@@ -262,8 +265,8 @@ export function ExecutionTimeline({
const item = (event as import("@/types/openai").ResponseOutputItemAddedEvent).item;
// Handle both executor_action items AND message items from Magentic agents
if (item && item.type === "executor_action" && item.executor_id && item.id) {
const executorId = item.executor_id;
if (item && item.type === "executor_action" && "executor_id" in item && item.id) {
const executorId = String(item.executor_id);
const itemId = item.id;
const runNumber = (runCount.get(executorId) || 0) + 1;
runCount.set(executorId, runNumber);
@@ -277,22 +280,25 @@ export function ExecutionTimeline({
timestamp: uiTimestamp,
runNumber,
});
} else if (item && item.type === "message" && item.metadata?.agent_id && item.metadata?.source === "magentic" && item.id) {
} else if (item && item.type === "message" && "metadata" in item && item.id) {
// Handle message items from Magentic agents
const executorId = item.metadata.agent_id;
const itemId = item.id;
const runNumber = (runCount.get(executorId) || 0) + 1;
runCount.set(executorId, runNumber);
const metadata = item.metadata as { agent_id?: string; source?: string } | undefined;
if (metadata?.agent_id && metadata?.source === "magentic") {
const executorId = metadata.agent_id;
const itemId = item.id;
const runNumber = (runCount.get(executorId) || 0) + 1;
runCount.set(executorId, runNumber);
runs.push({
executorId,
executorName: truncateText(executorId, 35),
itemId,
state: "running",
output: itemOutputs[itemId] || "",
timestamp: uiTimestamp,
runNumber,
});
runs.push({
executorId,
executorName: truncateText(executorId, 35),
itemId,
state: "running",
output: itemOutputs[itemId] || "",
timestamp: uiTimestamp,
runNumber,
});
}
}
}
@@ -301,7 +307,7 @@ export function ExecutionTimeline({
const item = (event as import("@/types/openai").ResponseOutputItemDoneEvent).item;
// Handle both executor_action items AND message items from Magentic agents
if (item && item.type === "executor_action" && item.executor_id && item.id) {
if (item && item.type === "executor_action" && "executor_id" in item && item.id) {
const itemId = item.id;
// Find the run by ITEM ID (not executor ID!) to handle multiple runs correctly
const existingRun = runs.find((r) => r.itemId === itemId);
@@ -315,18 +321,21 @@ export function ExecutionTimeline({
: "completed";
// Use item-specific output, not executor-wide output
existingRun.output = itemOutputs[itemId] || "";
if (item.status === "failed" && item.error) {
existingRun.error = item.error;
if (item.status === "failed" && "error" in item && item.error) {
existingRun.error = String(item.error);
}
}
} else if (item && item.type === "message" && item.metadata?.agent_id && item.metadata?.source === "magentic" && item.id) {
} else if (item && item.type === "message" && "metadata" in item && item.id) {
// Handle message completion from Magentic agents
const itemId = item.id;
const existingRun = runs.find((r) => r.itemId === itemId);
const metadata = item.metadata as { agent_id?: string; source?: string } | undefined;
if (metadata?.agent_id && metadata?.source === "magentic") {
const itemId = item.id;
const existingRun = runs.find((r) => r.itemId === itemId);
if (existingRun) {
existingRun.state = item.status === "completed" ? "completed" : "failed";
existingRun.output = itemOutputs[itemId] || "";
if (existingRun) {
existingRun.state = item.status === "completed" ? "completed" : "failed";
existingRun.output = itemOutputs[itemId] || "";
}
}
}
}
@@ -625,16 +634,35 @@ export function ExecutionTimeline({
{/* Bottom Control Bar - Sticky (hidden when HIL is active) */}
{(onRun || onCancel) && pendingHilRequests.length === 0 && (
<div className="border-t p-3 bg-background flex-shrink-0">
<RunWorkflowButton
inputSchema={inputSchema}
onRun={onRun || (() => {})}
onCancel={onCancel}
isSubmitting={workflowState === "running"}
isCancelling={isCancelling}
workflowState={workflowState}
checkpoints={checkpoints}
showCheckpoints={false}
/>
{inputSchema && isChatMessageSchema(inputSchema) ? (
<ChatMessageInput
onSubmit={async (content: ResponseInputContent[]) => {
// Wrap in OpenAI message format (same as run-workflow-button modal)
const openaiInput = [
{ type: "message", role: "user", content },
];
onRun?.(openaiInput as unknown as Record<string, unknown>);
}}
isSubmitting={workflowState === "running"}
isStreaming={workflowState === "running"}
onCancel={onCancel}
isCancelling={isCancelling}
placeholder="Message workflow..."
showFileUpload={true}
entityName="workflow"
/>
) : (
<RunWorkflowButton
inputSchema={inputSchema}
onRun={onRun || (() => {})}
onCancel={onCancel}
isSubmitting={workflowState === "running"}
isCancelling={isCancelling}
workflowState={workflowState}
checkpoints={checkpoints}
showCheckpoints={false}
/>
)}
</div>
)}
@@ -1,9 +1,11 @@
import { memo } from "react";
import { memo, useState } from "react";
import { Handle, Position, type NodeProps } from "@xyflow/react";
import {
Workflow,
Home,
Loader2,
ChevronRight,
ChevronDown,
} from "lucide-react";
import { cn } from "@/lib/utils";
import { truncateText } from "@/utils/workflow-utils";
@@ -70,8 +72,9 @@ const getExecutorStateConfig = (state: ExecutorState) => {
export const ExecutorNode = memo(({ data, selected }: NodeProps) => {
const nodeData = data as ExecutorNodeData;
const config = getExecutorStateConfig(nodeData.state);
const [isOutputExpanded, setIsOutputExpanded] = useState(false);
const hasData = nodeData.inputData || nodeData.outputData || nodeData.error;
const hasOutput = nodeData.outputData || nodeData.error;
const isRunning = nodeData.state === "running";
const shouldAnimate = isRunning && (nodeData.isStreaming ?? true); // Default to true for backwards compatibility
@@ -80,19 +83,13 @@ export const ExecutorNode = memo(({ data, selected }: NodeProps) => {
const targetPosition = isVertical ? Position.Top : Position.Left;
const sourcePosition = isVertical ? Position.Bottom : Position.Right;
// Helper to safely render data with full details
// Helper to render output/error details when expanded
const renderDataDetails = () => {
const details = [];
if (nodeData.error && typeof nodeData.error === "string") {
// Truncate error to first 150 characters for node display
const truncatedError = truncateText(nodeData.error, 150);
details.push(
<div key="error" className="mb-2">
<div className="text-xs font-medium text-red-600 dark:text-red-400 mb-1">Error:</div>
<div className="text-xs text-red-600 dark:text-red-400 bg-red-50 dark:bg-red-950/20 p-2 rounded border border-red-200 dark:border-red-800 break-words">
{truncatedError}
</div>
const truncatedError = truncateText(nodeData.error, 200);
return (
<div className="text-xs text-red-600 dark:text-red-400 bg-red-50 dark:bg-red-950/20 p-2 rounded border border-red-200 dark:border-red-800 break-words max-h-32 overflow-auto">
{truncatedError}
</div>
);
}
@@ -103,53 +100,21 @@ export const ExecutorNode = memo(({ data, selected }: NodeProps) => {
typeof nodeData.outputData === "string"
? nodeData.outputData
: JSON.stringify(nodeData.outputData, null, 2);
details.push(
<div key="output" className="mb-2">
<div className="text-xs font-medium text-green-600 dark:text-green-400 mb-1">Output:</div>
<div className="text-xs text-gray-700 dark:text-gray-300 bg-green-50 dark:bg-green-950/20 p-2 rounded border border-green-200 dark:border-green-800 max-h-20 overflow-auto">
<pre className="whitespace-pre-wrap font-mono">{outputStr}</pre>
</div>
return (
<div className="text-xs text-gray-700 dark:text-gray-300 bg-muted/50 p-2 rounded border max-h-32 overflow-auto">
<pre className="whitespace-pre-wrap font-mono">{outputStr}</pre>
</div>
);
} catch {
details.push(
<div key="output" className="mb-2">
<div className="text-xs font-medium text-green-600 dark:text-green-400 mb-1">Output:</div>
<div className="text-xs text-gray-600 dark:text-gray-400 bg-green-50 dark:bg-green-950/20 p-2 rounded border border-green-200 dark:border-green-800">
[Unable to display output data]
</div>
return (
<div className="text-xs text-gray-600 dark:text-gray-400 bg-muted/50 p-2 rounded border">
[Unable to display output]
</div>
);
}
}
if (nodeData.inputData) {
try {
const inputStr =
typeof nodeData.inputData === "string"
? nodeData.inputData
: JSON.stringify(nodeData.inputData, null, 2);
details.push(
<div key="input" className="mb-2">
<div className="text-xs font-medium text-blue-600 dark:text-blue-400 mb-1">Input:</div>
<div className="text-xs text-gray-700 dark:text-gray-300 bg-blue-50 dark:bg-blue-950/20 p-2 rounded border border-blue-200 dark:border-blue-800 max-h-20 overflow-auto">
<pre className="whitespace-pre-wrap font-mono">{inputStr}</pre>
</div>
</div>
);
} catch {
details.push(
<div key="input" className="mb-2">
<div className="text-xs font-medium text-blue-600 dark:text-blue-400 mb-1">Input:</div>
<div className="text-xs text-gray-600 dark:text-gray-400 bg-blue-50 dark:bg-blue-950/20 p-2 rounded border border-blue-200 dark:border-blue-800">
[Unable to display input data]
</div>
</div>
);
}
}
return details.length > 0 ? details : null;
return null;
};
return (
@@ -218,10 +183,28 @@ export const ExecutorNode = memo(({ data, selected }: NodeProps) => {
</div>
</div>
{/* Data details */}
{hasData && (
<div className="mt-3">
{renderDataDetails()}
{/* Collapsible output section */}
{hasOutput && (
<div className="mt-2 border-t border-border/50 pt-2">
<button
onClick={(e) => {
e.stopPropagation();
setIsOutputExpanded(!isOutputExpanded);
}}
className="flex items-center gap-1 text-xs text-muted-foreground hover:text-foreground transition-colors w-full"
>
{isOutputExpanded ? (
<ChevronDown className="w-3 h-3" />
) : (
<ChevronRight className="w-3 h-3" />
)}
<span>{nodeData.error ? "Show error" : "Show output"}</span>
</button>
{isOutputExpanded && (
<div className="mt-2">
{renderDataDetails()}
</div>
)}
</div>
)}
@@ -110,6 +110,7 @@ export function WorkflowView({
const removeSession = useDevUIStore((state) => state.removeSession);
const addToast = useDevUIStore((state) => state.addToast);
const runtime = useDevUIStore((state) => state.runtime);
const streamingEnabled = useDevUIStore((state) => state.streamingEnabled);
// View options state
const [viewOptions, setViewOptions] = useState(() => {
@@ -304,8 +305,9 @@ export function WorkflowView({
{ limit: 100 }
);
const checkpointItems = response.data.filter(
(item: any) => item.type === "checkpoint"
) as CheckpointItem[];
(item): item is CheckpointItem =>
typeof item === "object" && item !== null && "type" in item && (item as { type: string }).type === "checkpoint"
);
setSessionCheckpoints(checkpointItems);
} catch (error) {
console.error(`Failed to load checkpoints for session ${currentSession.conversation_id}:`, error);
@@ -453,9 +455,9 @@ export function WorkflowView({
| import("@/types/openai").ResponseOutputItemAddedEvent
| import("@/types/openai").ResponseOutputItemDoneEvent
).item;
if (item && item.type === "executor_action" && item.executor_id) {
if (item && item.type === "executor_action" && "executor_id" in item && item.executor_id) {
history.push({
executorId: item.executor_id,
executorId: String(item.executor_id),
message:
event.type === "response.output_item.added"
? "Executor started"
@@ -624,7 +626,8 @@ export function WorkflowView({
if (
item &&
item.type === "message" &&
item.metadata?.source === "magentic" &&
"metadata" in item &&
(item.metadata as { source?: string } | undefined)?.source === "magentic" &&
item.id
) {
// Track this message ID as the current streaming target for Magentic agents
@@ -639,19 +642,21 @@ export function WorkflowView({
if (
item &&
item.type === "message" &&
!item.metadata?.source &&
item.content
(!("metadata" in item) || !(item.metadata as { source?: string } | undefined)?.source) &&
"content" in item &&
Array.isArray(item.content)
) {
// Extract text from message content
for (const content of item.content) {
for (const content of item.content as Array<{ type: string; text?: string }>) {
if (content.type === "output_text" && content.text) {
const text = content.text; // Capture for closure
// Append to workflow result (support multiple yield_output calls)
setWorkflowResult((prev) => {
if (prev && prev.length > 0) {
// If there's existing output, add separator
return prev + "\n\n" + content.text;
return prev + "\n\n" + text;
}
return content.text;
return text;
});
// Try to parse as JSON for structured metadata
@@ -820,6 +825,99 @@ export function WorkflowView({
]
);
// Handle non-streaming workflow data sending
const handleSendWorkflowDataSync = useCallback(
async (inputData: Record<string, unknown>, checkpointId?: string) => {
if (!selectedWorkflow || selectedWorkflow.type !== "workflow") return;
setIsStreaming(false); // Not actually streaming
setWasCancelled(false);
setOpenAIEvents([]);
setWorkflowResult("");
itemOutputs.current = {};
currentStreamingItemId.current = null;
workflowMetadata.current = null;
setPendingHilRequests([]);
setHilResponses({});
onDebugEvent("clear");
try {
const response = await apiClient.runWorkflowSync(selectedWorkflow.id, {
input_data: inputData,
conversation_id: currentSession?.conversation_id || undefined,
checkpoint_id: checkpointId,
});
// Extract workflow result from response output
if (response.output) {
for (const outputItem of response.output) {
if (outputItem.type === "message" && "content" in outputItem && Array.isArray(outputItem.content)) {
for (const content of outputItem.content as Array<{ type: string; text?: string }>) {
if (content.type === "output_text" && content.text) {
setWorkflowResult((prev) => {
if (prev && prev.length > 0) {
return prev + "\n\n" + content.text;
}
return content.text || "";
});
// Try to parse as JSON for structured metadata
try {
const parsed = JSON.parse(content.text || "");
if (typeof parsed === "object" && parsed !== null) {
workflowMetadata.current = parsed;
}
} catch {
// Not JSON, keep as text
}
}
}
}
}
}
// Create a synthetic completion event for the timeline
const completedEvent = {
type: "response.completed",
response: response,
sequence_number: 0,
} as ExtendedResponseStreamEvent;
setOpenAIEvents([completedEvent]);
onDebugEvent(completedEvent);
// Refetch checkpoints after completion
await loadCheckpoints();
} catch (error) {
console.error("Workflow execution error:", error);
// Create a synthetic error event for the timeline
const errorMessage = error instanceof Error ? error.message : "Workflow execution failed";
const errorEvent: ExtendedResponseStreamEvent = {
type: "response.failed",
response: {
error: { message: errorMessage },
},
sequence_number: 0,
} as ExtendedResponseStreamEvent;
setOpenAIEvents([errorEvent]);
onDebugEvent(errorEvent);
}
},
[selectedWorkflow, currentSession, onDebugEvent, loadCheckpoints]
);
// Wrapper to choose between streaming and non-streaming
const handleWorkflowRun = useCallback(
async (inputData: Record<string, unknown>, checkpointId?: string) => {
if (streamingEnabled) {
await handleSendWorkflowData(inputData, checkpointId);
} else {
await handleSendWorkflowDataSync(inputData, checkpointId);
}
},
[streamingEnabled, handleSendWorkflowData, handleSendWorkflowDataSync]
);
// Check if all HIL responses are valid
const areAllHilResponsesValid = useCallback(() => {
// Check each pending request has a valid response
@@ -979,22 +1077,23 @@ export function WorkflowView({
}
// Handle workflow output messages
if (item && item.type === "message" && item.content) {
if (item && item.type === "message" && "content" in item && Array.isArray(item.content)) {
// Extract text from message content
for (const content of item.content) {
for (const content of item.content as Array<{ type: string; text?: string }>) {
if (content.type === "output_text" && content.text) {
const text = content.text; // Capture for closure
// Append to workflow result (support multiple yield_output calls)
setWorkflowResult((prev) => {
if (prev && prev.length > 0) {
// If there's existing output, add separator
return prev + "\n\n" + content.text;
return prev + "\n\n" + text;
}
return content.text;
return text;
});
// Try to parse as JSON for structured metadata
try {
const parsed = JSON.parse(content.text);
const parsed = JSON.parse(text);
if (typeof parsed === "object" && parsed !== null) {
workflowMetadata.current = parsed;
}
@@ -1296,7 +1395,7 @@ export function WorkflowView({
{timelineMinimized && (
<RunWorkflowButton
inputSchema={workflowInfo.input_schema}
onRun={handleSendWorkflowData}
onRun={handleWorkflowRun}
onCancel={handleCancel}
isSubmitting={isStreaming}
isCancelling={isCancelling}
@@ -1481,7 +1580,7 @@ export function WorkflowView({
inputSchema={workflowInfo?.input_schema}
onRun={(data, checkpointId) => {
// Use the form data from timeline
handleSendWorkflowData(data, checkpointId);
handleWorkflowRun(data, checkpointId);
}}
onCancel={handleCancel}
isCancelling={isCancelling}
@@ -3,7 +3,8 @@
* Features: Real-time event streaming, trace visualization, tool call details
*/
import { useRef, useState } from "react";
import { useRef, useState, useMemo } from "react";
import { useDevUIStore } from "@/stores/devuiStore";
import { ScrollArea } from "@/components/ui/scroll-area";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { Badge } from "@/components/ui/badge";
@@ -19,8 +20,9 @@ import {
MessageSquare,
ChevronRight,
ChevronDown,
Info,
BarChart3,
} from "lucide-react";
import { ContextInspector } from "@/components/features/agent/context-inspector";
import type { ExtendedResponseStreamEvent } from "@/types";
// Simple visual separator component
@@ -88,7 +90,23 @@ interface TraceEventData extends EventDataBase {
start_time?: number;
end_time?: number;
entity_id?: string;
session_id?: string | null;
response_id?: string | null;
}
// Helper type for trace hierarchy
interface TraceNode {
event: ExtendedResponseStreamEvent;
data: TraceEventData;
children: TraceNode[];
}
// Helper type for grouped traces by response
interface TraceGroup {
response_id: string;
timestamp: number;
traces: TraceNode[];
totalDuration: number;
entity_id?: string;
}
interface DebugPanelProps {
@@ -149,20 +167,22 @@ function processEventsForDisplay(
const item = outputEvent.item;
// If it's a function call item, extract metadata
if (item.type === "function_call" && item.call_id && item.name) {
const callId = item.call_id;
if (item.type === "function_call") {
// Type assertion for function call
const funcCall = item as import("@/types").ResponseFunctionToolCall;
const callId = funcCall.call_id;
// Initialize function call tracking with REAL function name from backend!
functionCalls.set(callId, {
name: item.name, // ← REAL NAME! (not "unknown")
name: funcCall.name, // ← REAL NAME! (not "unknown")
arguments: "",
callId: callId,
itemId: item.id, // Track item_id for delta matching
itemId: funcCall.id, // Track item_id for delta matching
timestamp: new Date().toISOString(),
});
// Also track in callIdToName map for result pairing
callIdToName.set(callId, item.name);
callIdToName.set(callId, funcCall.name);
}
// Pass through the event for display
@@ -955,27 +975,7 @@ function EventExpandedContent({
</span>
<div className="mt-1 max-h-32 overflow-auto">
<pre className="text-xs bg-background border rounded p-2 whitespace-pre-wrap break-all">
{(() => {
try {
// Try to pretty-print JSON, and unescape string values that contain JSON
const attrs = { ...data.attributes };
Object.keys(attrs).forEach((key) => {
if (
typeof attrs[key] === "string" &&
attrs[key].startsWith("[")
) {
try {
attrs[key] = JSON.parse(attrs[key]);
} catch {
// Keep original if parsing fails
}
}
});
return JSON.stringify(attrs, null, 2);
} catch {
return JSON.stringify(data.attributes, null, 2);
}
})()}
{formatTraceAttributes(data.attributes)}
</pre>
</div>
</div>
@@ -1149,257 +1149,418 @@ function EventsTab({
);
}
function TracesTab({ events }: { events: ExtendedResponseStreamEvent[] }) {
// ONLY show actual trace events - handle both event type formats
const traceEvents = events.filter(
(e) =>
e.type === "response.trace.completed" ||
e.type === "response.trace.completed"
);
// Build hierarchical trace structure from flat trace events
function buildTraceHierarchy(traceEvents: ExtendedResponseStreamEvent[]): TraceGroup[] {
// Group by response_id first
const groupedByResponse = new Map<string, ExtendedResponseStreamEvent[]>();
// Add separators between message rounds
const tracesWithSeparators = addSeparatorsToEvents(traceEvents);
for (const event of traceEvents) {
if (!("data" in event)) continue;
const data = event.data as TraceEventData;
const responseId = data.response_id || "unknown";
// Reverse to show latest traces at the top
const reversedTraceEvents = [...tracesWithSeparators].reverse();
if (!groupedByResponse.has(responseId)) {
groupedByResponse.set(responseId, []);
}
groupedByResponse.get(responseId)!.push(event);
}
// Convert each group to hierarchical structure
const groups: TraceGroup[] = [];
for (const [responseId, events] of groupedByResponse) {
// Build tree from parent_span_id relationships
const nodeMap = new Map<string, TraceNode>();
const rootNodes: TraceNode[] = [];
// First pass: create all nodes
for (const event of events) {
if (!("data" in event)) continue;
const data = (event as { data: TraceEventData }).data;
const spanId = data.span_id || `span_${Math.random()}`;
nodeMap.set(spanId, {
event,
data,
children: [],
});
}
// Second pass: build parent-child relationships
for (const event of events) {
if (!("data" in event)) continue;
const data = (event as { data: TraceEventData }).data;
const spanId = data.span_id || "";
const parentSpanId = data.parent_span_id;
const node = nodeMap.get(spanId);
if (!node) continue;
if (parentSpanId && nodeMap.has(parentSpanId)) {
// Has a parent in this group
nodeMap.get(parentSpanId)!.children.push(node);
} else {
// Root node (no parent or parent not in this group)
rootNodes.push(node);
}
}
// Sort root nodes by start_time (earliest first)
rootNodes.sort((a, b) => (a.data.start_time || 0) - (b.data.start_time || 0));
// Sort children recursively by start_time
const sortChildren = (node: TraceNode) => {
node.children.sort((a, b) => (a.data.start_time || 0) - (b.data.start_time || 0));
node.children.forEach(sortChildren);
};
rootNodes.forEach(sortChildren);
// Calculate group metadata
const firstEvent = events[0];
const firstData = firstEvent && "data" in firstEvent ? (firstEvent.data as TraceEventData) : null;
const timestamp = Math.min(...events.map(e => {
const d = "data" in e ? (e.data as TraceEventData) : null;
return d?.start_time || Date.now() / 1000;
}));
const totalDuration = events.reduce((sum, e) => {
const d = "data" in e ? (e.data as TraceEventData) : null;
return sum + (d?.duration_ms || 0);
}, 0);
groups.push({
response_id: responseId,
timestamp,
traces: rootNodes,
totalDuration,
entity_id: firstData?.entity_id,
});
}
// Sort groups by timestamp (newest first)
groups.sort((a, b) => b.timestamp - a.timestamp);
return groups;
}
// Recursively parse escaped JSON strings at any depth
function parseEscapedJson(value: unknown): unknown {
if (typeof value === "string") {
// Try to parse JSON strings (arrays or objects)
const trimmed = value.trim();
if (trimmed.startsWith("[") || trimmed.startsWith("{")) {
try {
const parsed = JSON.parse(value);
// Recursively process the parsed result
return parseEscapedJson(parsed);
} catch {
return value;
}
}
return value;
}
if (Array.isArray(value)) {
return value.map(parseEscapedJson);
}
if (value !== null && typeof value === "object") {
const result: Record<string, unknown> = {};
for (const [k, v] of Object.entries(value)) {
result[k] = parseEscapedJson(v);
}
return result;
}
return value;
}
// Format trace attributes by parsing escaped JSON strings for better readability
function formatTraceAttributes(attributes: Record<string, unknown>): string {
try {
const formatted = parseEscapedJson(attributes);
return JSON.stringify(formatted, null, 2);
} catch {
return JSON.stringify(attributes, null, 2);
}
}
// Get operation type badge color
function getOperationColor(operationName: string): string {
if (operationName.includes("invoke_agent") || operationName.includes("Agent")) {
return "bg-purple-100 dark:bg-purple-900 text-purple-800 dark:text-purple-200";
}
if (operationName.includes("chat") || operationName.includes("Chat")) {
return "bg-blue-100 dark:bg-blue-900 text-blue-800 dark:text-blue-200";
}
if (operationName.includes("tool") || operationName.includes("execute")) {
return "bg-green-100 dark:bg-green-900 text-green-800 dark:text-green-200";
}
return "bg-orange-100 dark:bg-orange-900 text-orange-800 dark:text-orange-200";
}
// Recursive component for rendering trace tree nodes
function TraceTreeNode({ node, depth = 0 }: { node: TraceNode; depth?: number }) {
const [isExpanded, setIsExpanded] = useState(depth < 2); // Auto-expand first 2 levels
const [showDetails, setShowDetails] = useState(false);
const { data } = node;
const operationName = data.operation_name || "Unknown";
const duration = data.duration_ms ? `${Number(data.duration_ms).toFixed(1)}ms` : "";
const hasChildren = node.children.length > 0;
// Extract token usage from attributes if available
const inputTokens = data.attributes?.["gen_ai.usage.input_tokens"];
const outputTokens = data.attributes?.["gen_ai.usage.output_tokens"];
const hasTokens = inputTokens !== undefined || outputTokens !== undefined;
return (
<div className="h-full flex flex-col">
<div className="flex items-center gap-2 p-3 border-b">
<Search className="h-4 w-4" />
<span className="font-medium">Traces</span>
<Badge variant="outline">{traceEvents.length}</Badge>
<div className="relative">
{/* Vertical line for tree structure */}
{depth > 0 && (
<div
className="absolute left-0 top-0 bottom-0 border-l-2 border-muted"
style={{ marginLeft: `${(depth - 1) * 16 + 8}px` }}
/>
)}
<div
className="flex items-center gap-2 py-1.5 hover:bg-muted/50 rounded transition-colors"
style={{ paddingLeft: `${depth * 16}px` }}
>
{/* Expand/collapse for children OR details */}
<button
onClick={() => hasChildren ? setIsExpanded(!isExpanded) : setShowDetails(!showDetails)}
className="w-4 h-4 flex items-center justify-center text-muted-foreground hover:text-foreground"
>
{hasChildren ? (
isExpanded ? <ChevronDown className="h-3 w-3" /> : <ChevronRight className="h-3 w-3" />
) : (
showDetails ? <ChevronDown className="h-3 w-3" /> : <ChevronRight className="h-3 w-3" />
)}
</button>
{/* Operation badge */}
<span className={`text-xs px-1.5 py-0.5 rounded font-medium ${getOperationColor(operationName)}`}>
{operationName.replace("ChatAgent.", "").replace("invoke_agent ", "")}
</span>
{/* Duration */}
{duration && (
<span className="text-xs text-muted-foreground font-mono">
{duration}
</span>
)}
{/* Token usage */}
{hasTokens && (
<span className="text-xs text-muted-foreground font-mono">
{inputTokens !== undefined && <span>{String(inputTokens)}</span>}
{inputTokens !== undefined && outputTokens !== undefined && <span className="mx-0.5">/</span>}
{outputTokens !== undefined && <span>{String(outputTokens)}</span>}
</span>
)}
</div>
<ScrollArea className="flex-1">
<div className="p-3">
{traceEvents.length === 0 ? (
<div className="text-center text-muted-foreground text-sm py-8">
No trace data available.
<br />
{events && events.length > 0 && (
<div className="mt-3 text-xs border rounded p-2">
{" "}
<Info className="inline h-4 w-4 mr-1 " />
You may have to set the environment variable{" "}
<span className="font-mono bg-accent/10 px-1 rounded">
ENABLE_INSTRUMENTATION=true
</span>{" "}
or restart devui with the tracing flag{" "}
<div className="font-mono bg-accent/10 px-1 rounded">
devui --tracing
</div>
to enable tracing.
</div>
)}
</div>
) : (
<div className="space-y-3">
{reversedTraceEvents.map((event, index) => {
if ('type' in event && event.type === "separator") {
return <MessageSeparator key={(event as { type: "separator"; id: string }).id} />;
}
return <TraceEventItem key={index} event={event as ExtendedResponseStreamEvent} />;
})}
</div>
)}
{/* Details panel */}
{showDetails && !hasChildren && (
<div
className="ml-4 mt-1 mb-2 p-2 bg-muted/30 rounded border text-xs"
style={{ marginLeft: `${depth * 16 + 20}px` }}
>
<div className="space-y-1">
{data.span_id && (
<div className="flex gap-2">
<span className="text-muted-foreground w-20">Span ID:</span>
<span className="font-mono text-xs break-all">{data.span_id}</span>
</div>
)}
{data.trace_id && (
<div className="flex gap-2">
<span className="text-muted-foreground w-20">Trace ID:</span>
<span className="font-mono text-xs break-all">{data.trace_id}</span>
</div>
)}
{data.status && (
<div className="flex gap-2">
<span className="text-muted-foreground w-20">Status:</span>
<span className={`px-1.5 py-0.5 rounded text-xs ${
data.status === "StatusCode.UNSET" || data.status === "OK"
? "bg-green-100 dark:bg-green-900 text-green-800 dark:text-green-200"
: "bg-red-100 dark:bg-red-900 text-red-800 dark:text-red-200"
}`}>
{data.status}
</span>
</div>
)}
{data.attributes && Object.keys(data.attributes).length > 0 && (
<div className="mt-2">
<span className="text-muted-foreground block mb-1">Attributes:</span>
<pre className="text-xs bg-background border rounded p-2 overflow-auto max-h-32 whitespace-pre-wrap break-all">
{formatTraceAttributes(data.attributes)}
</pre>
</div>
)}
</div>
</div>
</ScrollArea>
)}
{/* Children */}
{hasChildren && isExpanded && (
<div>
{node.children.map((child, idx) => (
<TraceTreeNode key={child.data.span_id || idx} node={child} depth={depth + 1} />
))}
</div>
)}
</div>
);
}
function TraceEventItem({ event }: { event: ExtendedResponseStreamEvent }) {
const [isExpanded, setIsExpanded] = useState(false);
// Component for a single trace group (one response/turn)
function TraceGroupItem({ group }: { group: TraceGroup }) {
const [isExpanded, setIsExpanded] = useState(true);
if (
(event.type !== "response.trace.completed" &&
event.type !== "response.trace.completed") ||
!("data" in event)
) {
return (
<div className="border rounded p-3 text-red-600 dark:text-red-400 text-xs">
Error: Expected trace event but got {event.type}
</div>
);
}
const data = event.data as TraceEventData;
// Use stored UI timestamp first, then trace timestamps, then fallback to current time
let timestamp: string;
if ('_uiTimestamp' in event && typeof event._uiTimestamp === 'number') {
// Use stored UI timestamp from when event was received
timestamp = new Date(event._uiTimestamp * 1000).toLocaleTimeString();
} else if (data.end_time) {
timestamp = new Date(data.end_time * 1000).toLocaleTimeString();
} else if (data.start_time) {
timestamp = new Date(data.start_time * 1000).toLocaleTimeString();
} else if (data.timestamp) {
timestamp = new Date(data.timestamp).toLocaleTimeString();
} else {
timestamp = new Date().toLocaleTimeString();
}
const operationName = data.operation_name || "Unknown Operation";
const duration = data.duration_ms
? `${Number(data.duration_ms).toFixed(1)}ms`
: "";
const entityId = data.entity_id || "";
const timestamp = new Date(group.timestamp * 1000).toLocaleTimeString();
const duration = group.totalDuration > 0 ? `${group.totalDuration.toFixed(0)}ms` : "";
const spanCount = group.traces.reduce((count, node) => {
const countNode = (n: TraceNode): number => 1 + n.children.reduce((c, child) => c + countNode(child), 0);
return count + countNode(node);
}, 0);
return (
<div className="border-l-2 border-muted pl-3 py-2 hover:bg-muted/50 transition-colors">
<div className="flex items-center gap-2 text-xs text-muted-foreground mb-1">
<Search className="h-3 w-3 text-orange-600 dark:text-orange-400" />
<span className="font-mono">{timestamp}</span>
<Badge variant="outline" className="text-xs py-0">
trace
</Badge>
<div className="border rounded-lg overflow-hidden">
{/* Group header */}
<div
className="flex items-center gap-2 p-2 bg-muted/50 cursor-pointer hover:bg-muted/70 transition-colors"
onClick={() => setIsExpanded(!isExpanded)}
>
<div className="text-muted-foreground">
{isExpanded ? <ChevronDown className="h-4 w-4" /> : <ChevronRight className="h-4 w-4" />}
</div>
<span className="font-mono text-xs text-muted-foreground">{timestamp}</span>
{group.entity_id && (
<Badge variant="outline" className="text-xs py-0">
{group.entity_id.replace("agent_", "").replace("workflow_", "")}
</Badge>
)}
<div className="flex-1" />
{duration && (
<Badge variant="secondary" className="text-xs py-0">
{duration}
</Badge>
)}
<span className="text-xs text-muted-foreground">
{spanCount} span{spanCount !== 1 ? "s" : ""}
</span>
</div>
<div className="text-sm">
<div
className="flex items-center gap-2 cursor-pointer"
onClick={() => setIsExpanded(!isExpanded)}
>
<div className="text-muted-foreground">
{isExpanded ? (
<ChevronDown className="h-3 w-3" />
) : (
<ChevronRight className="h-3 w-3" />
)}
</div>
<div className="text-muted-foreground flex-1 break-all">
<span className="font-medium">{operationName}</span>
{entityId && <span className="ml-2 text-xs">({entityId})</span>}
</div>
{/* Group content - trace tree */}
{isExpanded && (
<div className="p-2 border-t">
{group.traces.map((node, idx) => (
<TraceTreeNode key={node.data.span_id || idx} node={node} depth={0} />
))}
</div>
)}
</div>
);
}
{/* Expandable content */}
{isExpanded && (
<div className="mt-2 ml-5 p-3 bg-muted/30 rounded border">
<div className="space-y-2">
function TracesTab({ events }: { events: ExtendedResponseStreamEvent[] }) {
// Use persisted store state instead of local useState
const subTab = useDevUIStore((state) => state.debugTraceSubTab);
const setSubTab = useDevUIStore((state) => state.setDebugTraceSubTab);
// ONLY show actual trace events
const traceEvents = events.filter(
(e) => e.type === "response.trace.completed"
);
// Build hierarchical structure grouped by response_id
const traceGroups = buildTraceHierarchy(traceEvents);
return (
<div className="h-full flex flex-col">
{/* Sub-tab header */}
<div className="flex items-center gap-2 p-3 border-b">
<Search className="h-4 w-4" />
<span className="font-medium">Traces</span>
<Badge variant="outline">{traceEvents.length}</Badge>
{/* Sub-tab toggle */}
<div className="flex-1" />
<div className="flex items-center bg-muted rounded-md p-1 min-w-0">
<button
onClick={() => setSubTab("spans")}
className={`px-3 py-1.5 text-xs rounded transition-colors truncate ${
subTab === "spans"
? "bg-background shadow-sm font-medium"
: "text-muted-foreground hover:text-foreground"
}`}
>
OTel Spans
</button>
<button
onClick={() => setSubTab("context")}
className={`px-3 py-1.5 text-xs rounded transition-colors flex items-center gap-1.5 min-w-0 ${
subTab === "context"
? "bg-background shadow-sm font-medium"
: "text-muted-foreground hover:text-foreground"
}`}
>
<BarChart3 className="h-3.5 w-3.5 flex-shrink-0" />
<span className="truncate">Context Inspector</span>
</button>
</div>
</div>
{/* Sub-tab content */}
{subTab === "spans" ? (
<div className="flex-1 flex flex-col min-h-0">
{/* OTel Spans header - only show when we have data */}
{traceEvents.length > 0 && (
<div className="p-3 border-b flex-shrink-0">
<div className="flex items-center gap-2">
<Search className="h-4 w-4 text-orange-500" />
<span className="font-semibold text-sm">Trace Details</span>
</div>
<div className="grid grid-cols-1 gap-2 text-xs">
<div>
<span className="font-medium text-muted-foreground">
Operation:
</span>
<span className="ml-2 font-mono bg-orange-100 dark:bg-orange-900 px-2 py-1 rounded">
{operationName}
</span>
</div>
{data.span_id && (
<div>
<span className="font-medium text-muted-foreground">
Span ID:
</span>
<span className="ml-2 font-mono text-xs break-all">
{data.span_id}
</span>
</div>
)}
{data.trace_id && (
<div>
<span className="font-medium text-muted-foreground">
Trace ID:
</span>
<span className="ml-2 font-mono text-xs break-all">
{data.trace_id}
</span>
</div>
)}
{data.parent_span_id && (
<div>
<span className="font-medium text-muted-foreground">
Parent Span:
</span>
<span className="ml-2 font-mono text-xs break-all">
{data.parent_span_id}
</span>
</div>
)}
{data.duration_ms && (
<div>
<span className="font-medium text-muted-foreground">
Duration:
</span>
<span className="ml-2 font-mono text-xs">
{Number(data.duration_ms).toFixed(2)}ms
</span>
</div>
)}
{data.status && (
<div>
<span className="font-medium text-muted-foreground">
Status:
</span>
<span
className={`ml-2 px-2 py-1 rounded text-xs font-medium ${data.status === "StatusCode.UNSET" ||
data.status === "OK"
? "bg-green-100 dark:bg-green-900 text-green-800 dark:text-green-200"
: "bg-red-100 dark:bg-red-900 text-red-800 dark:text-red-200"
}`}
>
{data.status || "unknown"}
</span>
</div>
)}
{data.entity_id && (
<div>
<span className="font-medium text-muted-foreground">
Entity:
</span>
<span className="ml-2 font-mono text-xs break-all">
{data.entity_id}
</span>
</div>
)}
{data.attributes && Object.keys(data.attributes).length > 0 && (
<div>
<span className="font-medium text-muted-foreground">
Attributes:
</span>
<div className="mt-1 max-h-32 overflow-auto">
<pre className="text-xs bg-background border rounded p-2 whitespace-pre-wrap max-w-full break-all">
{(() => {
try {
// Try to pretty-print JSON, and unescape string values that contain JSON
const attrs = { ...data.attributes };
Object.keys(attrs).forEach((key) => {
if (
typeof attrs[key] === "string" &&
attrs[key].startsWith("[")
) {
try {
attrs[key] = JSON.parse(attrs[key]);
} catch {
// Keep original if parsing fails
}
}
});
return JSON.stringify(attrs, null, 2);
} catch {
return JSON.stringify(data.attributes, null, 2);
}
})()}
</pre>
</div>
</div>
)}
<Search className="h-4 w-4" />
<span className="font-medium text-sm">OTel Spans</span>
<Badge variant="outline" className="text-xs">
{traceGroups.length} turn{traceGroups.length !== 1 ? "s" : ""}
</Badge>
</div>
</div>
</div>
)}
</div>
)}
{traceEvents.length === 0 ? (
<div className="flex flex-col items-center text-center p-6 pt-9">
<BarChart3 className="h-8 w-8 text-muted-foreground mb-3" />
<div className="text-sm font-medium mb-1">No Data</div>
<div className="text-xs text-muted-foreground max-w-[200px]">
Run{" "}
<span className="font-mono bg-accent/10 px-1 rounded">
devui --instrumentation
</span>{" "}
and start a conversation.
</div>
</div>
) : (
<ScrollArea className="flex-1">
<div className="p-3">
<div className="space-y-3">
{traceGroups.map((group) => (
<TraceGroupItem key={group.response_id} group={group} />
))}
</div>
</div>
</ScrollArea>
)}
</div>
) : (
<ContextInspector events={events} />
)}
</div>
);
}
@@ -1590,19 +1751,48 @@ export function DebugPanel({
isStreaming = false,
onMinimize,
}: DebugPanelProps) {
// Use persisted store state for active tab
const activeTab = useDevUIStore((state) => state.debugPanelTab);
const setActiveTab = useDevUIStore((state) => state.setDebugPanelTab);
// Compute counts once for tab badges (memoized to avoid perf hits)
const counts = useMemo(() => {
const processedEvents = processEventsForDisplay(events);
const eventsCount = processedEvents.length;
const tracesCount = events.filter(e => e.type === "response.trace.completed").length;
const toolsCount = processedEvents.filter(e => e.type === "response.function_call.complete").length
+ events.filter(e => getFunctionResultFromEvent(e) !== null).length;
return { eventsCount, tracesCount, toolsCount };
}, [events]);
return (
<div className="flex-1 border-l flex flex-col min-h-0">
<Tabs defaultValue="events" className="flex-1 flex flex-col min-h-0">
<Tabs value={activeTab} onValueChange={(v) => setActiveTab(v as "events" | "traces" | "tools")} className="flex-1 flex flex-col min-h-0">
<div className="px-3 pt-3 flex items-center gap-2 flex-shrink-0">
<TabsList className="flex-1">
<TabsTrigger value="events" className="flex-1">
<TabsTrigger value="events" className="flex-1 gap-1.5">
Events
{counts.eventsCount > 0 && (
<span className="text-[10px] bg-muted-foreground/20 text-muted-foreground px-1.5 py-0.5 rounded-full min-w-[1.25rem] text-center">
{counts.eventsCount}
</span>
)}
</TabsTrigger>
<TabsTrigger value="traces" className="flex-1">
<TabsTrigger value="traces" className="flex-1 gap-1.5">
Traces
{counts.tracesCount > 0 && (
<span className="text-[10px] bg-muted-foreground/20 text-muted-foreground px-1.5 py-0.5 rounded-full min-w-[1.25rem] text-center">
{counts.tracesCount}
</span>
)}
</TabsTrigger>
<TabsTrigger value="tools" className="flex-1">
<TabsTrigger value="tools" className="flex-1 gap-1.5">
Tools
{counts.toolsCount > 0 && (
<span className="text-[10px] bg-muted-foreground/20 text-muted-foreground px-1.5 py-0.5 rounded-full min-w-[1.25rem] text-center">
{counts.toolsCount}
</span>
)}
</TabsTrigger>
</TabsList>
{onMinimize && (
@@ -209,7 +209,7 @@ export function DeploymentModal({
setCopiedTemplate(null);
timeoutRef.current = null;
}, 2000);
} catch (err) {
} catch {
// Reset state on error - clipboard write failed
setCopiedTemplate(null);
}
@@ -248,7 +248,7 @@ services:
- AZURE_OPENAI_API_KEY=\${AZURE_OPENAI_API_KEY}
- AZURE_OPENAI_ENDPOINT=\${AZURE_OPENAI_ENDPOINT}
- AZURE_OPENAI_CHAT_DEPLOYMENT_NAME=\${AZURE_OPENAI_CHAT_DEPLOYMENT_NAME}
# Optional: Enable tracing
# Optional: Enable instrumentation
- ENABLE_INSTRUMENTATION=\${ENABLE_INSTRUMENTATION:-false}
ports:
- "8080:8080"
@@ -41,8 +41,8 @@ export function SettingsModal({
}: SettingsModalProps) {
const [activeTab, setActiveTab] = useState<Tab>("general");
// OpenAI proxy mode, Azure deployment, auth status, server capabilities, and version from store
const { oaiMode, setOAIMode, azureDeploymentEnabled, setAzureDeploymentEnabled, authRequired, serverCapabilities, serverVersion, runtime, uiMode } = useDevUIStore();
// OpenAI proxy mode, Azure deployment, auth status, server capabilities, streaming, and version from store
const { oaiMode, setOAIMode, azureDeploymentEnabled, setAzureDeploymentEnabled, authRequired, serverCapabilities, serverVersion, runtime, uiMode, streamingEnabled, setStreamingEnabled } = useDevUIStore();
// Get current backend URL from localStorage or default
const defaultUrl = import.meta.env.VITE_API_BASE_URL !== undefined ? import.meta.env.VITE_API_BASE_URL : "";
@@ -353,6 +353,37 @@ export function SettingsModal({
/>
</div>
</div>
{/* Streaming Mode Setting */}
<div className="space-y-3 border-t pt-6">
<div className="flex items-center justify-between">
<div className="space-y-0.5">
<Label className="text-sm font-medium">
Streaming Mode
</Label>
<p className="text-xs text-muted-foreground">
Stream responses token-by-token as they're generated
</p>
</div>
<Switch
checked={streamingEnabled}
onCheckedChange={setStreamingEnabled}
/>
</div>
{!streamingEnabled && (
<div className="flex items-start gap-2 text-xs text-amber-600 dark:text-amber-400 bg-amber-500/10 p-3 rounded">
<Info className="h-3.5 w-3.5 flex-shrink-0 mt-0.5" />
<div>
<p className="font-medium">Non-streaming mode limitations:</p>
<ul className="mt-1 space-y-0.5 list-disc list-inside text-amber-600/80 dark:text-amber-400/80">
<li>Tool calls won't display in real-time</li>
<li>No typing indicator during generation</li>
<li>Response appears all at once when complete</li>
</ul>
</div>
</div>
)}
</div>
</div>
)}
@@ -628,11 +659,11 @@ export function SettingsModal({
<div className="space-y-2 pt-2">
<p className="text-xs font-medium text-muted-foreground uppercase tracking-wide">Capabilities</p>
<div className="space-y-1 text-sm">
{serverCapabilities?.tracing !== undefined && (
{serverCapabilities?.instrumentation !== undefined && (
<div className="flex justify-between items-center">
<span className="text-muted-foreground">Tracing:</span>
<span className={`text-xs px-2 py-0.5 rounded-full ${serverCapabilities.tracing ? 'bg-green-500/10 text-green-600 dark:text-green-400' : 'bg-muted text-muted-foreground'}`}>
{serverCapabilities.tracing ? 'Enabled' : 'Disabled'}
<span className="text-muted-foreground">Instrumentation:</span>
<span className={`text-xs px-2 py-0.5 rounded-full ${serverCapabilities.instrumentation ? 'bg-green-500/10 text-green-600 dark:text-green-400' : 'bg-muted text-muted-foreground'}`}>
{serverCapabilities.instrumentation ? 'Enabled' : 'Disabled'}
</span>
</div>
)}
@@ -0,0 +1,30 @@
import * as React from "react";
import * as TooltipPrimitive from "@radix-ui/react-tooltip";
import { cn } from "@/lib/utils";
const TooltipProvider = TooltipPrimitive.Provider;
const Tooltip = TooltipPrimitive.Root;
const TooltipTrigger = TooltipPrimitive.Trigger;
const TooltipContent = React.forwardRef<
React.ElementRef<typeof TooltipPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof TooltipPrimitive.Content>
>(({ className, sideOffset = 4, ...props }, ref) => (
<TooltipPrimitive.Portal>
<TooltipPrimitive.Content
ref={ref}
sideOffset={sideOffset}
className={cn(
"z-50 overflow-hidden rounded-md bg-primary px-3 py-1.5 text-xs text-primary-foreground animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
className
)}
{...props}
/>
</TooltipPrimitive.Portal>
));
TooltipContent.displayName = TooltipPrimitive.Content.displayName;
export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider };
@@ -398,7 +398,11 @@ class ApiClient {
async listConversationItems(
conversationId: string,
options?: { limit?: number; after?: string; order?: "asc" | "desc" }
): Promise<{ data: unknown[]; has_more: boolean }> {
): Promise<{
data: unknown[];
has_more: boolean;
metadata?: { traces?: unknown[] };
}> {
const params = new URLSearchParams();
if (options?.limit) params.set("limit", options.limit.toString());
if (options?.after) params.set("after", options.after);
@@ -409,7 +413,11 @@ class ApiClient {
queryString ? `?${queryString}` : ""
}`;
return this.request<{ data: unknown[]; has_more: boolean }>(url);
return this.request<{
data: unknown[];
has_more: boolean;
metadata?: { traces?: unknown[] };
}>(url);
}
async getConversationItem(
@@ -800,34 +808,68 @@ class ApiClient {
yield* this.streamOpenAIResponse(openAIRequest, request.conversation_id, signal);
}
// REMOVED: Legacy streaming methods - use streamAgentExecutionOpenAI and streamWorkflowExecutionOpenAI instead
// ========================================
// Non-Streaming Execution Methods
// ========================================
// Non-streaming execution (for testing)
async runAgent(
// Non-streaming agent execution using /v1/responses with stream=false
async runAgentSync(
agentId: string,
request: RunAgentRequest
): Promise<{
conversation_id: string;
result: unknown[];
message_count: number;
}> {
return this.request(`/agents/${agentId}/run`, {
): Promise<import("@/types/openai").OpenAIResponse> {
// Check if OAI proxy mode is enabled
const { oaiMode } = await import("@/stores").then((m) => ({
oaiMode: m.useDevUIStore.getState().oaiMode,
}));
const openAIRequest: AgentFrameworkRequest = {
metadata: { entity_id: agentId },
input: request.input,
stream: false,
conversation: request.conversation_id,
};
// Apply OAI mode settings if enabled
if (oaiMode.enabled) {
openAIRequest.model = oaiMode.model;
if (oaiMode.temperature !== undefined) {
openAIRequest.temperature = oaiMode.temperature;
}
if (oaiMode.max_output_tokens !== undefined) {
openAIRequest.max_output_tokens = oaiMode.max_output_tokens;
}
}
const headers: Record<string, string> = {};
if (oaiMode.enabled) {
headers["X-Proxy-Backend"] = "openai";
}
return this.request<import("@/types/openai").OpenAIResponse>("/v1/responses", {
method: "POST",
body: JSON.stringify(request),
headers,
body: JSON.stringify(openAIRequest),
});
}
async runWorkflow(
// Non-streaming workflow execution using /v1/responses with stream=false
async runWorkflowSync(
workflowId: string,
request: RunWorkflowRequest
): Promise<{
result: string;
events: number;
message_count: number;
}> {
return this.request(`/workflows/${workflowId}/run`, {
): Promise<import("@/types/openai").OpenAIResponse> {
const openAIRequest: AgentFrameworkRequest = {
metadata: { entity_id: workflowId },
input: JSON.stringify(request.input_data || {}),
stream: false,
conversation: request.conversation_id,
extra_body: request.checkpoint_id
? { entity_id: workflowId, checkpoint_id: request.checkpoint_id }
: undefined,
};
return this.request<import("@/types/openai").OpenAIResponse>("/v1/responses", {
method: "POST",
body: JSON.stringify(request),
body: JSON.stringify(openAIRequest),
});
}
@@ -60,6 +60,13 @@ interface DevUIState {
debugEvents: ExtendedResponseStreamEvent[];
isResizing: boolean;
showToolCalls: boolean; // UI setting to show/hide tool calls in chat
streamingEnabled: boolean; // Whether to use streaming mode for responses
// Debug Panel Preferences (persisted)
debugPanelTab: "events" | "traces" | "tools"; // Main debug panel tab
debugTraceSubTab: "spans" | "context"; // OTel Spans vs Context Inspector
contextInspectorViewMode: "tokens" | "composition";
contextInspectorCumulative: boolean;
// Modal Slice
showAboutModal: boolean;
@@ -82,7 +89,7 @@ interface DevUIState {
uiMode: "developer" | "user";
runtime: "python" | "dotnet";
serverCapabilities: {
tracing: boolean;
instrumentation: boolean;
openai_proxy: boolean;
deployment: boolean;
};
@@ -146,6 +153,13 @@ interface DevUIActions {
clearDebugEvents: () => void;
setIsResizing: (resizing: boolean) => void;
setShowToolCalls: (show: boolean) => void;
setStreamingEnabled: (enabled: boolean) => void;
// Debug Panel Preference Actions
setDebugPanelTab: (tab: "events" | "traces" | "tools") => void;
setDebugTraceSubTab: (tab: "spans" | "context") => void;
setContextInspectorViewMode: (mode: "tokens" | "composition") => void;
setContextInspectorCumulative: (cumulative: boolean) => void;
// Modal Actions
setShowAboutModal: (show: boolean) => void;
@@ -166,7 +180,7 @@ interface DevUIActions {
toggleOAIMode: () => void;
// Server Meta Actions
setServerMeta: (meta: { uiMode: "developer" | "user"; runtime: "python" | "dotnet"; capabilities: { tracing: boolean; openai_proxy: boolean; deployment: boolean }; authRequired: boolean; version?: string }) => void;
setServerMeta: (meta: { uiMode: "developer" | "user"; runtime: "python" | "dotnet"; capabilities: { instrumentation: boolean; openai_proxy: boolean; deployment: boolean }; authRequired: boolean; version?: string }) => void;
// Deployment Actions
startDeployment: () => void;
@@ -228,6 +242,13 @@ export const useDevUIStore = create<DevUIStore>()(
debugEvents: [],
isResizing: false,
showToolCalls: true, // Default to showing tool calls
streamingEnabled: true, // Default to streaming mode (recommended)
// Debug Panel Preferences (persisted)
debugPanelTab: "events", // Default to events tab
debugTraceSubTab: "spans", // Default to spans sub-tab
contextInspectorViewMode: "tokens", // Default to tokens view
contextInspectorCumulative: false, // Default to per-message view
// Modal State
showAboutModal: false,
@@ -248,7 +269,7 @@ export const useDevUIStore = create<DevUIStore>()(
uiMode: "developer", // Default to developer mode
runtime: "python", // Default to Python runtime
serverCapabilities: {
tracing: false,
instrumentation: false,
openai_proxy: false,
deployment: false,
},
@@ -371,14 +392,16 @@ export const useDevUIStore = create<DevUIStore>()(
setDebugPanelMinimized: (minimized) => set({ debugPanelMinimized: minimized }),
setDebugPanelWidth: (width) => set({ debugPanelWidth: width }),
setShowToolCalls: (show) => set({ showToolCalls: show }),
setStreamingEnabled: (enabled) => set({ streamingEnabled: enabled }),
addDebugEvent: (event) =>
set((state) => {
// Generate unique timestamp for each event
// Use current time + small increment to ensure uniqueness even for rapid events
const baseTimestamp = Math.floor(Date.now() / 1000);
const lastTimestamp = state.debugEvents.length > 0
? (state.debugEvents[state.debugEvents.length - 1] as any)._uiTimestamp || 0
: 0;
const lastEvent = state.debugEvents.length > 0
? state.debugEvents[state.debugEvents.length - 1] as { _uiTimestamp?: number }
: null;
const lastTimestamp = lastEvent?._uiTimestamp ?? 0;
// Ensure new timestamp is always greater than the last one
const uniqueTimestamp = Math.max(baseTimestamp, lastTimestamp + 1);
@@ -399,6 +422,12 @@ export const useDevUIStore = create<DevUIStore>()(
clearDebugEvents: () => set({ debugEvents: [] }),
setIsResizing: (resizing) => set({ isResizing: resizing }),
// Debug Panel Preference Actions
setDebugPanelTab: (tab) => set({ debugPanelTab: tab }),
setDebugTraceSubTab: (tab) => set({ debugTraceSubTab: tab }),
setContextInspectorViewMode: (mode) => set({ contextInspectorViewMode: mode }),
setContextInspectorCumulative: (cumulative) => set({ contextInspectorCumulative: cumulative }),
// ========================================
// Modal Actions
// ========================================
@@ -605,8 +634,14 @@ export const useDevUIStore = create<DevUIStore>()(
debugPanelMinimized: state.debugPanelMinimized,
debugPanelWidth: state.debugPanelWidth,
showToolCalls: state.showToolCalls, // Persist tool calls visibility preference
streamingEnabled: state.streamingEnabled, // Persist streaming mode preference
oaiMode: state.oaiMode, // Persist OpenAI proxy mode settings
azureDeploymentEnabled: state.azureDeploymentEnabled, // Persist Azure deployment preference
// Debug panel tab preferences
debugPanelTab: state.debugPanelTab,
debugTraceSubTab: state.debugTraceSubTab,
contextInspectorViewMode: state.contextInspectorViewMode,
contextInspectorCumulative: state.contextInspectorCumulative,
}),
}
),
@@ -130,6 +130,7 @@ export type {
ResponseCompletedEvent,
ResponseFailedEvent,
ResponseFunctionResultComplete,
ResponseFunctionToolCall,
StructuredEvent,
WorkflowItem,
ExecutorActionItem,
@@ -159,7 +160,7 @@ export interface MetaResponse {
framework: string;
runtime: "python" | "dotnet";
capabilities: {
tracing: boolean;
instrumentation: boolean;
openai_proxy: boolean;
deployment: boolean;
};
@@ -266,6 +267,29 @@ export interface CheckpointInfo {
metadata?: Record<string, unknown>;
}
// Full checkpoint data structure
export interface FullCheckpoint {
checkpoint_id: string;
workflow_id: string;
timestamp: string;
messages: Record<string, unknown[]>;
shared_state: Record<string, unknown>;
pending_request_info_events: Record<string, PendingRequestInfoEvent>;
iteration_count: number;
metadata: Record<string, unknown>;
version: string;
}
// Pending request info event data
export interface PendingRequestInfoEvent {
source_executor_id: string;
request_type?: string;
response_type?: string;
request_data?: Record<string, unknown>;
request_schema?: Record<string, unknown>;
timestamp?: string;
}
// Checkpoint item from conversation items API
export interface CheckpointItem {
id: string;
@@ -281,16 +305,6 @@ export interface CheckpointItem {
message_count: number;
size_bytes?: number;
version: string;
full_checkpoint?: {
checkpoint_id: string;
workflow_id: string;
timestamp: string;
messages: Record<string, unknown[]>;
shared_state: Record<string, unknown>;
pending_request_info_events: Record<string, unknown>;
iteration_count: number;
metadata: Record<string, unknown>;
version: string;
};
full_checkpoint?: FullCheckpoint;
};
}
@@ -3,6 +3,45 @@
* Based on OpenAI's official response types
*/
// OpenAI Response Error (from response_error.py)
export type ResponseErrorCode =
| "server_error"
| "rate_limit_exceeded"
| "invalid_prompt"
| "vector_store_timeout"
| "invalid_image"
| "invalid_image_format"
| "invalid_base64_image"
| "invalid_image_url"
| "image_too_large"
| "image_too_small"
| "image_parse_error"
| "image_content_policy_violation"
| "invalid_image_mode"
| "image_file_too_large"
| "unsupported_image_media_type"
| "empty_image_file"
| "failed_to_download_image"
| "image_file_not_found";
export interface ResponseError {
code: ResponseErrorCode;
message: string;
}
// OpenAI Response Usage (from response_usage.py)
export interface ResponseUsage {
input_tokens: number;
output_tokens: number;
total_tokens: number;
input_tokens_details?: {
cached_tokens: number;
};
output_tokens_details?: {
reasoning_tokens: number;
};
}
// Core OpenAI Response Stream Event
export interface ResponseStreamEvent {
type: string;
@@ -28,7 +67,7 @@ export interface ResponseCreatedEvent {
id: string;
status: "in_progress";
created_at: number;
output?: any[];
output?: ResponseOutputItem[];
};
sequence_number?: number;
}
@@ -47,9 +86,11 @@ export interface ResponseCompletedEvent {
response: {
id: string;
status?: "completed";
usage?: any; // Optional usage information
usage?: ResponseUsage; // Optional usage information
model?: string; // Optional model information
[key: string]: any; // Allow any additional fields
output?: ResponseOutputItem[]; // Output items
error?: ResponseError; // Error if failed
metadata?: Record<string, unknown>; // Additional metadata
};
sequence_number?: number;
}
@@ -59,7 +100,7 @@ export interface ResponseFailedEvent {
response: {
id: string;
status: "failed";
error?: any;
error?: ResponseError;
};
sequence_number?: number;
}
@@ -157,16 +198,16 @@ export interface WorkflowItem {
type: string; // "executor_action", "workflow_action", "message", or any future type
id: string;
status?: "in_progress" | "completed" | "failed" | "cancelled";
[key: string]: any; // Allow any additional fields
[key: string]: unknown; // Allow any additional fields with unknown type
}
// Executor Action Item (DevUI specific)
export interface ExecutorActionItem extends WorkflowItem {
type: "executor_action";
executor_id: string;
metadata?: Record<string, any>;
result?: any;
error?: any;
metadata?: Record<string, unknown>;
result?: unknown;
error?: unknown;
}
// Type guard for executor actions
@@ -364,18 +405,7 @@ export interface ResponseOutputText {
annotations: Record<string, unknown>[];
}
export interface ResponseUsage {
input_tokens: number;
output_tokens: number;
total_tokens: number;
input_tokens_details: {
cached_tokens: number;
};
output_tokens_details: {
reasoning_tokens: number;
};
}
// Note: ResponseUsage is defined at the top of this file
// Request format for Agent Framework
// AgentFrameworkRequest moved to agent-framework.ts to avoid conflicts
@@ -404,11 +434,62 @@ export interface MessageInputTextContent {
text: string;
}
// Annotation types for output text (from response_output_text.py)
export interface AnnotationFileCitation {
type: "file_citation";
file_id: string;
filename: string;
index: number;
}
export interface AnnotationURLCitation {
type: "url_citation";
url: string;
title: string;
start_index: number;
end_index: number;
}
export interface AnnotationContainerFileCitation {
type: "container_file_citation";
container_id: string;
file_id: string;
filename: string;
start_index: number;
end_index: number;
}
export interface AnnotationFilePath {
type: "file_path";
file_id: string;
index: number;
}
export type OutputTextAnnotation =
| AnnotationFileCitation
| AnnotationURLCitation
| AnnotationContainerFileCitation
| AnnotationFilePath;
// Logprob types for output text
export interface LogprobTopLogprob {
token: string;
bytes: number[];
logprob: number;
}
export interface Logprob {
token: string;
bytes: number[];
logprob: number;
top_logprobs: LogprobTopLogprob[];
}
export interface MessageOutputTextContent {
type: "output_text";
text: string;
annotations?: any[];
logprobs?: any[];
annotations?: OutputTextAnnotation[];
logprobs?: Logprob[];
}
export interface MessageInputImage {
@@ -541,9 +622,218 @@ export interface Conversation {
metadata?: Record<string, unknown>;
}
// List response
// ============================================================================
// OpenTelemetry Trace Attribute Keys
// Mirrored from Python: agent_framework/observability.py ObservabilityAttributes
// ============================================================================
/**
* Standard attribute keys for OpenTelemetry traces.
* These match the Python ObservabilityAttributes enum exactly.
*/
export const TraceAttributes = {
// Request attributes
MODEL: "gen_ai.request.model",
MAX_TOKENS: "gen_ai.request.max_tokens",
TEMPERATURE: "gen_ai.request.temperature",
TOP_P: "gen_ai.request.top_p",
SEED: "gen_ai.request.seed",
FREQUENCY_PENALTY: "gen_ai.request.frequency_penalty",
PRESENCE_PENALTY: "gen_ai.request.presence_penalty",
STOP_SEQUENCES: "gen_ai.request.stop_sequences",
// Response attributes
FINISH_REASONS: "gen_ai.response.finish_reasons",
RESPONSE_ID: "gen_ai.response.id",
// Usage attributes
INPUT_TOKENS: "gen_ai.usage.input_tokens",
OUTPUT_TOKENS: "gen_ai.usage.output_tokens",
// Content attributes (messages sent/received)
INPUT_MESSAGES: "gen_ai.input.messages",
OUTPUT_MESSAGES: "gen_ai.output.messages",
SYSTEM_INSTRUCTIONS: "gen_ai.system_instructions",
OUTPUT_TYPE: "gen_ai.output.type",
// Tool attributes
TOOL_CALL_ID: "gen_ai.tool.call.id",
TOOL_NAME: "gen_ai.tool.name",
TOOL_TYPE: "gen_ai.tool.type",
TOOL_DEFINITIONS: "gen_ai.tool.definitions",
TOOL_ARGUMENTS: "gen_ai.tool.call.arguments",
TOOL_RESULT: "gen_ai.tool.call.result",
// Agent attributes
AGENT_ID: "gen_ai.agent.id",
AGENT_NAME: "gen_ai.agent.name",
AGENT_DESCRIPTION: "gen_ai.agent.description",
CONVERSATION_ID: "gen_ai.conversation.id",
// Workflow attributes
WORKFLOW_ID: "workflow.id",
WORKFLOW_NAME: "workflow.name",
EXECUTOR_ID: "executor.id",
EXECUTOR_TYPE: "executor.type",
} as const;
/**
* Type for trace attribute keys - ensures type safety when accessing attributes
*/
export type TraceAttributeKey = (typeof TraceAttributes)[keyof typeof TraceAttributes];
/**
* Typed interface for known trace attributes.
* Using this instead of Record<string, unknown> provides compile-time safety.
*/
export interface TypedTraceAttributes {
// Request attributes
[TraceAttributes.MODEL]?: string;
[TraceAttributes.MAX_TOKENS]?: number;
[TraceAttributes.TEMPERATURE]?: number;
[TraceAttributes.TOP_P]?: number;
[TraceAttributes.SEED]?: number;
// Usage attributes
[TraceAttributes.INPUT_TOKENS]?: number;
[TraceAttributes.OUTPUT_TOKENS]?: number;
// Content attributes (JSON strings that need parsing)
[TraceAttributes.INPUT_MESSAGES]?: string;
[TraceAttributes.OUTPUT_MESSAGES]?: string;
[TraceAttributes.SYSTEM_INSTRUCTIONS]?: string;
// Tool attributes
[TraceAttributes.TOOL_NAME]?: string;
[TraceAttributes.TOOL_DEFINITIONS]?: string;
[TraceAttributes.TOOL_ARGUMENTS]?: string;
[TraceAttributes.TOOL_RESULT]?: string;
// Agent/workflow attributes
[TraceAttributes.AGENT_NAME]?: string;
[TraceAttributes.WORKFLOW_NAME]?: string;
[TraceAttributes.EXECUTOR_ID]?: string;
// Allow additional unknown attributes
[key: string]: unknown;
}
/**
* Message part types used in gen_ai.input.messages / gen_ai.output.messages
*
* Source: Python agent_framework/observability.py _to_otel_part()
*
* Python produces:
* - text: {"type": "text", "content": "..."}
* - function_call: {"type": "tool_call", "id": "...", "name": "...", "arguments": "..."}
* - function_result: {"type": "tool_call_response", "id": "...", "response": "..."}
*/
// Text content part
// Python: {"type": "text", "content": content.text}
export interface TraceTextPart {
type: "text";
content?: string; // Agent Framework format (from Python)
text?: string; // Alternative field name (OpenAI format)
}
// Tool/function call part (from assistant)
// Python: {"type": "tool_call", "id": content.call_id, "name": content.name, "arguments": content.arguments}
export interface TraceToolCallPart {
type: "tool_call" | "function_call";
id?: string; // Tool call ID for correlation
name?: string; // Function name
arguments?: string; // JSON string of arguments
}
// Tool/function result part (response to tool call)
// Python: {"type": "tool_call_response", "id": content.call_id, "response": response}
export interface TraceToolResultPart {
type: "tool_call_response" | "tool_result" | "function_result";
id?: string; // Tool call ID for correlation
response?: string; // Agent Framework format (from Python)
result?: string; // Alternative field name (other formats)
}
// Union type for all message parts
export type TraceMessagePart = TraceTextPart | TraceToolCallPart | TraceToolResultPart;
// Helper type guard functions
export function isTextPart(part: TraceMessagePart): part is TraceTextPart {
return part.type === "text";
}
export function isToolCallPart(part: TraceMessagePart): part is TraceToolCallPart {
return part.type === "tool_call" || part.type === "function_call";
}
export function isToolResultPart(part: TraceMessagePart): part is TraceToolResultPart {
return (
part.type === "tool_result" ||
part.type === "function_result" ||
part.type === "tool_call_response"
);
}
/**
* Message structure in gen_ai.input.messages / gen_ai.output.messages
* Format: [{role: "system"|"user"|"assistant"|"tool", parts: [...]}]
*/
export interface TraceMessage {
role: "system" | "user" | "assistant" | "tool";
parts: TraceMessagePart[];
}
/**
* Helper to safely get a typed attribute value
*/
export function getTraceAttribute<K extends keyof TypedTraceAttributes>(
attributes: TypedTraceAttributes,
key: K
): TypedTraceAttributes[K] {
return attributes[key];
}
/**
* Helper to parse JSON message array from trace attributes
*/
export function parseTraceMessages(jsonString: string | undefined): TraceMessage[] {
if (!jsonString) return [];
try {
return JSON.parse(jsonString) as TraceMessage[];
} catch {
return [];
}
}
// Stored trace span (from conversation metadata)
export interface TraceSpan {
type?: string;
span_id: string;
trace_id: string;
parent_span_id?: string | null;
operation_name: string;
start_time: number;
end_time?: number;
duration_ms?: number;
attributes: TypedTraceAttributes;
status: string;
response_id?: string | null;
entity_id?: string;
events?: Array<{
name: string;
timestamp: number;
attributes?: Record<string, unknown>;
}>;
error?: string;
}
// List response with trace metadata (DevUI extension)
export interface ConversationItemsListResponse {
object: "list";
data: ConversationItem[];
has_more: boolean;
metadata?: {
traces?: TraceSpan[];
};
}
@@ -99,54 +99,54 @@ export interface Workflow {
* Type guards for runtime type checking
*/
export function isWorkflow(obj: unknown): obj is Workflow {
if (typeof obj !== "object" || obj === null) return false;
const record = obj as Record<string, unknown>;
return (
typeof obj === "object" &&
obj !== null &&
"id" in obj &&
"edge_groups" in obj &&
"executors" in obj &&
"start_executor_id" in obj &&
"max_iterations" in obj &&
typeof (obj as any).id === "string" &&
Array.isArray((obj as any).edge_groups) &&
typeof (obj as any).executors === "object" &&
typeof (obj as any).start_executor_id === "string" &&
typeof (obj as any).max_iterations === "number"
"id" in record &&
"edge_groups" in record &&
"executors" in record &&
"start_executor_id" in record &&
"max_iterations" in record &&
typeof record.id === "string" &&
Array.isArray(record.edge_groups) &&
typeof record.executors === "object" &&
typeof record.start_executor_id === "string" &&
typeof record.max_iterations === "number"
);
}
export function isExecutor(obj: unknown): obj is Executor {
if (typeof obj !== "object" || obj === null) return false;
const record = obj as Record<string, unknown>;
return (
typeof obj === "object" &&
obj !== null &&
"id" in obj &&
"type" in obj &&
typeof (obj as any).id === "string" &&
typeof (obj as any).type === "string"
"id" in record &&
"type" in record &&
typeof record.id === "string" &&
typeof record.type === "string"
);
}
export function isEdge(obj: unknown): obj is Edge {
if (typeof obj !== "object" || obj === null) return false;
const record = obj as Record<string, unknown>;
return (
typeof obj === "object" &&
obj !== null &&
"source_id" in obj &&
"target_id" in obj &&
typeof (obj as any).source_id === "string" &&
typeof (obj as any).target_id === "string"
"source_id" in record &&
"target_id" in record &&
typeof record.source_id === "string" &&
typeof record.target_id === "string"
);
}
export function isEdgeGroup(obj: unknown): obj is EdgeGroup {
if (typeof obj !== "object" || obj === null) return false;
const record = obj as Record<string, unknown>;
return (
typeof obj === "object" &&
obj !== null &&
"id" in obj &&
"type" in obj &&
"edges" in obj &&
typeof (obj as any).id === "string" &&
typeof (obj as any).type === "string" &&
Array.isArray((obj as any).edges)
"id" in record &&
"type" in record &&
"edges" in record &&
typeof record.id === "string" &&
typeof record.type === "string" &&
Array.isArray(record.edges)
);
}
@@ -7,6 +7,8 @@ import type {
import type {
ExtendedResponseStreamEvent,
ResponseWorkflowEventComplete,
ResponseOutputItemAddedEvent,
ResponseOutputItemDoneEvent,
JSONSchemaProperty,
} from "@/types";
import type { Workflow } from "@/types/workflow";
@@ -389,9 +391,10 @@ export function processWorkflowEvents(
events.forEach((event) => {
// Handle new standard OpenAI events
if (event.type === "response.output_item.added" || event.type === "response.output_item.done") {
const item = (event as any).item;
if (item && item.type === "executor_action" && item.executor_id) {
const executorId = item.executor_id;
const outputEvent = event as ResponseOutputItemAddedEvent | ResponseOutputItemDoneEvent;
const item = outputEvent.item;
if (item && item.type === "executor_action" && "executor_id" in item) {
const executorId = item.executor_id as string;
const itemId = item.id;
// Track the latest item ID for this executor
@@ -492,16 +495,17 @@ export function processWorkflowEvents(
// This prevents setting to "running" after the executor has already completed
const hasCompletionEvent = events.some((event) => {
if (event.type === "response.output_item.done") {
const item = (event as any).item;
return item && item.type === "executor_action" && item.executor_id === startExecutorId;
const outputEvent = event as ResponseOutputItemDoneEvent;
const item = outputEvent.item;
return item && item.type === "executor_action" && "executor_id" in item && item.executor_id === startExecutorId;
}
if (event.type === "response.workflow_event.completed" && "data" in event && event.data) {
const data = event.data as any;
const data = event.data as Record<string, unknown>;
return data.executor_id === startExecutorId &&
(data.event_type === "ExecutorCompletedEvent" ||
data.event_type === "ExecutorFailedEvent" ||
data.event_type?.includes("Error") ||
data.event_type?.includes("Failed"));
(typeof data.event_type === "string" && data.event_type.includes("Error")) ||
(typeof data.event_type === "string" && data.event_type.includes("Failed")));
}
return false;
});
@@ -565,9 +569,10 @@ export function getCurrentlyExecutingExecutors(
events.forEach((event) => {
// Handle new standard OpenAI events
if (event.type === "response.output_item.added" || event.type === "response.output_item.done") {
const item = (event as any).item;
if (item && item.type === "executor_action" && item.executor_id) {
const executorId = item.executor_id;
const outputEvent = event as ResponseOutputItemAddedEvent | ResponseOutputItemDoneEvent;
const item = outputEvent.item;
if (item && item.type === "executor_action" && "executor_id" in item) {
const executorId = item.executor_id as string;
executorTimeline[executorId] = {
lastEvent: event.type === "response.output_item.added" ? "ExecutorInvokedEvent" : "ExecutorCompletedEvent",