chore: fix tracing for text chat mode

This commit is contained in:
Abhishek Kumar 2026-05-21 12:30:56 +05:30
parent e23cce444f
commit 08a2435ba5
31 changed files with 1753 additions and 597 deletions

View file

@ -1,6 +1,6 @@
"use client";
import { ChevronLeft, ChevronRight, Download, Globe } from 'lucide-react';
import { ArrowDownLeft, ArrowUpRight, ChevronLeft, ChevronRight, Download, Globe, MessageSquare, Phone } from 'lucide-react';
import { useRouter, useSearchParams } from 'next/navigation';
import { useCallback, useEffect, useId, useState } from 'react';
import TimezoneSelect, { type ITimezoneOption } from 'react-timezone-select';
@ -23,6 +23,7 @@ import {
TableHeader,
TableRow,
} from '@/components/ui/table';
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
import { useUserConfig } from '@/context/UserConfigContext';
import { useAuth } from '@/lib/auth';
import { usageFilterAttributes } from '@/lib/filterAttributes';
@ -32,6 +33,53 @@ import { ActiveFilter, DateRangeValue } from '@/types/filters';
// Get local timezone
const getLocalTimezone = () => Intl.DateTimeFormat().resolvedOptions().timeZone;
// Collapse a run's `mode` (from WorkflowRunMode in api/enums.py) into a coarse
// channel. Telephony providers (twilio, plivo, telnyx, vonage, vobiz, cloudonix,
// ari, ...) are phone calls; webrtc/smallwebrtc are browser web calls; textchat
// is a text conversation. Anything unknown falls back to "phone".
const WEB_CALL_MODES = new Set(['webrtc', 'smallwebrtc']);
const TEXT_CHAT_MODES = new Set(['textchat']);
const getCallChannel = (mode?: string | null): 'phone' | 'web' | 'chat' => {
if (mode && TEXT_CHAT_MODES.has(mode)) return 'chat';
if (mode && WEB_CALL_MODES.has(mode)) return 'web';
return 'phone';
};
// Render the call's channel (mode) and direction (call_type) as two compact
// icons in a single cell, with a tooltip spelling out the full label. The
// channel icon shows medium/how (phone / web / chat); the colored arrow shows
// direction (inbound = incoming/emerald, outbound = outgoing/blue).
const CallTypeCell = ({ mode, callType }: { mode?: string | null; callType?: string | null }) => {
if (!mode && !callType) {
return <span className="text-sm text-muted-foreground">-</span>;
}
const channel = getCallChannel(mode);
const ChannelIcon = channel === 'chat' ? MessageSquare : channel === 'web' ? Globe : Phone;
const channelLabel = channel === 'chat' ? 'Text chat' : channel === 'web' ? 'Web call' : 'Phone call';
const isInbound = callType === 'inbound';
const DirectionIcon = isInbound ? ArrowDownLeft : ArrowUpRight;
const directionLabel = isInbound ? 'Inbound' : 'Outbound';
return (
<Tooltip>
<TooltipTrigger asChild>
<span className="inline-flex items-center gap-1">
<ChannelIcon className="h-4 w-4 text-muted-foreground" />
<DirectionIcon
className={`h-3.5 w-3.5 ${isInbound ? 'text-emerald-600' : 'text-blue-600'}`}
/>
</span>
</TooltipTrigger>
<TooltipContent sideOffset={4}>
{directionLabel} · {channelLabel}
</TooltipContent>
</Tooltip>
);
};
export default function UsagePage() {
const router = useRouter();
const searchParams = useSearchParams();
@ -534,13 +582,7 @@ export default function UsagePage() {
</TableCell>
<TableCell>{run.workflow_name || 'Unknown'}</TableCell>
<TableCell>
{run.call_type ? (
<Badge variant={run.call_type === 'inbound' ? "secondary" : "default"}>
{run.call_type === 'inbound' ? 'Inbound' : 'Outbound'}
</Badge>
) : (
<span className="text-sm text-muted-foreground">-</span>
)}
<CallTypeCell mode={run.mode} callType={run.call_type} />
</TableCell>
<TableCell className="text-sm">
{(run.call_type === 'inbound'

View file

@ -385,45 +385,6 @@ export const WorkflowEditorHeader = ({
</Popover>
)}
<Button
variant="outline"
className="flex items-center gap-2 bg-transparent border-[#3a3a3a] hover:bg-[#2a2a2a] text-white"
onClick={onTestAgentClick}
>
<Bot className="w-4 h-4" />
Test Agent
</Button>
{!isViewingHistoricalVersion && (
<Button
variant="outline"
className="flex items-center gap-2 bg-transparent border-[#3a3a3a] hover:bg-[#2a2a2a] text-white"
disabled={isCallDisabled}
onClick={onPhoneCallClick}
>
<Phone className="w-4 h-4" />
Phone Call
</Button>
)}
{/* Save button (only shown when editing the draft) */}
{!isViewingHistoricalVersion && (
<Button
onClick={handleSave}
disabled={!isDirty || savingWorkflow}
className="bg-teal-600 hover:bg-teal-700 text-white px-4"
>
{savingWorkflow ? (
<>
<LoaderCircle className="w-4 h-4 mr-2 animate-spin" />
Saving...
</>
) : (
"Save"
)}
</Button>
)}
{/* Publish button (only when on draft with no unsaved changes) */}
{!isViewingHistoricalVersion && hasDraft && (
<Button
@ -446,6 +407,45 @@ export const WorkflowEditorHeader = ({
</Button>
)}
{!isViewingHistoricalVersion && (
<Button
variant="outline"
className="flex items-center gap-2 bg-transparent border-[#3a3a3a] hover:bg-[#2a2a2a] text-white"
disabled={isCallDisabled}
onClick={onPhoneCallClick}
>
<Phone className="w-4 h-4" />
Phone Call
</Button>
)}
<Button
variant="outline"
className="flex items-center gap-2 bg-transparent border-[#3a3a3a] hover:bg-[#2a2a2a] text-white"
onClick={onTestAgentClick}
>
<Bot className="w-4 h-4" />
Test Agent
</Button>
{/* Save button (only shown when editing the draft) */}
{!isViewingHistoricalVersion && (
<Button
onClick={handleSave}
disabled={!isDirty || savingWorkflow}
className="bg-teal-600 hover:bg-teal-700 text-white px-4"
>
{savingWorkflow ? (
<>
<LoaderCircle className="w-4 h-4 mr-2 animate-spin" />
Saving...
</>
) : (
"Save"
)}
</Button>
)}
{/* More options dropdown */}
<DropdownMenu>
<DropdownMenuTrigger asChild>

View file

@ -74,6 +74,12 @@ type TextChatSession = Omit<WorkflowRunTextSessionResponse, "session_data" | "ch
checkpoint: TextChatCheckpoint;
};
interface TextChatToolEvent {
kind: "start" | "result";
functionName: string;
resultText?: string;
}
function toTextChatSession(response: WorkflowRunTextSessionResponse): TextChatSession {
return {
...response,
@ -182,6 +188,66 @@ function TypingBubble() {
);
}
function stringifyToolResult(result: unknown) {
if (result == null) return "No result";
if (typeof result === "string") return result;
try {
return JSON.stringify(result);
} catch {
return String(result);
}
}
function extractToolEvents(events: Array<Record<string, unknown>>): TextChatToolEvent[] {
return events.reduce<TextChatToolEvent[]>((acc, event) => {
const eventType = event.type;
const payload = event.payload;
if (!payload || typeof payload !== "object") {
return acc;
}
const typedPayload = payload as Record<string, unknown>;
const functionName = typeof typedPayload.function_name === "string"
? typedPayload.function_name
: "tool";
if (eventType === "tool_call_started") {
acc.push({ kind: "start", functionName });
return acc;
}
if (eventType === "tool_call_result") {
acc.push({
kind: "result",
functionName,
resultText: stringifyToolResult(typedPayload.result),
});
return acc;
}
return acc;
}, []);
}
function ToolEventBubble({ event }: { event: TextChatToolEvent }) {
return (
<div className="flex justify-start">
<div className="max-w-[85%] rounded-2xl rounded-bl-md border border-border/70 bg-background px-3.5 py-2 text-sm leading-6 text-foreground">
<div className="flex items-center gap-2">
<Badge variant="outline" className="h-5 px-1.5 text-[10px] uppercase tracking-[0.14em]">
{event.kind === "start" ? "Tool" : "Result"}
</Badge>
<span className="font-mono text-xs text-muted-foreground">
{event.kind === "start"
? `${event.functionName}()`
: `${event.functionName} -> ${event.resultText ?? "No result"}`}
</span>
</div>
</div>
</div>
);
}
function EmbeddedVoiceTester({
workflowId,
workflowRunId,
@ -357,14 +423,17 @@ function ManualTextChat({
initialContextVariables,
disabled,
disabledReason,
onActiveChange,
}: {
workflowId: number;
ready: boolean;
initialContextVariables?: Record<string, string>;
disabled: boolean;
disabledReason: string | null;
onActiveChange?: (active: boolean) => void;
}) {
const [session, setSession] = useState<TextChatSession | null>(null);
const [started, setStarted] = useState(false);
const [draft, setDraft] = useState("");
const [creatingSession, setCreatingSession] = useState(false);
const [sendingMessage, setSendingMessage] = useState(false);
@ -403,11 +472,15 @@ function ManualTextChat({
}, [disabled, initialContextVariables, workflowId]);
useEffect(() => {
if (creatingSession || session || !ready || disabled) {
if (!started || creatingSession || session || !ready || disabled) {
return;
}
void createSession();
}, [createSession, creatingSession, disabled, ready, session]);
}, [createSession, creatingSession, disabled, ready, session, started]);
useEffect(() => {
onActiveChange?.(started);
}, [onActiveChange, started]);
const sendMessage = useCallback(async () => {
if (!session || !draft.trim() || disabled) return;
@ -460,6 +533,25 @@ function ManualTextChat({
const inputDisabled = disabled || !session;
if (!started && !session) {
return (
<div className="flex h-full min-h-0 flex-col gap-3">
{disabledReason ? <DisabledNotice reason={disabledReason} /> : null}
<EmptyState
icon={<MessageSquareText className="h-7 w-7" />}
title="Chat with this agent"
description="Test the agent over a text conversation. Send messages and see how it responds, with tool calls and rewind support."
action={
<Button onClick={() => setStarted(true)} disabled={disabled || !ready}>
<MessageSquareText className="h-4 w-4" />
Start Test
</Button>
}
/>
</div>
);
}
return (
<div className="flex min-h-0 flex-1 flex-col">
{disabledReason ? (
@ -484,11 +576,19 @@ function ManualTextChat({
</div>
) : (
<div className="space-y-3 py-1">
{turns.map((turn) => (
{turns.map((turn) => {
const toolEvents = extractToolEvents(turn.events);
return (
<div key={turn.id} className="group space-y-1.5">
{turn.user_message ? (
<MessageBubble role="user" text={turn.user_message.text} />
) : null}
{toolEvents.map((event, index) => (
<ToolEventBubble
key={`${turn.id}-${event.kind}-${event.functionName}-${index}`}
event={event}
/>
))}
{turn.assistant_message ? (
<MessageBubble role="agent" text={turn.assistant_message.text} />
) : turn.status === "failed" ? (
@ -510,7 +610,8 @@ function ManualTextChat({
</button>
</div>
</div>
))}
);
})}
{sendingMessage ? <TypingBubble /> : null}
<div ref={scrollEndRef} />
</div>
@ -634,6 +735,7 @@ export function WorkflowTesterPanel({
const [activeMode, setActiveMode] = useState<"audio" | "text">("audio");
const [chatMode, setChatMode] = useState<"manual" | "simulated">("manual");
const [chatSessionKey, setChatSessionKey] = useState(0);
const [chatActive, setChatActive] = useState(false);
const [voiceRunId, setVoiceRunId] = useState<number | null>(null);
const [creatingVoiceRun, setCreatingVoiceRun] = useState(false);
const [tokenReady, setTokenReady] = useState(false);
@ -788,7 +890,7 @@ export function WorkflowTesterPanel({
<div className="flex h-full min-h-0 flex-col gap-3">
<div className="flex items-center justify-between gap-2">
<ChatModeToggle value={chatMode} onChange={setChatMode} />
{chatMode === "manual" ? (
{chatMode === "manual" && chatActive ? (
<Button
variant="ghost"
size="sm"
@ -810,6 +912,7 @@ export function WorkflowTesterPanel({
initialContextVariables={initialContextVariables}
disabled={testerBlocked}
disabledReason={effectiveDisabledReason}
onActiveChange={setChatActive}
/>
) : (
<AiSimulatorPlaceholder disabledReason={effectiveDisabledReason} />

View file

@ -27,6 +27,7 @@ import { downloadFile } from '@/lib/files';
import { getRandomId } from '@/lib/utils';
interface WorkflowRunResponse {
mode: string;
is_completed: boolean;
transcript_url: string | null;
recording_url: string | null;
@ -183,6 +184,7 @@ export default function WorkflowRunPage() {
});
setIsLoading(false);
const runData = {
mode: response.data?.mode ?? '',
is_completed: response.data?.is_completed ?? false,
transcript_url: response.data?.transcript_url ?? null,
recording_url: response.data?.recording_url ?? null,
@ -223,6 +225,8 @@ export default function WorkflowRunPage() {
};
let returnValue = null;
const isTextChatRun = workflowRun?.mode === WORKFLOW_RUN_MODES.TEXTCHAT;
const showHistoricalRunView = Boolean(workflowRun?.is_completed || isTextChatRun);
if (isLoading) {
returnValue = (
@ -246,7 +250,7 @@ export default function WorkflowRunPage() {
</div>
);
}
else if (workflowRun?.is_completed) {
else if (showHistoricalRunView) {
returnValue = (
<div className={`flex ${RUN_SHELL_HEIGHT_CLASS} min-h-0 w-full overflow-hidden bg-background`}>
<div className="min-w-0 flex-1 overflow-y-auto">
@ -254,27 +258,35 @@ export default function WorkflowRunPage() {
<Card className="border-border">
<CardHeader className="flex flex-row items-center justify-between">
<div className="flex items-center gap-4">
<CardTitle className="text-2xl">Agent Run Completed</CardTitle>
<div className="h-8 w-8 bg-emerald-500/20 rounded-full flex items-center justify-center">
<svg className="h-5 w-5 text-emerald-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M5 13l4 4L19 7" />
</svg>
<CardTitle className="text-2xl">
{isTextChatRun ? 'Text Chat Session' : 'Agent Run Completed'}
</CardTitle>
<div className={`h-8 w-8 rounded-full flex items-center justify-center ${isTextChatRun ? 'bg-sky-500/15' : 'bg-emerald-500/20'}`}>
{isTextChatRun ? (
<FileText className="h-5 w-5 text-sky-500" />
) : (
<svg className="h-5 w-5 text-emerald-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M5 13l4 4L19 7" />
</svg>
)}
</div>
</div>
<div className="flex items-center gap-2">
<Button
onClick={handleTestAgain}
disabled={startingCall}
variant="outline"
className="gap-2"
>
{startingCall ? (
<LoaderCircle className="h-4 w-4 animate-spin" />
) : (
<Phone className="h-4 w-4" />
)}
{startingCall ? 'Starting...' : 'Test Again'}
</Button>
{!isTextChatRun && (
<Button
onClick={handleTestAgain}
disabled={startingCall}
variant="outline"
className="gap-2"
>
{startingCall ? (
<LoaderCircle className="h-4 w-4 animate-spin" />
) : (
<Phone className="h-4 w-4" />
)}
{startingCall ? 'Starting...' : 'Test Again'}
</Button>
)}
<Link href={`/workflow/${params.workflowId}`}>
<Button
ref={customizeButtonRef}
@ -294,41 +306,49 @@ export default function WorkflowRunPage() {
</div>
</CardHeader>
<CardContent>
<p className="text-muted-foreground mb-8">Your voice agent run has been completed successfully. You can preview or download the transcript and recording.</p>
<p className="text-muted-foreground mb-8">
{isTextChatRun
? 'Review the conversation history, metrics, and context captured for this text session.'
: 'Your voice agent run has been completed successfully. You can preview or download the transcript and recording.'}
</p>
<div className="flex flex-wrap gap-4">
<div className="flex items-center gap-2">
<span className="text-sm text-muted-foreground">Preview:</span>
<MediaPreviewButton
recordingUrl={workflowRun?.recording_url}
transcriptUrl={workflowRun?.transcript_url}
runId={Number(params.runId)}
onOpenPreview={openPreview}
/>
</div>
<div className="flex items-center gap-2 border-l border-border pl-4">
<span className="text-sm text-muted-foreground">Download:</span>
<Button
onClick={() => downloadFile(workflowRun?.transcript_url)}
disabled={!workflowRun?.transcript_url || !auth.isAuthenticated}
size="sm"
className="gap-2"
>
<FileText className="h-4 w-4" />
Transcript
</Button>
<Button
onClick={() => downloadFile(workflowRun?.recording_url)}
disabled={!workflowRun?.recording_url || !auth.isAuthenticated}
size="sm"
className="gap-2"
>
<Video className="h-4 w-4" />
Recording
</Button>
</div>
{!isTextChatRun && (
<>
<div className="flex items-center gap-2">
<span className="text-sm text-muted-foreground">Preview:</span>
<MediaPreviewButton
recordingUrl={workflowRun?.recording_url}
transcriptUrl={workflowRun?.transcript_url}
runId={Number(params.runId)}
onOpenPreview={openPreview}
/>
</div>
<div className="flex items-center gap-2 border-l border-border pl-4">
<span className="text-sm text-muted-foreground">Download:</span>
<Button
onClick={() => downloadFile(workflowRun?.transcript_url ?? null)}
disabled={!workflowRun?.transcript_url || !auth.isAuthenticated}
size="sm"
className="gap-2"
>
<FileText className="h-4 w-4" />
Transcript
</Button>
<Button
onClick={() => downloadFile(workflowRun?.recording_url ?? null)}
disabled={!workflowRun?.recording_url || !auth.isAuthenticated}
size="sm"
className="gap-2"
>
<Video className="h-4 w-4" />
Recording
</Button>
</div>
</>
)}
{workflowRun?.gathered_context?.trace_url && (
<div className="flex items-center gap-2 border-l border-border pl-4">
<div className={`flex items-center gap-2 ${isTextChatRun ? '' : 'border-l border-border pl-4'}`}>
<span className="text-sm text-muted-foreground">Trace:</span>
<Button
asChild
@ -352,19 +372,19 @@ export default function WorkflowRunPage() {
</Card>
<RunMetricsSection
costInfo={workflowRun?.cost_info}
logs={workflowRun?.logs}
gatheredContext={workflowRun?.gathered_context}
costInfo={workflowRun?.cost_info ?? null}
logs={workflowRun?.logs ?? null}
gatheredContext={workflowRun?.gathered_context ?? null}
/>
<div className="grid gap-6 md:grid-cols-2">
<ContextDisplay
title="Initial Context"
context={workflowRun?.initial_context}
context={workflowRun?.initial_context ?? null}
/>
<ContextDisplay
title="Gathered Context"
context={workflowRun?.gathered_context}
context={workflowRun?.gathered_context ?? null}
/>
</div>
@ -379,7 +399,7 @@ export default function WorkflowRunPage() {
<div className="h-full min-h-0 w-[420px] shrink-0 border-l border-border bg-background p-5">
<TranscriptRailFrame className="h-full">
<RealtimeFeedback mode="historical" logs={workflowRun?.logs} />
<RealtimeFeedback mode="historical" logs={workflowRun?.logs ?? null} />
</TranscriptRailFrame>
</div>
</div>
@ -411,7 +431,7 @@ export default function WorkflowRunPage() {
{dialog}
{/* Onboarding Tooltip for Customize Workflow */}
{workflowRun?.is_completed && (
{showHistoricalRunView && (
<OnboardingTooltip
title='Customize Your Workflow'
targetRef={customizeButtonRef}

View file

@ -4675,6 +4675,10 @@ export type WorkflowRunUsageResponse = {
* Call Type
*/
call_type?: string | null;
/**
* Mode
*/
mode?: string | null;
/**
* Disposition
*/