Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
43 changes: 43 additions & 0 deletions flight-booking-app/app/api/hooks/webhook/[token]/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
import { webhookHook } from '@/workflows/chat/hooks/webhook';

export async function POST(
request: Request,
{ params }: { params: Promise<{ token: string }> }
) {
const { token } = await params;

let body: any;
const contentType = request.headers.get('content-type') || '';
try {
if (contentType.includes('application/json')) {
body = await request.json();
} else {
body = await request.text();
}
} catch {
body = null;
}

await webhookHook.resume(token, {
method: request.method,
body,
});

return Response.json({ received: true });
}

export async function GET(
request: Request,
{ params }: { params: Promise<{ token: string }> }
) {
const { token } = await params;
const url = new URL(request.url);
const body = Object.fromEntries(url.searchParams.entries());

await webhookHook.resume(token, {
method: 'GET',
body: Object.keys(body).length > 0 ? body : null,
});

return Response.json({ received: true });
}
178 changes: 154 additions & 24 deletions flight-booking-app/app/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,8 @@ import {
ToolOutput,
} from "@/components/ai-elements/tool";
import { BookingApproval } from "@/components/booking-approval";
import { WebhookWaiting } from "@/components/webhook-waiting";
import { SandboxWidget } from "@/components/sandbox-widget";
import { useMultiTurnChat } from "@/hooks/use-multi-turn-chat";
import type { MyMessageMetadata } from "@/schemas/chat";
import ChatInput from "@/components/chat-input";
Expand All @@ -36,7 +38,7 @@ const SUGGESTIONS = [
];

const FULL_EXAMPLE_PROMPT =
"Book me the cheapest flight from San Francisco to Los Angeles for July 27 2025. My name is Pranay Prakash. I like window seats. Don't ask me for approval.";
"Book me the cheapest flight from San Francisco to Los Angeles for July 27 2026. My name is Peter Wielander. I like window seats. Don't ask me for approval.";

export default function ChatPage() {
const textareaRef = useRef<HTMLTextAreaElement>(null);
Expand Down Expand Up @@ -137,11 +139,49 @@ export default function ChatPage() {
const hasText = message.parts.some((part) => part.type === "text");
const isLastMessage = index === messages.length - 1;

// Deduplicate tool calls from doStreamStep retries.
// When a stream step fails and retries, each attempt emits tool-call
// chunks with different toolCallIds. Skip the phantom ones (no output)
// if a later call of the same tool type completed with output.
const supersededToolCallIds = new Set<string>();
const toolPartsByType = new Map<string, any[]>();
for (const part of message.parts) {
if ("toolCallId" in part && "state" in part) {
const type = (part as any).type as string;
if (!toolPartsByType.has(type)) toolPartsByType.set(type, []);
toolPartsByType.get(type)!.push(part);
}
}
for (const [, parts] of toolPartsByType) {
if (parts.length <= 1) continue;
const hasCompleted = parts.some(
(p: any) =>
p.state === "output-available" || p.state === "output-error"
);
if (hasCompleted) {
for (const p of parts) {
if (
p.state !== "output-available" &&
p.state !== "output-error"
) {
supersededToolCallIds.add(p.toolCallId);
}
}
}
}

return (
<div key={message.id}>
<Message from={message.role}>
<MessageContent className={message.role === "assistant" ? "w-full" : undefined}>
{message.parts.map((part, partIndex) => {
// Skip phantom tool calls from retried stream steps
if (
"toolCallId" in part &&
supersededToolCallIds.has((part as any).toolCallId)
) {
return null;
}
// Render text parts
if (part.type === "text") {
return (
Expand All @@ -158,14 +198,7 @@ export default function ChatPage() {
if (data?.type === "user-message") {
return null;
}
// Render observability events inline
return (
<WorkflowEventBadge
key={`${message.id}-data-${partIndex}`}
data={data}
t0={requestReceivedAt}
/>
);
return null;
}

// Render tool parts
Expand All @@ -175,7 +208,8 @@ export default function ChatPage() {
part.type === "tool-getAirportInfo" ||
part.type === "tool-bookFlight" ||
part.type === "tool-checkBaggageAllowance" ||
part.type === "tool-sleep"
part.type === "tool-sleep" ||
part.type === "tool-runCode"
) {
if (!("toolCallId" in part) || !("state" in part)) {
return null;
Expand Down Expand Up @@ -221,26 +255,74 @@ export default function ChatPage() {
);
}

// Render webhook waiting
if (part.type === "tool-waitForWebhook") {
return (
<WebhookWaiting
key={partIndex}
toolCallId={part.toolCallId}
input={part.input as { description: string }}
output={part.output as string}
/>
);
}

return null;
})}

{/* Loading indicators for assistant messages */}
{message.role === "assistant" &&
isLastMessage &&
!hasText && (
<>
{status === "submitted" && (
<Shimmer className="text-sm">
Sending message...
</Shimmer>
)}
{status === "streaming" && (
<Shimmer className="text-sm">
Waiting for response...
</Shimmer>
)}
</>
)}
!hasText && (() => {
const hasSleepActive = message.parts.some(
(part) =>
part.type === "tool-sleep" &&
"state" in part &&
part.state !== "output-available"
);
const hasApprovalActive = message.parts.some(
(part) =>
(part.type === "tool-bookingApproval" ||
part.type === "tool-waitForWebhook") &&
"state" in part &&
part.state !== "output-available"
);
const hasSandboxActive = message.parts.some(
(part) =>
part.type === "tool-runCode" &&
"state" in part &&
part.state !== "output-available"
);
return (
<>
{status === "submitted" && (
<Shimmer className="text-sm">
Sending message...
</Shimmer>
)}
{status === "streaming" && !hasSleepActive && !hasApprovalActive && !hasSandboxActive && (
<Shimmer className="text-sm">
Waiting for response...
</Shimmer>
)}
{status === "streaming" && hasSleepActive && (
<Shimmer className="text-sm">
Sleeping...
</Shimmer>
)}
{status === "streaming" && hasApprovalActive && (
<Shimmer className="text-sm">
Waiting for approval...
</Shimmer>
)}
{status === "streaming" && hasSandboxActive && (
<Shimmer className="text-sm">
Running in sandbox...
</Shimmer>
)}
</>
);
})()}
</MessageContent>
</Message>
</div>
Expand Down Expand Up @@ -294,6 +376,8 @@ export default function ChatPage() {
onSendMessage={sendMessage}
stop={stop}
/>

<SandboxWidget messages={messages} />
</div>
);
}
Expand Down Expand Up @@ -431,6 +515,10 @@ function WorkflowEventBadge({ data, t0 }: { data: any; t0: number | null }) {
// Skip rendering agent-step since we now have realtime tool-call events
return null;

case "sandbox-event":
// Rendered in the floating SandboxWidget instead
return null;

default:
// Render generic data messages
if (data?.message) {
Expand Down Expand Up @@ -661,6 +749,48 @@ function renderToolOutput(part: any) {
);
}

case "tool-runCode": {
if (!parsedOutput) return null;

// Error case: tool caught the error and returned structured data
if (parsedOutput.error) {
return (
<div className="p-3 space-y-2 text-sm">
<div className="font-medium text-red-400">
Sandbox error during: {parsedOutput.phase || "unknown"}
</div>
<pre className="whitespace-pre-wrap text-xs bg-red-500/10 rounded-md p-2 overflow-auto max-h-64 text-red-400">
{parsedOutput.message || JSON.stringify(parsedOutput, null, 2)}
</pre>
</div>
);
}

// Success case
const { exitCode, stdout, stderr } = parsedOutput;
return (
<div className="p-3 space-y-2 text-sm">
{exitCode === 0 ? (
<div className="font-medium text-green-600">Exited with code 0</div>
) : (
<div className="font-medium text-red-400">Exit code: {exitCode}</div>
)}
{stdout && stdout !== "(no output)" && (
<div className="space-y-1">
<div className="text-xs text-muted-foreground uppercase tracking-wide">stdout</div>
<pre className="whitespace-pre-wrap text-xs bg-muted/50 rounded-md p-2 overflow-auto max-h-64">{stdout}</pre>
</div>
)}
{stderr && (
<div className="space-y-1">
<div className="text-xs text-muted-foreground uppercase tracking-wide">stderr</div>
<pre className="whitespace-pre-wrap text-xs bg-red-500/10 rounded-md p-2 overflow-auto max-h-64">{stderr}</pre>
</div>
)}
</div>
);
}

default:
return null;
}
Expand Down
21 changes: 5 additions & 16 deletions flight-booking-app/components/booking-approval.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -20,22 +20,11 @@ export function BookingApproval({

// If we have output, the approval has been processed
if (output) {
try {
const json = JSON.parse(output) as { output: { value: string } };
return (
<div className="border rounded-lg p-4">
<p className="text-sm text-muted-foreground">{json.output.value}</p>
</div>
);
} catch (error) {
return (
<div className="border rounded-lg p-4">
<p className="text-sm text-muted-foreground">
Error parsing approval result: {(error as Error).message}
</p>
</div>
);
}
return (
<div className="border rounded-lg p-4">
<p className="text-sm text-muted-foreground">{output}</p>
</div>
);
}

const handleSubmit = async (approved: boolean) => {
Expand Down
Loading