+
+ {data.map((value, i) => {
+ const height = max > 0 ? Math.max((value / max) * 100, value > 0 ? 8 : 0) : 0;
+ return (
+
0 ? barColor : "transparent",
+ opacity: value > 0 ? 0.8 : 0,
+ }}
+ />
+ );
+ })}
+
+
{formatTotal(total)}
+
+ );
+}
diff --git a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.playground.$agentParam/route.tsx b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.playground.$agentParam/route.tsx
new file mode 100644
index 000000000..923fa2bb6
--- /dev/null
+++ b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.playground.$agentParam/route.tsx
@@ -0,0 +1,1190 @@
+import {
+ ArrowUpIcon,
+ BoltIcon,
+ CpuChipIcon,
+ StopIcon,
+ ArrowPathIcon,
+ TrashIcon,
+} from "@heroicons/react/20/solid";
+import { type MetaFunction } from "@remix-run/node";
+import { Link, useFetcher, useNavigate } from "@remix-run/react";
+import { typedjson, useTypedLoaderData } from "remix-typedjson";
+import { type LoaderFunctionArgs } from "@remix-run/server-runtime";
+import { useCallback, useEffect, useRef, useState } from "react";
+import { useChat } from "@ai-sdk/react";
+import { TriggerChatTransport } from "@trigger.dev/sdk/chat";
+import type { TriggerChatTaskParams, TriggerChatTaskResult } from "@trigger.dev/sdk/chat";
+import { MainCenteredContainer } from "~/components/layout/AppLayout";
+import { Badge } from "~/components/primitives/Badge";
+import { Button, LinkButton } from "~/components/primitives/Buttons";
+import { CopyButton } from "~/components/primitives/CopyButton";
+import { Header3 } from "~/components/primitives/Headers";
+import { Paragraph } from "~/components/primitives/Paragraph";
+import { Spinner } from "~/components/primitives/Spinner";
+import { Popover, PopoverContent, PopoverTrigger } from "~/components/primitives/Popover";
+import { ClockRotateLeftIcon } from "~/assets/icons/ClockRotateLeftIcon";
+import type { PlaygroundConversation } from "~/presenters/v3/PlaygroundPresenter.server";
+import { DateTime } from "~/components/primitives/DateTime";
+import { cn } from "~/utils/cn";
+import { JSONEditor } from "~/components/code/JSONEditor";
+import { ToolUseRow, AssistantResponse, ChatBubble } from "~/components/runs/v3/ai/AIChatMessages";
+import {
+ ResizableHandle,
+ ResizablePanel,
+ ResizablePanelGroup,
+} from "~/components/primitives/Resizable";
+import {
+ ClientTabs,
+ ClientTabsContent,
+ ClientTabsList,
+ ClientTabsTrigger,
+} from "~/components/primitives/ClientTabs";
+import { useEnvironment } from "~/hooks/useEnvironment";
+import { useOrganization } from "~/hooks/useOrganizations";
+import { useProject } from "~/hooks/useProject";
+import { findProjectBySlug } from "~/models/project.server";
+import { findEnvironmentBySlug } from "~/models/runtimeEnvironment.server";
+import { playgroundPresenter } from "~/presenters/v3/PlaygroundPresenter.server";
+import { requireUserId } from "~/services/session.server";
+import { EnvironmentParamSchema } from "~/utils/pathBuilder";
+import { env as serverEnv } from "~/env.server";
+import { generateJWT as internal_generateJWT } from "@trigger.dev/core/v3";
+import { extractJwtSigningSecretKey } from "~/services/realtime/jwtAuth.server";
+import { SchemaTabContent } from "~/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.test.tasks.$taskParam/SchemaTabContent";
+import { AIPayloadTabContent } from "~/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.test.tasks.$taskParam/AIPayloadTabContent";
+import type { UIMessage } from "@ai-sdk/react";
+
+export const meta: MetaFunction = () => {
+ return [{ title: "Playground | Trigger.dev" }];
+};
+
+export const loader = async ({ request, params }: LoaderFunctionArgs) => {
+ const userId = await requireUserId(request);
+ const { organizationSlug, projectParam, envParam } = EnvironmentParamSchema.parse(params);
+ const agentSlug = params.agentParam;
+
+ if (!agentSlug) {
+ throw new Response(undefined, { status: 404, statusText: "Agent not specified" });
+ }
+
+ const project = await findProjectBySlug(organizationSlug, projectParam, userId);
+ if (!project) {
+ throw new Response(undefined, { status: 404, statusText: "Project not found" });
+ }
+
+ const environment = await findEnvironmentBySlug(project.id, envParam, userId);
+ if (!environment) {
+ throw new Response(undefined, { status: 404, statusText: "Environment not found" });
+ }
+
+ const agent = await playgroundPresenter.getAgent({
+ environmentId: environment.id,
+ environmentType: environment.type,
+ agentSlug,
+ });
+
+ if (!agent) {
+ throw new Response(undefined, { status: 404, statusText: "Agent not found" });
+ }
+
+ const agentConfig = agent.config as { type?: string } | null;
+ const apiOrigin = serverEnv.API_ORIGIN || serverEnv.LOGIN_ORIGIN || "http://localhost:3030";
+
+ const recentConversations = await playgroundPresenter.getRecentConversations({
+ environmentId: environment.id,
+ agentSlug,
+ userId,
+ });
+
+ // Check for ?conversation= param to resume an existing conversation
+ const url = new URL(request.url);
+ const conversationId = url.searchParams.get("conversation");
+
+ let activeConversation: {
+ chatId: string;
+ runFriendlyId: string | null;
+ publicAccessToken: string | null;
+ clientData: unknown;
+ messages: unknown;
+ lastEventId: string | null;
+ } | null = null;
+
+ if (conversationId) {
+ const conv = recentConversations.find((c) => c.id === conversationId);
+ if (conv) {
+ let jwt: string | null = null;
+ if (conv.isActive && conv.runFriendlyId) {
+ jwt = await internal_generateJWT({
+ secretKey: extractJwtSigningSecretKey(environment),
+ payload: {
+ sub: environment.id,
+ pub: true,
+ scopes: [`read:runs:${conv.runFriendlyId}`, `write:inputStreams:${conv.runFriendlyId}`],
+ },
+ expirationTime: "1h",
+ });
+ }
+
+ activeConversation = {
+ chatId: conv.chatId,
+ runFriendlyId: conv.runFriendlyId,
+ publicAccessToken: jwt,
+ clientData: conv.clientData,
+ messages: conv.messages,
+ lastEventId: conv.lastEventId,
+ };
+ }
+ }
+
+ return typedjson({
+ agent: {
+ slug: agent.slug,
+ filePath: agent.filePath,
+ type: agentConfig?.type ?? "unknown",
+ clientDataSchema: agent.payloadSchema ?? null,
+ },
+ apiOrigin,
+ recentConversations,
+ activeConversation,
+ });
+};
+
+export default function PlaygroundAgentPage() {
+ const { activeConversation } = useTypedLoaderData
();
+ // Key on conversation chatId so React remounts all stateful children when
+ // navigating between conversations (Link changes search params, loader re-runs,
+ // but without a key change the component instance is reused and useState
+ // initializers / useRef initializations don't re-run).
+ const conversationKey = activeConversation?.chatId ?? "new";
+ return ;
+}
+
+function PlaygroundChat() {
+ const { agent, apiOrigin, recentConversations, activeConversation } =
+ useTypedLoaderData();
+ const navigate = useNavigate();
+ const organization = useOrganization();
+ const project = useProject();
+ const environment = useEnvironment();
+
+ const [conversationId, setConversationId] = useState(() =>
+ activeConversation
+ ? recentConversations.find((c) => c.chatId === activeConversation.chatId)?.id ?? null
+ : null
+ );
+ const [chatId, setChatId] = useState(() => activeConversation?.chatId ?? crypto.randomUUID());
+ const [clientDataJson, setClientDataJson] = useState(() =>
+ activeConversation?.clientData ? JSON.stringify(activeConversation.clientData, null, 2) : "{}"
+ );
+ const clientDataJsonRef = useRef(clientDataJson);
+ clientDataJsonRef.current = clientDataJson;
+ const [machine, setMachine] = useState(undefined);
+ const [tags, setTags] = useState("");
+
+ const actionPath = `/resources/orgs/${organization.slug}/projects/${project.slug}/env/${environment.slug}/playground/action`;
+
+ // Server-side trigger via Remix action (acts like a Next.js server action)
+ const triggerTask = useCallback(
+ async (params: TriggerChatTaskParams): Promise => {
+ const formData = new FormData();
+ formData.set("intent", "trigger");
+ formData.set("agentSlug", agent.slug);
+ formData.set("chatId", chatId);
+ formData.set("payload", JSON.stringify(params.payload));
+ formData.set("clientData", clientDataJsonRef.current);
+ if (tags.trim()) formData.set("tags", tags.trim());
+ if (machine) formData.set("machine", machine);
+
+ const response = await fetch(actionPath, { method: "POST", body: formData });
+ const data = (await response.json()) as {
+ runId?: string;
+ publicAccessToken?: string;
+ conversationId?: string;
+ error?: string;
+ };
+
+ if (!response.ok || !data.runId || !data.publicAccessToken) {
+ throw new Error(data.error ?? "Failed to trigger agent");
+ }
+
+ if (data.conversationId) {
+ setConversationId(data.conversationId);
+ }
+
+ return { runId: data.runId, publicAccessToken: data.publicAccessToken };
+ },
+ [actionPath, agent.slug, chatId, tags, machine]
+ );
+
+ // Token renewal via Remix action
+ const renewToken = useCallback(
+ async ({ runId }: { chatId: string; runId: string }): Promise => {
+ const formData = new FormData();
+ formData.set("intent", "renew");
+ formData.set("agentSlug", agent.slug);
+ formData.set("runId", runId);
+
+ const response = await fetch(actionPath, { method: "POST", body: formData });
+ const data = (await response.json()) as { publicAccessToken?: string };
+ return data.publicAccessToken;
+ },
+ [actionPath, agent.slug]
+ );
+
+ // Resource route prefix — all realtime traffic goes through session-authed routes
+ const playgroundBaseURL = `${apiOrigin}/resources/orgs/${organization.slug}/projects/${project.slug}/env/${environment.slug}/playground`;
+
+ // Create TriggerChatTransport directly (not via useTriggerChatTransport hook
+ // to avoid React version mismatch between SDK and webapp)
+ const transportRef = useRef(null);
+ if (transportRef.current === null) {
+ transportRef.current = new TriggerChatTransport({
+ task: agent.slug,
+ triggerTask,
+ renewRunAccessToken: renewToken,
+ baseURL: playgroundBaseURL,
+ clientData: JSON.parse(clientDataJson || "{}") as Record,
+ ...(activeConversation?.runFriendlyId && activeConversation?.publicAccessToken
+ ? {
+ sessions: {
+ [activeConversation.chatId]: {
+ runId: activeConversation.runFriendlyId,
+ publicAccessToken: activeConversation.publicAccessToken,
+ lastEventId: activeConversation.lastEventId ?? undefined,
+ },
+ },
+ }
+ : {}),
+ });
+ }
+ const transport = transportRef.current;
+
+ // Keep callbacks up to date
+ useEffect(() => {
+ transport.setTriggerTask(triggerTask);
+ }, [triggerTask, transport]);
+
+ useEffect(() => {
+ transport.setRenewRunAccessToken(renewToken);
+ }, [renewToken, transport]);
+
+ // Initial messages from persisted conversation (for resume)
+ const initialMessages = activeConversation?.messages
+ ? (activeConversation.messages as UIMessage[])
+ : [];
+
+ // Track the initial message count so we only save after genuinely new turns
+ // (not during resume replay which re-fires onFinish for replayed turns)
+ const initialMessageCountRef = useRef(initialMessages?.length ?? 0);
+
+ // Save messages after each turn completes
+ const saveMessages = useCallback(
+ (allMessages: UIMessage[]) => {
+ // Skip saves during resume replay — only save when we have more messages than we started with
+ if (allMessages.length <= initialMessageCountRef.current) return;
+
+ const currentSession = transport.getSession(chatId);
+ const lastEventId = currentSession?.lastEventId;
+
+ const formData = new FormData();
+ formData.set("intent", "save");
+ formData.set("agentSlug", agent.slug);
+ formData.set("chatId", chatId);
+ formData.set("messages", JSON.stringify(allMessages));
+ if (lastEventId) formData.set("lastEventId", lastEventId);
+
+ // Fire and forget
+ fetch(actionPath, { method: "POST", body: formData }).catch(() => {});
+
+ // Update the baseline so subsequent saves work correctly
+ initialMessageCountRef.current = allMessages.length;
+ },
+ [chatId, agent.slug, actionPath, transport]
+ );
+
+ // useChat from AI SDK — handles message accumulation, streaming, stop
+ const { messages, sendMessage, stop, status, error } = useChat({
+ id: chatId,
+ messages: initialMessages,
+ transport,
+ onFinish: ({ messages: allMessages }) => {
+ saveMessages(allMessages);
+ },
+ });
+
+ const isStreaming = status === "streaming";
+ const isSubmitted = status === "submitted";
+
+ // Pending messages — steering during streaming
+ const pending = usePlaygroundPendingMessages({
+ transport,
+ chatId,
+ status,
+ messages,
+ sendMessage,
+ metadata: safeParseJson(clientDataJson),
+ });
+
+ const [input, setInput] = useState("");
+ const [preloading, setPreloading] = useState(false);
+ const [preloaded, setPreloaded] = useState(false);
+ const inputRef = useRef(null);
+
+ const session = transport.getSession(chatId);
+
+ const handlePreload = useCallback(async () => {
+ setPreloading(true);
+ try {
+ await transport.preload(chatId, {
+ idleTimeoutInSeconds: 60,
+ metadata: safeParseJson(clientDataJsonRef.current),
+ });
+ setPreloaded(true);
+ inputRef.current?.focus();
+ } finally {
+ setPreloading(false);
+ }
+ }, [transport, chatId]);
+
+ const handleNewConversation = useCallback(() => {
+ // Navigate without ?conversation= so the loader returns activeConversation=null
+ // and the key changes to "new", causing a full remount with fresh state.
+ navigate(window.location.pathname);
+ }, [navigate]);
+
+ const handleDeleteConversation = useCallback(async () => {
+ if (!conversationId) return;
+
+ const formData = new FormData();
+ formData.set("intent", "delete");
+ formData.set("agentSlug", agent.slug);
+ formData.set("deleteConversationId", conversationId);
+
+ await fetch(actionPath, { method: "POST", body: formData });
+ handleNewConversation();
+ }, [conversationId, agent.slug, actionPath, handleNewConversation]);
+
+ const handleSend = useCallback(() => {
+ const trimmed = input.trim();
+ if (!trimmed) return;
+
+ setInput("");
+ // steer() handles both cases: sends via input stream during streaming,
+ // or sends as a normal message when ready
+ pending.steer(trimmed);
+ }, [input, pending]);
+
+ const handleKeyDown = useCallback(
+ (e: React.KeyboardEvent) => {
+ if (e.key === "Enter" && !e.shiftKey) {
+ e.preventDefault();
+ handleSend();
+ }
+ },
+ [handleSend]
+ );
+
+ return (
+
+
+
+ {/* Header */}
+
+
+
+ {agent.slug}
+ {formatAgentType(agent.type)}
+
+
+ {session?.runId && (
+
+ View run
+
+ )}
+ {messages.length > 0 && (
+
+ Copy raw
+
+ )}
+
+ {conversationId && (
+
+ )}
+
+
+
+
+ {/* Messages */}
+
+ {messages.length === 0 ? (
+
+
+ {preloaded ? (
+ <>
+
+
Preloaded
+
+ Agent is warmed up and waiting. Type a message below to start.
+
+ >
+ ) : (
+ <>
+
+
Start a conversation
+
+ Type a message below to start testing{" "}
+ {agent.slug}
+
+ {!session?.runId && (
+
+ )}
+ >
+ )}
+
+
+ ) : (
+
+ {messages.map((msg) => (
+
+ ))}
+ {isSubmitted && (
+
+ )}
+
+ )}
+
+
+ {/* Error */}
+ {error && (
+
+ {error.message}
+
+ )}
+
+ {/* Input */}
+
+
+ {/* Pending messages overlay */}
+ {pending.pending.length > 0 && (
+
+ {pending.pending.map((msg) => (
+
+
+ {msg.mode === "steering" ? "Steering" : "Queued"}
+
+ {msg.text}
+ {msg.injected && Injected}
+
+ ))}
+
+ )}
+
+
+ {isStreaming
+ ? "Send a steering message to guide the agent between tool calls"
+ : "Press Enter to send, Shift+Enter for new line"}
+
+
+
+
+
+
+
+
+ );
+}
+
+function formatAgentType(type: string): string {
+ switch (type) {
+ case "ai-sdk-chat":
+ return "AI SDK Chat";
+ default:
+ return type;
+ }
+}
+
+// ---------------------------------------------------------------------------
+// Message rendering
+// ---------------------------------------------------------------------------
+
+// UIMessage part types (AI SDK):
+// text — markdown text content
+// reasoning — model reasoning/thinking
+// tool-{name} — tool call with input/output/state
+// source-url — citation link
+// source-document — citation document reference
+// file — file attachment (image, etc.)
+// step-start — visual separator between steps (skip)
+// data-{name} — custom data parts (skip)
+
+function MessageBubble({ message }: { message: UIMessage }) {
+ if (message.role === "user") {
+ const text =
+ message.parts
+ ?.filter((p) => p.type === "text")
+ .map((p) => (p as { type: "text"; text: string }).text)
+ .join("") ?? "";
+
+ return (
+
+ );
+ }
+
+ if (message.role === "assistant") {
+ const hasContent = message.parts && message.parts.length > 0;
+ if (!hasContent) return null;
+
+ return (
+
+
+ {message.parts?.map((part, i) => renderPart(part, i))}
+
+
+ );
+ }
+
+ return null;
+}
+
+function renderPart(part: UIMessage["parts"][number], i: number) {
+ const p = part as any;
+ const type = part.type as string;
+
+ // Text — markdown rendered via AssistantResponse
+ if (type === "text") {
+ return p.text ? : null;
+ }
+
+ // Reasoning — amber-bordered italic block
+ if (type === "reasoning") {
+ return (
+
+ );
+ }
+
+ // Tool call — type: "tool-{name}" with toolCallId, input, output, state
+ if (type.startsWith("tool-")) {
+ const toolName = type.slice(5);
+ return (
+
+ );
+ }
+
+ // Source URL — clickable citation link
+ if (type === "source-url") {
+ return (
+
+ );
+ }
+
+ // Source document — citation label
+ if (type === "source-document") {
+ return (
+
+ 📄 {p.title}
+ {p.mediaType ? ` (${p.mediaType})` : ""}
+
+ );
+ }
+
+ // File — render as image if image type, otherwise as download link
+ if (type === "file") {
+ const isImage = typeof p.mediaType === "string" && p.mediaType.startsWith("image/");
+ if (isImage) {
+ return (
+
+ );
+ }
+ return (
+
+ );
+ }
+
+ // Step start — subtle dashed separator with centered label
+ if (type === "step-start") {
+ return (
+
+ );
+ }
+
+ // Data parts — type: "data-{name}", show as labeled JSON popover
+ if (type.startsWith("data-")) {
+ const dataName = type.slice(5);
+ return ;
+ }
+
+ return null;
+}
+
+function DataPartPopover({ name, data }: { name: string; data: unknown }) {
+ const formatted = JSON.stringify(data, null, 2);
+
+ return (
+
+
+
+
+
+
+ data-{name}
+
+
+
+
+ );
+}
+
+// ---------------------------------------------------------------------------
+// Sidebar
+// ---------------------------------------------------------------------------
+
+const machinePresets = [
+ "micro",
+ "small-1x",
+ "small-2x",
+ "medium-1x",
+ "medium-2x",
+ "large-1x",
+ "large-2x",
+];
+
+function PlaygroundSidebar({
+ clientDataJson,
+ onClientDataChange,
+ getCurrentClientData,
+ clientDataSchema,
+ agentSlug,
+ machine,
+ onMachineChange,
+ tags,
+ onTagsChange,
+ session,
+ messageCount,
+ isStreaming,
+ status,
+}: {
+ clientDataJson: string;
+ onClientDataChange: (val: string) => void;
+ getCurrentClientData: () => string;
+ clientDataSchema: unknown;
+ agentSlug: string;
+ machine: string | undefined;
+ onMachineChange: (val: string | undefined) => void;
+ tags: string;
+ onTagsChange: (val: string) => void;
+ session: { runId: string; publicAccessToken: string; lastEventId?: string } | undefined;
+ messageCount: number;
+ isStreaming: boolean;
+ status: string;
+}) {
+ return (
+
+
+
+
+
+ Client Data
+
+
+ Options
+
+
+ Session
+
+
+
+
+ {/* Client Data tab */}
+
+
+
+
+ Custom metadata sent with each conversation turn.
+
+
+
+
+
+
+
+
+ {clientDataSchema != null && (
+
+ )}
+
+
+
+ {/* Options tab */}
+
+
+
+
+
+
Machine preset for the agent run.
+
+
+
+
onTagsChange(e.target.value)}
+ placeholder="tag1, tag2"
+ className="w-full rounded border border-charcoal-650 bg-charcoal-850 px-2.5 py-1.5 text-xs text-text-bright placeholder-text-dimmed focus:border-indigo-500 focus:outline-none"
+ />
+
+ Comma-separated tags (max 5 total).
+
+
+
+
+
+ {/* Session tab */}
+
+
+ {session?.runId ? (
+ <>
+
+
+
+
+
+
+ {status}
+
+
+ >
+ ) : (
+
+ No active session. Send a message to start a conversation.
+
+ )}
+
+
+
+
+ );
+}
+
+// ---------------------------------------------------------------------------
+// Pending messages hook (reimplemented to avoid React version mismatch)
+// ---------------------------------------------------------------------------
+
+const PENDING_MESSAGE_INJECTED_TYPE = "data-pending-message-injected";
+
+type PendingMessageEntry = {
+ id: string;
+ text: string;
+ mode: "steering" | "queued";
+ injected: boolean;
+};
+
+function usePlaygroundPendingMessages({
+ transport,
+ chatId,
+ status,
+ messages,
+ sendMessage,
+ metadata,
+}: {
+ transport: TriggerChatTransport;
+ chatId: string;
+ status: string;
+ messages: UIMessage[];
+ sendMessage: (msg: { text: string }, opts?: { metadata?: Record }) => void;
+ metadata?: Record;
+}) {
+ type InternalMsg = {
+ id: string;
+ role: "user";
+ parts: { type: "text"; text: string }[];
+ _mode: "steering" | "queued";
+ };
+ const [pendingMsgs, setPendingMsgs] = useState([]);
+ const injectedIdsRef = useRef>(new Set());
+ const prevStatusRef = useRef(status);
+
+ // Watch for injection confirmation chunks
+ useEffect(() => {
+ if (status !== "streaming") return;
+ let newlyInjected = false;
+ for (const msg of messages) {
+ if (msg.role !== "assistant") continue;
+ for (const part of msg.parts ?? []) {
+ if ((part as any).type === PENDING_MESSAGE_INJECTED_TYPE) {
+ const messageIds = (part as any).data?.messageIds as string[] | undefined;
+ if (Array.isArray(messageIds)) {
+ for (const id of messageIds) {
+ if (!injectedIdsRef.current.has(id)) {
+ injectedIdsRef.current.add(id);
+ newlyInjected = true;
+ }
+ }
+ }
+ }
+ }
+ }
+ if (newlyInjected) {
+ setPendingMsgs((prev) => prev.filter((m) => !injectedIdsRef.current.has(m.id)));
+ }
+ }, [status, messages]);
+
+ // Handle turn completion — auto-send non-injected messages as next turn
+ useEffect(() => {
+ const turnCompleted = prevStatusRef.current === "streaming" && status === "ready";
+ prevStatusRef.current = status;
+ if (!turnCompleted) return;
+
+ const toSend = pendingMsgs.filter((m) => !injectedIdsRef.current.has(m.id));
+ setPendingMsgs([]);
+ injectedIdsRef.current.clear();
+
+ if (toSend.length > 0) {
+ const text = toSend.map((m) => m.parts[0]?.text ?? "").join("\n");
+ sendMessage({ text }, metadata ? { metadata } : undefined);
+ }
+ }, [status, pendingMsgs, sendMessage, metadata, messages]);
+
+ const steer = useCallback(
+ (text: string) => {
+ if (status === "streaming") {
+ const msg: InternalMsg = {
+ id: crypto.randomUUID(),
+ role: "user",
+ parts: [{ type: "text", text }],
+ _mode: "steering",
+ };
+ transport.sendPendingMessage(chatId, msg as any, metadata);
+ setPendingMsgs((prev) => [...prev, msg]);
+ } else {
+ sendMessage({ text }, metadata ? { metadata } : undefined);
+ }
+ },
+ [status, transport, chatId, sendMessage, metadata]
+ );
+
+ const pending: PendingMessageEntry[] = pendingMsgs.map((m) => ({
+ id: m.id,
+ text: m.parts[0]?.text ?? "",
+ mode: m._mode,
+ injected: injectedIdsRef.current.has(m.id),
+ }));
+
+ return { pending, steer };
+}
+
+function RecentConversationsPopover({
+ conversations,
+ actionPath,
+}: {
+ conversations: PlaygroundConversation[];
+ actionPath: string;
+}) {
+ const fetcher = useFetcher();
+ const [isOpen, setIsOpen] = useState(false);
+
+ const deletingId =
+ fetcher.state !== "idle" ? (fetcher.formData?.get("deleteConversationId") as string) : null;
+
+ const handleDelete = useCallback(
+ (e: React.MouseEvent, conv: PlaygroundConversation) => {
+ e.preventDefault();
+ e.stopPropagation();
+
+ fetcher.submit(
+ {
+ intent: "delete",
+ agentSlug: conv.agentSlug,
+ deleteConversationId: conv.id,
+ },
+ { method: "POST", action: actionPath }
+ );
+ setIsOpen(false);
+ },
+ [actionPath, fetcher]
+ );
+
+ return (
+
+
+
+
+
+
+
+ {conversations.map((conv) => (
+
+
setIsOpen(false)}
+ className="flex min-w-0 flex-1 flex-col items-start gap-0.5 outline-none focus-custom"
+ >
+
+ {conv.title}
+
+
+
+
+
+
+
+ ))}
+ {conversations.length === 0 && (
+
+ No recent conversations
+
+ )}
+
+
+
+
+ );
+}
+
+function safeParseJson(json: string): Record {
+ try {
+ return JSON.parse(json || "{}") as Record;
+ } catch {
+ return {};
+ }
+}
+
+function SessionField({ label, value }: { label: string; value: string }) {
+ return (
+
+
+ {value}
+
+ );
+}
diff --git a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.playground/route.tsx b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.playground/route.tsx
new file mode 100644
index 000000000..9079b81a1
--- /dev/null
+++ b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.playground/route.tsx
@@ -0,0 +1,133 @@
+import { CpuChipIcon } from "@heroicons/react/20/solid";
+import { json, type MetaFunction } from "@remix-run/node";
+import { Outlet, useNavigate, useParams, useLoaderData } from "@remix-run/react";
+import { type LoaderFunctionArgs } from "@remix-run/server-runtime";
+import { MainCenteredContainer, PageBody, PageContainer } from "~/components/layout/AppLayout";
+import { Header2 } from "~/components/primitives/Headers";
+import { NavBar, PageAccessories, PageTitle } from "~/components/primitives/PageHeader";
+import { Paragraph } from "~/components/primitives/Paragraph";
+import {
+ Select,
+ SelectItem,
+} from "~/components/primitives/Select";
+import { useEnvironment } from "~/hooks/useEnvironment";
+import { useOrganization } from "~/hooks/useOrganizations";
+import { useProject } from "~/hooks/useProject";
+import { findProjectBySlug } from "~/models/project.server";
+import { findEnvironmentBySlug } from "~/models/runtimeEnvironment.server";
+import {
+ type PlaygroundAgent,
+ playgroundPresenter,
+} from "~/presenters/v3/PlaygroundPresenter.server";
+import { requireUserId } from "~/services/session.server";
+import { EnvironmentParamSchema, v3PlaygroundAgentPath } from "~/utils/pathBuilder";
+
+export const meta: MetaFunction = () => {
+ return [{ title: "Playground | Trigger.dev" }];
+};
+
+export const loader = async ({ request, params }: LoaderFunctionArgs) => {
+ const userId = await requireUserId(request);
+ const { organizationSlug, projectParam, envParam } = EnvironmentParamSchema.parse(params);
+
+ const project = await findProjectBySlug(organizationSlug, projectParam, userId);
+ if (!project) {
+ throw new Response(undefined, { status: 404, statusText: "Project not found" });
+ }
+
+ const environment = await findEnvironmentBySlug(project.id, envParam, userId);
+ if (!environment) {
+ throw new Response(undefined, { status: 404, statusText: "Environment not found" });
+ }
+
+ const agents = await playgroundPresenter.listAgents({
+ environmentId: environment.id,
+ environmentType: environment.type,
+ });
+
+ return json({ agents });
+};
+
+export default function PlaygroundPage() {
+ const { agents } = useLoaderData();
+ const organization = useOrganization();
+ const project = useProject();
+ const environment = useEnvironment();
+ const navigate = useNavigate();
+ const params = useParams();
+ const selectedAgent = params.agentParam ?? "";
+
+ if (agents.length === 0) {
+ return (
+
+
+
+
+
+
+
+
+
No agents deployed
+
+ Create a chat agent using chat.agent() from{" "}
+ @trigger.dev/sdk/ai and deploy it to see it here.
+
+
+
+
+
+ );
+ }
+
+ return (
+
+
+
+
+
+
+
+
+ {selectedAgent ? (
+
+ ) : (
+
+
+
+
Select an agent
+
+ Choose an agent from the dropdown to start a conversation.
+
+
+
+ )}
+
+
+ );
+}
diff --git a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.test.tasks.$taskParam/AIPayloadTabContent.tsx b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.test.tasks.$taskParam/AIPayloadTabContent.tsx
index 3d9302356..915b37780 100644
--- a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.test.tasks.$taskParam/AIPayloadTabContent.tsx
+++ b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.test.tasks.$taskParam/AIPayloadTabContent.tsx
@@ -31,11 +31,17 @@ export function AIPayloadTabContent({
payloadSchema,
taskIdentifier,
getCurrentPayload,
+ generateButtonLabel = "Generate payload",
+ placeholder,
+ examplePromptsOverride,
}: {
onPayloadGenerated: (payload: string) => void;
payloadSchema?: unknown;
taskIdentifier: string;
getCurrentPayload?: () => string;
+ generateButtonLabel?: string;
+ placeholder?: string;
+ examplePromptsOverride?: string[];
}) {
const [prompt, setPrompt] = useState("");
const [isLoading, setIsLoading] = useState(false);
@@ -191,7 +197,7 @@ export function AIPayloadTabContent({
}
}, [error]);
- const examplePrompts = payloadSchema
+ const examplePrompts = examplePromptsOverride ?? (payloadSchema
? [
"Generate a valid payload",
"Generate a payload with edge cases",
@@ -201,7 +207,7 @@ export function AIPayloadTabContent({
"Generate a simple JSON payload",
"Generate a payload with nested objects",
"Generate a payload with an array of items",
- ];
+ ]);
return (
@@ -215,9 +221,9 @@ export function AIPayloadTabContent({
ref={textareaRef}
name="prompt"
placeholder={
- payloadSchema
+ placeholder ?? (payloadSchema
? "e.g. generate a payload for a new user signup"
- : "e.g. generate a JSON payload with name, email, and age fields"
+ : "e.g. generate a JSON payload with name, email, and age fields")
}
value={prompt}
onChange={(e) => setPrompt(e.target.value)}
@@ -251,7 +257,7 @@ export function AIPayloadTabContent({
className={cn(!prompt.trim() && "opacity-50")}
onClick={() => handleSubmit()}
>
- Generate payload
+ {generateButtonLabel}
)}
diff --git a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.test.tasks.$taskParam/SchemaTabContent.tsx b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.test.tasks.$taskParam/SchemaTabContent.tsx
index b7a43a750..a5e6a3907 100644
--- a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.test.tasks.$taskParam/SchemaTabContent.tsx
+++ b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.test.tasks.$taskParam/SchemaTabContent.tsx
@@ -9,18 +9,34 @@ import { docsPath } from "~/utils/pathBuilder";
export function SchemaTabContent({
schema,
inferredSchema,
+ title = "Payload schema",
+ description,
+ showDocsLink = true,
}: {
schema?: unknown;
inferredSchema?: unknown;
+ title?: string;
+ description?: string;
+ showDocsLink?: boolean;
}) {
if (schema) {
return (
-
Payload schema
-
- JSON Schema defined by this task via{" "}
- schemaTask.
-
+
{title}
+ {showDocsLink ? (
+
+ {description ?? (
+ <>
+ JSON Schema defined by this task via{" "}
+ schemaTask.
+ >
+ )}
+
+ ) : description ? (
+
+ {description}
+
+ ) : null}
{
+ const userId = await requireUserId(request);
+ const { organizationSlug, projectParam, envParam } = EnvironmentParamSchema.parse(params);
+
+ const project = await findProjectBySlug(organizationSlug, projectParam, userId);
+ if (!project) {
+ return json({ error: "Project not found" }, { status: 404 });
+ }
+
+ const environment = await findEnvironmentBySlug(project.id, envParam, userId);
+ if (!environment) {
+ return json({ error: "Environment not found" }, { status: 404 });
+ }
+
+ const formData = await request.formData();
+ const parsed = PlaygroundAction.safeParse(Object.fromEntries(formData));
+ if (!parsed.success) {
+ return json({ error: "Invalid request", details: parsed.error.issues }, { status: 400 });
+ }
+
+ const { intent } = parsed.data;
+
+ switch (intent) {
+ case "create": {
+ const { agentSlug } = parsed.data;
+ const chatId = crypto.randomUUID();
+
+ const conversation = await prisma.playgroundConversation.create({
+ data: {
+ chatId,
+ agentSlug,
+ projectId: project.id,
+ runtimeEnvironmentId: environment.id,
+ userId,
+ },
+ });
+
+ return json({
+ conversationId: conversation.id,
+ chatId,
+ });
+ }
+
+ case "trigger": {
+ const { agentSlug, chatId, payload: payloadStr, clientData, tags: tagsStr, machine } = parsed.data;
+
+ if (!payloadStr || !chatId) {
+ return json({ error: "payload and chatId are required" }, { status: 400 });
+ }
+
+ const payload = JSON.parse(payloadStr) as Record;
+
+ const triggerService = new TriggerTaskService();
+ const result = await triggerService.call(
+ agentSlug,
+ environment,
+ {
+ payload,
+ options: {
+ payloadType: "application/json",
+ test: true,
+ tags: [
+ `chat:${chatId}`,
+ "playground:true",
+ ...(tagsStr ? tagsStr.split(",").map((t) => t.trim()).filter(Boolean) : []),
+ ].slice(0, 5),
+ machine: machine as any,
+ },
+ },
+ { triggerSource: "dashboard", triggerAction: "test", realtimeStreamsVersion: "v2" }
+ );
+
+ if (!result?.run) {
+ return json({ error: "Failed to trigger agent" }, { status: 500 });
+ }
+
+ // Create or update the playground conversation
+ let parsedClientData: unknown;
+ try {
+ parsedClientData = clientData ? JSON.parse(clientData) : undefined;
+ } catch {
+ // Client data JSON was invalid — proceed without it
+ }
+
+ // Extract first message text for title
+ const firstMessage = payload?.messages?.[0];
+ const firstText =
+ firstMessage?.parts?.find((p: any) => p.type === "text")?.text ?? "New conversation";
+ const title = firstText.length > 60 ? firstText.slice(0, 60) + "..." : firstText;
+
+ const conversation = await prisma.playgroundConversation.upsert({
+ where: {
+ chatId_runtimeEnvironmentId: {
+ chatId,
+ runtimeEnvironmentId: environment.id,
+ },
+ },
+ create: {
+ chatId,
+ title,
+ agentSlug,
+ runId: result.run.id,
+ clientData: parsedClientData as any,
+ projectId: project.id,
+ runtimeEnvironmentId: environment.id,
+ userId,
+ },
+ update: {
+ runId: result.run.id,
+ clientData: parsedClientData as any,
+ title,
+ },
+ });
+
+ const jwt = await mintRunToken(environment, result.run.friendlyId);
+
+ return json({
+ runId: result.run.friendlyId,
+ publicAccessToken: jwt,
+ conversationId: conversation.id,
+ });
+ }
+
+ case "renew": {
+ const { runId } = parsed.data;
+ if (!runId) {
+ return json({ error: "runId is required" }, { status: 400 });
+ }
+
+ const jwt = await mintRunToken(environment, runId);
+ return json({ publicAccessToken: jwt });
+ }
+
+ case "save": {
+ const { chatId, messages: messagesStr, lastEventId } = parsed.data;
+ if (!chatId) {
+ return json({ error: "chatId is required" }, { status: 400 });
+ }
+
+ const messagesData = messagesStr ? JSON.parse(messagesStr) : undefined;
+
+ await prisma.playgroundConversation.updateMany({
+ where: {
+ chatId,
+ runtimeEnvironmentId: environment.id,
+ },
+ data: {
+ ...(messagesData ? { messages: messagesData as any } : {}),
+ ...(lastEventId ? { lastEventId } : {}),
+ },
+ });
+
+ return json({ ok: true });
+ }
+
+ case "delete": {
+ const { deleteConversationId } = parsed.data;
+ if (!deleteConversationId) {
+ return json({ error: "deleteConversationId is required" }, { status: 400 });
+ }
+
+ await prisma.playgroundConversation.deleteMany({
+ where: {
+ id: deleteConversationId,
+ runtimeEnvironmentId: environment.id,
+ userId,
+ },
+ });
+
+ return json({ ok: true });
+ }
+ }
+};
+
+async function mintRunToken(
+ environment: Parameters[0],
+ runFriendlyId: string
+): Promise {
+ return internal_generateJWT({
+ secretKey: extractJwtSigningSecretKey(environment),
+ payload: {
+ sub: environment.id,
+ pub: true,
+ scopes: [
+ `read:runs:${runFriendlyId}`,
+ `write:inputStreams:${runFriendlyId}`,
+ ],
+ },
+ expirationTime: "1h",
+ });
+}
diff --git a/apps/webapp/app/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.playground.realtime.v1.streams.$runId.$streamId.ts b/apps/webapp/app/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.playground.realtime.v1.streams.$runId.$streamId.ts
new file mode 100644
index 000000000..466d0bcbe
--- /dev/null
+++ b/apps/webapp/app/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.playground.realtime.v1.streams.$runId.$streamId.ts
@@ -0,0 +1,61 @@
+import { type LoaderFunctionArgs } from "@remix-run/server-runtime";
+import { z } from "zod";
+import { $replica } from "~/db.server";
+import { getRealtimeStreamInstance } from "~/services/realtime/v1StreamsGlobal.server";
+import { findProjectBySlug } from "~/models/project.server";
+import { findEnvironmentBySlug } from "~/models/runtimeEnvironment.server";
+import { requireUserId } from "~/services/session.server";
+import { EnvironmentParamSchema } from "~/utils/pathBuilder";
+
+const ParamsSchema = z.object({
+ runId: z.string(),
+ streamId: z.string(),
+});
+
+// GET: SSE stream subscription — authenticated via session cookie
+export async function loader({ request, params }: LoaderFunctionArgs) {
+ const userId = await requireUserId(request);
+ const { organizationSlug, projectParam, envParam } = EnvironmentParamSchema.parse(params);
+ const { runId, streamId } = ParamsSchema.parse(params);
+
+ const project = await findProjectBySlug(organizationSlug, projectParam, userId);
+ if (!project) {
+ return new Response("Project not found", { status: 404 });
+ }
+
+ const environment = await findEnvironmentBySlug(project.id, envParam, userId);
+ if (!environment) {
+ return new Response("Environment not found", { status: 404 });
+ }
+
+ const run = await $replica.taskRun.findFirst({
+ where: {
+ friendlyId: runId,
+ runtimeEnvironmentId: environment.id,
+ },
+ select: {
+ id: true,
+ friendlyId: true,
+ realtimeStreamsVersion: true,
+ },
+ });
+
+ if (!run) {
+ return new Response("Run not found", { status: 404 });
+ }
+
+ const lastEventId = request.headers.get("Last-Event-ID") || undefined;
+ const timeoutInSecondsRaw = request.headers.get("Timeout-Seconds") ?? undefined;
+ const timeoutInSeconds = timeoutInSecondsRaw ? parseInt(timeoutInSecondsRaw) : undefined;
+
+ if (timeoutInSeconds && (isNaN(timeoutInSeconds) || timeoutInSeconds < 1 || timeoutInSeconds > 600)) {
+ return new Response("Invalid timeout", { status: 400 });
+ }
+
+ const realtimeStream = getRealtimeStreamInstance(environment, run.realtimeStreamsVersion);
+
+ return realtimeStream.streamResponse(request, run.friendlyId, streamId, request.signal, {
+ lastEventId,
+ timeoutInSeconds,
+ });
+}
diff --git a/apps/webapp/app/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.playground.realtime.v1.streams.$runId.input.$streamId.ts b/apps/webapp/app/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.playground.realtime.v1.streams.$runId.input.$streamId.ts
new file mode 100644
index 000000000..b339138ba
--- /dev/null
+++ b/apps/webapp/app/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.playground.realtime.v1.streams.$runId.input.$streamId.ts
@@ -0,0 +1,96 @@
+import { json, type ActionFunctionArgs } from "@remix-run/server-runtime";
+import { z } from "zod";
+import { $replica } from "~/db.server";
+import {
+ getInputStreamWaitpoint,
+ deleteInputStreamWaitpoint,
+} from "~/services/inputStreamWaitpointCache.server";
+import { getRealtimeStreamInstance } from "~/services/realtime/v1StreamsGlobal.server";
+import { engine } from "~/v3/runEngine.server";
+import { findProjectBySlug } from "~/models/project.server";
+import { findEnvironmentBySlug } from "~/models/runtimeEnvironment.server";
+import { requireUserId } from "~/services/session.server";
+import { EnvironmentParamSchema } from "~/utils/pathBuilder";
+
+const ParamsSchema = z.object({
+ runId: z.string(),
+ streamId: z.string(),
+});
+
+const BodySchema = z.object({
+ data: z.unknown(),
+});
+
+// POST: Send data to an input stream — authenticated via session cookie
+export async function action({ request, params }: ActionFunctionArgs) {
+ const userId = await requireUserId(request);
+ const { organizationSlug, projectParam, envParam } = EnvironmentParamSchema.parse(params);
+ const { runId, streamId } = ParamsSchema.parse(params);
+
+ const project = await findProjectBySlug(organizationSlug, projectParam, userId);
+ if (!project) {
+ return json({ ok: false, error: "Project not found" }, { status: 404 });
+ }
+
+ const environment = await findEnvironmentBySlug(project.id, envParam, userId);
+ if (!environment) {
+ return json({ ok: false, error: "Environment not found" }, { status: 404 });
+ }
+
+ const run = await $replica.taskRun.findFirst({
+ where: {
+ friendlyId: runId,
+ runtimeEnvironmentId: environment.id,
+ },
+ select: {
+ id: true,
+ friendlyId: true,
+ completedAt: true,
+ realtimeStreamsVersion: true,
+ },
+ });
+
+ if (!run) {
+ return json({ ok: false, error: "Run not found" }, { status: 404 });
+ }
+
+ if (run.completedAt) {
+ return json(
+ { ok: false, error: "Cannot send to input stream on a completed run" },
+ { status: 400 }
+ );
+ }
+
+ const body = BodySchema.safeParse(await request.json());
+ if (!body.success) {
+ return json({ ok: false, error: "Invalid request body" }, { status: 400 });
+ }
+
+ const realtimeStream = getRealtimeStreamInstance(environment, run.realtimeStreamsVersion);
+
+ const recordId = `inp_${crypto.randomUUID().replace(/-/g, "").slice(0, 12)}`;
+ const record = JSON.stringify(body.data.data);
+
+ await realtimeStream.appendPart(
+ record,
+ recordId,
+ run.friendlyId,
+ `$trigger.input:${streamId}`
+ );
+
+ // Complete any linked waitpoint
+ const waitpointId = await getInputStreamWaitpoint(runId, streamId);
+ if (waitpointId) {
+ await engine.completeWaitpoint({
+ id: waitpointId,
+ output: {
+ value: JSON.stringify(body.data.data),
+ type: "application/json",
+ isError: false,
+ },
+ });
+ await deleteInputStreamWaitpoint(runId, streamId);
+ }
+
+ return json({ ok: true });
+}
diff --git a/apps/webapp/app/routes/runs.$runParam.ts b/apps/webapp/app/routes/runs.$runParam.ts
index 4a8d7a12d..b472d7ae8 100644
--- a/apps/webapp/app/routes/runs.$runParam.ts
+++ b/apps/webapp/app/routes/runs.$runParam.ts
@@ -28,6 +28,7 @@ export async function loader({ params, request }: LoaderFunctionArgs) {
},
},
select: {
+ spanId: true,
runtimeEnvironment: {
select: {
slug: true,
@@ -57,11 +58,20 @@ export async function loader({ params, request }: LoaderFunctionArgs) {
);
}
+ // Preserve existing search params from the request, add span if not already set
+ const url = new URL(request.url);
+ const searchParams = url.searchParams;
+
+ if (!searchParams.has("span") && run.spanId) {
+ searchParams.set("span", run.spanId);
+ }
+
const path = v3RunPath(
{ slug: run.project.organization.slug },
{ slug: run.project.slug },
{ slug: run.runtimeEnvironment.slug },
- { friendlyId: runParam }
+ { friendlyId: runParam },
+ searchParams
);
return redirect(path);
diff --git a/apps/webapp/app/runEngine/concerns/queues.server.ts b/apps/webapp/app/runEngine/concerns/queues.server.ts
index 136c3da3b..a51ec6243 100644
--- a/apps/webapp/app/runEngine/concerns/queues.server.ts
+++ b/apps/webapp/app/runEngine/concerns/queues.server.ts
@@ -79,6 +79,7 @@ export class DefaultQueueManager implements QueueManager {
let queueName: string;
let lockedQueueId: string | undefined;
let taskTtl: string | null | undefined;
+ let taskKind: string | undefined;
// Determine queue name based on lockToVersion and provided options
if (lockedBackgroundWorker) {
@@ -158,6 +159,7 @@ export class DefaultQueueManager implements QueueManager {
// Use the task's default queue name
queueName = lockedTask.queue.name;
lockedQueueId = lockedTask.queue.id;
+ taskKind = lockedTask.triggerSource;
}
} else {
// Task is not locked to a specific version, use regular logic
@@ -172,6 +174,7 @@ export class DefaultQueueManager implements QueueManager {
const taskInfo = await this.getTaskQueueInfo(request);
queueName = taskInfo.queueName;
taskTtl = taskInfo.taskTtl;
+ taskKind = taskInfo.taskKind;
}
// Sanitize the final determined queue name once
@@ -188,12 +191,13 @@ export class DefaultQueueManager implements QueueManager {
queueName,
lockedQueueId,
taskTtl,
+ taskKind,
};
}
private async getTaskQueueInfo(
request: TriggerTaskRequest
- ): Promise<{ queueName: string; taskTtl?: string | null }> {
+ ): Promise<{ queueName: string; taskTtl?: string | null; taskKind?: string | undefined }> {
const { taskId, environment, body } = request;
const { queue } = body.options ?? {};
@@ -228,10 +232,10 @@ export class DefaultQueueManager implements QueueManager {
runtimeEnvironmentId: environment.id,
slug: taskId,
},
- select: { ttl: true },
+ select: { ttl: true, triggerSource: true },
});
- return { queueName: overriddenQueueName, taskTtl: task?.ttl };
+ return { queueName: overriddenQueueName, taskTtl: task?.ttl, taskKind: task?.triggerSource };
}
const task = await this.replicaPrisma.backgroundWorkerTask.findFirst({
@@ -261,10 +265,10 @@ export class DefaultQueueManager implements QueueManager {
queueConfig: task.queueConfig,
});
- return { queueName: defaultQueueName, taskTtl: task.ttl };
+ return { queueName: defaultQueueName, taskTtl: task.ttl, taskKind: task.triggerSource };
}
- return { queueName: task.queue.name ?? defaultQueueName, taskTtl: task.ttl };
+ return { queueName: task.queue.name ?? defaultQueueName, taskTtl: task.ttl, taskKind: task.triggerSource };
}
async validateQueueLimits(
diff --git a/apps/webapp/app/runEngine/services/triggerTask.server.ts b/apps/webapp/app/runEngine/services/triggerTask.server.ts
index 445e0eb15..bbfdc3956 100644
--- a/apps/webapp/app/runEngine/services/triggerTask.server.ts
+++ b/apps/webapp/app/runEngine/services/triggerTask.server.ts
@@ -185,7 +185,7 @@ export class RunEngineTriggerTaskService {
if (debounceDelayError || !debounceDelayUntil) {
throw new ServiceValidationError(
`Invalid debounce delay: ${body.options.debounce.delay}. ` +
- `Supported formats: {number}s, {number}m, {number}h, {number}d, {number}w`
+ `Supported formats: {number}s, {number}m, {number}h, {number}d, {number}w`
);
}
}
@@ -193,11 +193,11 @@ export class RunEngineTriggerTaskService {
// Get parent run if specified
const parentRun = body.options?.parentRunId
? await this.prisma.taskRun.findFirst({
- where: {
- id: RunId.fromFriendlyId(body.options.parentRunId),
- runtimeEnvironmentId: environment.id,
- },
- })
+ where: {
+ id: RunId.fromFriendlyId(body.options.parentRunId),
+ runtimeEnvironmentId: environment.id,
+ },
+ })
: undefined;
// Validate parent run
@@ -231,21 +231,21 @@ export class RunEngineTriggerTaskService {
const lockedToBackgroundWorker = body.options?.lockToVersion
? await this.prisma.backgroundWorker.findFirst({
- where: {
- projectId: environment.projectId,
- runtimeEnvironmentId: environment.id,
- version: body.options?.lockToVersion,
- },
- select: {
- id: true,
- version: true,
- sdkVersion: true,
- cliVersion: true,
- },
- })
+ where: {
+ projectId: environment.projectId,
+ runtimeEnvironmentId: environment.id,
+ version: body.options?.lockToVersion,
+ },
+ select: {
+ id: true,
+ version: true,
+ sdkVersion: true,
+ cliVersion: true,
+ },
+ })
: undefined;
- const { queueName, lockedQueueId, taskTtl } =
+ const { queueName, lockedQueueId, taskTtl, taskKind } =
await this.queueConcern.resolveQueueProperties(
triggerRequest,
lockedToBackgroundWorker ?? undefined
@@ -281,10 +281,10 @@ export class RunEngineTriggerTaskService {
const metadataPacket = body.options?.metadata
? handleMetadataPacket(
- body.options?.metadata,
- body.options?.metadataType ?? "application/json",
- this.metadataMaximumSize
- )
+ body.options?.metadata,
+ body.options?.metadataType ?? "application/json",
+ this.metadataMaximumSize
+ )
: undefined;
const tags = (
@@ -313,6 +313,7 @@ export class RunEngineTriggerTaskService {
triggerAction,
rootTriggerSource: parentAnnotations?.rootTriggerSource ?? triggerSource,
rootScheduleId: parentAnnotations?.rootScheduleId || options.scheduleId || undefined,
+ taskKind: taskKind ?? "STANDARD",
};
try {
@@ -369,9 +370,9 @@ export class RunEngineTriggerTaskService {
rootTaskRunId: parentRun?.rootTaskRunId ?? parentRun?.id,
batch: options?.batchId
? {
- id: options.batchId,
- index: options.batchIndex ?? 0,
- }
+ id: options.batchId,
+ index: options.batchIndex ?? 0,
+ }
: undefined,
resumeParentOnCompletion: body.options?.resumeParentOnCompletion,
depth,
@@ -402,26 +403,26 @@ export class RunEngineTriggerTaskService {
onDebounced:
body.options?.debounce && body.options?.resumeParentOnCompletion
? async ({ existingRun, waitpoint, debounceKey }) => {
- return await this.traceEventConcern.traceDebouncedRun(
- triggerRequest,
- parentRun?.taskEventStore,
- {
- existingRun,
- debounceKey,
- incomplete: waitpoint.status === "PENDING",
- isError: waitpoint.outputIsError,
- },
- async (spanEvent) => {
- const spanId =
- options?.parentAsLinkType === "replay"
- ? spanEvent.spanId
- : spanEvent.traceparent?.spanId
+ return await this.traceEventConcern.traceDebouncedRun(
+ triggerRequest,
+ parentRun?.taskEventStore,
+ {
+ existingRun,
+ debounceKey,
+ incomplete: waitpoint.status === "PENDING",
+ isError: waitpoint.outputIsError,
+ },
+ async (spanEvent) => {
+ const spanId =
+ options?.parentAsLinkType === "replay"
+ ? spanEvent.spanId
+ : spanEvent.traceparent?.spanId
? `${spanEvent.traceparent.spanId}:${spanEvent.spanId}`
: spanEvent.spanId;
- return spanId;
- }
- );
- }
+ return spanId;
+ }
+ );
+ }
: undefined,
},
this.prisma
diff --git a/apps/webapp/app/runEngine/types.ts b/apps/webapp/app/runEngine/types.ts
index d5e61d018..c0c5de1d2 100644
--- a/apps/webapp/app/runEngine/types.ts
+++ b/apps/webapp/app/runEngine/types.ts
@@ -37,18 +37,19 @@ export type TriggerTaskResult = {
export type QueueValidationResult =
| {
- ok: true;
- }
+ ok: true;
+ }
| {
- ok: false;
- maximumSize: number;
- queueSize: number;
- };
+ ok: false;
+ maximumSize: number;
+ queueSize: number;
+ };
export type QueueProperties = {
queueName: string;
lockedQueueId?: string;
taskTtl?: string | null;
+ taskKind?: string;
};
export type LockedBackgroundWorker = Pick<
@@ -98,22 +99,22 @@ export interface ParentRunValidationParams {
export type ValidationResult =
| {
- ok: true;
- }
+ ok: true;
+ }
| {
- ok: false;
- error: Error;
- };
+ ok: false;
+ error: Error;
+ };
export type EntitlementValidationResult =
| {
- ok: true;
- plan?: ReportUsagePlan;
- }
+ ok: true;
+ plan?: ReportUsagePlan;
+ }
| {
- ok: false;
- error: Error;
- };
+ ok: false;
+ error: Error;
+ };
export interface TriggerTaskValidator {
validateTags(params: TagValidationParams): ValidationResult;
diff --git a/apps/webapp/app/services/runsReplicationService.server.ts b/apps/webapp/app/services/runsReplicationService.server.ts
index 7930c0548..167564572 100644
--- a/apps/webapp/app/services/runsReplicationService.server.ts
+++ b/apps/webapp/app/services/runsReplicationService.server.ts
@@ -921,6 +921,7 @@ export class RunsReplicationService {
run.maxDurationInSeconds ?? null, // max_duration_in_seconds
annotations?.triggerSource ?? "", // trigger_source
annotations?.rootTriggerSource ?? "", // root_trigger_source
+ annotations?.taskKind ?? "", // task_kind
run.isWarmStart ?? null, // is_warm_start
];
}
diff --git a/apps/webapp/app/services/runsRepository/clickhouseRunsRepository.server.ts b/apps/webapp/app/services/runsRepository/clickhouseRunsRepository.server.ts
index 1368279e6..49725d2ce 100644
--- a/apps/webapp/app/services/runsRepository/clickhouseRunsRepository.server.ts
+++ b/apps/webapp/app/services/runsRepository/clickhouseRunsRepository.server.ts
@@ -151,6 +151,7 @@ export class ClickHouseRunsRepository implements IRunsRepository {
metadataType: true,
machinePreset: true,
queue: true,
+ annotations: true,
},
});
@@ -334,4 +335,22 @@ function applyRunFiltersToQueryBuilder(
errorFingerprint: ErrorId.toId(options.errorId),
});
}
+
+ if (options.taskKinds && options.taskKinds.length > 0) {
+ const includesStandard = options.taskKinds.includes("STANDARD");
+ // Include empty string when filtering for STANDARD (default value for pre-existing runs)
+ const effectiveKinds = includesStandard
+ ? [...options.taskKinds, ""]
+ : options.taskKinds;
+
+ if (effectiveKinds.length === 1) {
+ queryBuilder.where("task_kind = {taskKind: String}", {
+ taskKind: effectiveKinds[0]!,
+ });
+ } else {
+ queryBuilder.where("task_kind IN {taskKinds: Array(String)}", {
+ taskKinds: effectiveKinds,
+ });
+ }
+ }
}
diff --git a/apps/webapp/app/services/runsRepository/runsRepository.server.ts b/apps/webapp/app/services/runsRepository/runsRepository.server.ts
index 4f097f61c..2f9546289 100644
--- a/apps/webapp/app/services/runsRepository/runsRepository.server.ts
+++ b/apps/webapp/app/services/runsRepository/runsRepository.server.ts
@@ -42,6 +42,7 @@ const RunListInputOptionsSchema = z.object({
queues: z.array(z.string()).optional(),
machines: MachinePresetName.array().optional(),
errorId: z.string().optional(),
+ taskKinds: z.array(z.string()).optional(),
});
export type RunListInputOptions = z.infer;
@@ -53,6 +54,7 @@ export type RunListInputFilters = Omit<
export type ParsedRunFilters = RunListInputFilters & {
cursor?: string;
direction?: "forward" | "backward";
+ sources?: string[];
};
export type FilterRunsOptions = Omit & {
@@ -102,6 +104,7 @@ export type ListedRun = Prisma.TaskRunGetPayload<{
metadataType: true;
machinePreset: true;
queue: true;
+ annotations: true;
};
}>;
diff --git a/apps/webapp/app/utils/pathBuilder.ts b/apps/webapp/app/utils/pathBuilder.ts
index 7a151053f..135733bc4 100644
--- a/apps/webapp/app/utils/pathBuilder.ts
+++ b/apps/webapp/app/utils/pathBuilder.ts
@@ -314,6 +314,31 @@ export function v3TestTaskPath(
)}`;
}
+export function v3PlaygroundPath(
+ organization: OrgForPath,
+ project: ProjectForPath,
+ environment: EnvironmentForPath
+) {
+ return `${v3EnvironmentPath(organization, project, environment)}/playground`;
+}
+
+export function v3PlaygroundAgentPath(
+ organization: OrgForPath,
+ project: ProjectForPath,
+ environment: EnvironmentForPath,
+ agentSlug: string
+) {
+ return `${v3PlaygroundPath(organization, project, environment)}/${encodeURIComponent(agentSlug)}`;
+}
+
+export function v3AgentsPath(
+ organization: OrgForPath,
+ project: ProjectForPath,
+ environment: EnvironmentForPath
+) {
+ return `${v3EnvironmentPath(organization, project, environment)}/agents`;
+}
+
export function v3RunsPath(
organization: OrgForPath,
project: ProjectForPath,
diff --git a/apps/webapp/app/v3/services/createBackgroundWorker.server.ts b/apps/webapp/app/v3/services/createBackgroundWorker.server.ts
index c83813272..8a97def28 100644
--- a/apps/webapp/app/v3/services/createBackgroundWorker.server.ts
+++ b/apps/webapp/app/v3/services/createBackgroundWorker.server.ts
@@ -288,6 +288,19 @@ async function createWorkerTask(
);
}
+ // @crumbs
+ console.log(`[crumbs:webapp] createWorkerTask task=${task.id} triggerSource=${task.triggerSource} agentConfig=${JSON.stringify(task.agentConfig)} taskKeys=${Object.keys(task).join(",")}`); // @crumbs
+
+ const resolvedTriggerSource =
+ task.triggerSource === "schedule"
+ ? ("SCHEDULED" as const)
+ : task.triggerSource === "agent"
+ ? ("AGENT" as const)
+ : ("STANDARD" as const);
+
+ // @crumbs
+ console.log(`[crumbs:webapp] createWorkerTask resolved triggerSource=${resolvedTriggerSource} for task=${task.id}`); // @crumbs
+
await prisma.backgroundWorkerTask.create({
data: {
friendlyId: generateFriendlyId("task"),
@@ -301,7 +314,8 @@ async function createWorkerTask(
retryConfig: task.retry,
queueConfig: task.queue,
machineConfig: task.machine,
- triggerSource: task.triggerSource === "schedule" ? "SCHEDULED" : "STANDARD",
+ triggerSource: resolvedTriggerSource,
+ config: task.agentConfig ? (task.agentConfig as any) : undefined,
fileId: tasksToBackgroundFiles?.get(task.id) ?? null,
maxDurationInSeconds: task.maxDuration ? clampMaxDuration(task.maxDuration) : null,
ttl:
diff --git a/apps/webapp/package.json b/apps/webapp/package.json
index 0880eb710..0517f38f3 100644
--- a/apps/webapp/package.json
+++ b/apps/webapp/package.json
@@ -28,6 +28,7 @@
],
"dependencies": {
"@ai-sdk/openai": "^1.3.23",
+ "@ai-sdk/react": "^3.0.0",
"@ariakit/react": "^0.4.6",
"@ariakit/react-core": "^0.4.6",
"@aws-sdk/client-ecr": "^3.931.0",
diff --git a/internal-packages/clickhouse/schema/029_add_task_kind_to_task_runs_v2.sql b/internal-packages/clickhouse/schema/029_add_task_kind_to_task_runs_v2.sql
new file mode 100644
index 000000000..a88a7a46c
--- /dev/null
+++ b/internal-packages/clickhouse/schema/029_add_task_kind_to_task_runs_v2.sql
@@ -0,0 +1,7 @@
+-- +goose Up
+ALTER TABLE trigger_dev.task_runs_v2
+ ADD COLUMN task_kind LowCardinality(String) DEFAULT '';
+
+-- +goose Down
+ALTER TABLE trigger_dev.task_runs_v2
+ DROP COLUMN task_kind;
diff --git a/internal-packages/clickhouse/src/taskRuns.ts b/internal-packages/clickhouse/src/taskRuns.ts
index 6a9f66d78..f64273597 100644
--- a/internal-packages/clickhouse/src/taskRuns.ts
+++ b/internal-packages/clickhouse/src/taskRuns.ts
@@ -51,6 +51,7 @@ export const TaskRunV2 = z.object({
max_duration_in_seconds: z.number().int().nullish(),
trigger_source: z.string().default(""),
root_trigger_source: z.string().default(""),
+ task_kind: z.string().default(""),
is_warm_start: z.boolean().nullish(),
_version: z.string(),
_is_deleted: z.number().int().default(0),
@@ -110,6 +111,7 @@ export const TASK_RUN_COLUMNS = [
"max_duration_in_seconds",
"trigger_source",
"root_trigger_source",
+ "task_kind",
"is_warm_start",
] as const;
@@ -176,6 +178,7 @@ export type TaskRunFieldTypes = {
max_duration_in_seconds: number | null;
trigger_source: string;
root_trigger_source: string;
+ task_kind: string;
is_warm_start: boolean | null;
};
@@ -313,6 +316,7 @@ export type TaskRunInsertArray = [
max_duration_in_seconds: number | null,
trigger_source: string,
root_trigger_source: string,
+ task_kind: string,
is_warm_start: boolean | null,
];
diff --git a/internal-packages/database/prisma/migrations/20260329100903_add_agent_trigger_source_and_task_config/migration.sql b/internal-packages/database/prisma/migrations/20260329100903_add_agent_trigger_source_and_task_config/migration.sql
new file mode 100644
index 000000000..29233ab27
--- /dev/null
+++ b/internal-packages/database/prisma/migrations/20260329100903_add_agent_trigger_source_and_task_config/migration.sql
@@ -0,0 +1,5 @@
+-- AlterEnum
+ALTER TYPE "public"."TaskTriggerSource" ADD VALUE 'AGENT';
+
+-- AlterTable
+ALTER TABLE "public"."BackgroundWorkerTask" ADD COLUMN "config" JSONB;
diff --git a/internal-packages/database/prisma/migrations/20260330113734_add_playground_conversation/migration.sql b/internal-packages/database/prisma/migrations/20260330113734_add_playground_conversation/migration.sql
new file mode 100644
index 000000000..7d061a513
--- /dev/null
+++ b/internal-packages/database/prisma/migrations/20260330113734_add_playground_conversation/migration.sql
@@ -0,0 +1,34 @@
+-- CreateTable
+CREATE TABLE "public"."PlaygroundConversation" (
+ "id" TEXT NOT NULL,
+ "chatId" TEXT NOT NULL,
+ "title" TEXT NOT NULL DEFAULT 'New conversation',
+ "agentSlug" TEXT NOT NULL,
+ "runId" TEXT,
+ "clientData" JSONB,
+ "projectId" TEXT NOT NULL,
+ "runtimeEnvironmentId" TEXT NOT NULL,
+ "userId" TEXT NOT NULL,
+ "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ "updatedAt" TIMESTAMP(3) NOT NULL,
+
+ CONSTRAINT "PlaygroundConversation_pkey" PRIMARY KEY ("id")
+);
+
+-- CreateIndex
+CREATE INDEX "PlaygroundConversation_runtimeEnvironmentId_agentSlug_updat_idx" ON "public"."PlaygroundConversation"("runtimeEnvironmentId", "agentSlug", "updatedAt" DESC);
+
+-- CreateIndex
+CREATE INDEX "PlaygroundConversation_userId_runtimeEnvironmentId_idx" ON "public"."PlaygroundConversation"("userId", "runtimeEnvironmentId");
+
+-- CreateIndex
+CREATE UNIQUE INDEX "PlaygroundConversation_chatId_runtimeEnvironmentId_key" ON "public"."PlaygroundConversation"("chatId", "runtimeEnvironmentId");
+
+-- AddForeignKey
+ALTER TABLE "public"."PlaygroundConversation" ADD CONSTRAINT "PlaygroundConversation_runId_fkey" FOREIGN KEY ("runId") REFERENCES "public"."TaskRun"("id") ON DELETE SET NULL ON UPDATE CASCADE;
+
+-- AddForeignKey
+ALTER TABLE "public"."PlaygroundConversation" ADD CONSTRAINT "PlaygroundConversation_projectId_fkey" FOREIGN KEY ("projectId") REFERENCES "public"."Project"("id") ON DELETE CASCADE ON UPDATE CASCADE;
+
+-- AddForeignKey
+ALTER TABLE "public"."PlaygroundConversation" ADD CONSTRAINT "PlaygroundConversation_runtimeEnvironmentId_fkey" FOREIGN KEY ("runtimeEnvironmentId") REFERENCES "public"."RuntimeEnvironment"("id") ON DELETE CASCADE ON UPDATE CASCADE;
diff --git a/internal-packages/database/prisma/migrations/20260330135232_add_messages_and_last_event_id_to_playground/migration.sql b/internal-packages/database/prisma/migrations/20260330135232_add_messages_and_last_event_id_to_playground/migration.sql
new file mode 100644
index 000000000..0793d411c
--- /dev/null
+++ b/internal-packages/database/prisma/migrations/20260330135232_add_messages_and_last_event_id_to_playground/migration.sql
@@ -0,0 +1,3 @@
+-- AlterTable
+ALTER TABLE "public"."PlaygroundConversation" ADD COLUMN "lastEventId" TEXT,
+ADD COLUMN "messages" JSONB;
diff --git a/internal-packages/database/prisma/schema.prisma b/internal-packages/database/prisma/schema.prisma
index dcce27276..a255f7079 100644
--- a/internal-packages/database/prisma/schema.prisma
+++ b/internal-packages/database/prisma/schema.prisma
@@ -376,7 +376,8 @@ model RuntimeEnvironment {
waitpointTags WaitpointTag[]
BulkActionGroup BulkActionGroup[]
customerQueries CustomerQuery[]
- prompts Prompt[]
+ prompts Prompt[]
+ playgroundConversations PlaygroundConversation[]
errorGroupStates ErrorGroupState[]
taskIdentifiers TaskIdentifier[]
revokedApiKeys RevokedApiKey[]
@@ -460,6 +461,7 @@ model Project {
connectedGithubRepository ConnectedGithubRepository?
organizationProjectIntegration OrganizationProjectIntegration[]
customerQueries CustomerQuery[]
+ playgroundConversations PlaygroundConversation[]
buildSettings Json?
onboardingData Json?
@@ -696,6 +698,10 @@ model BackgroundWorkerTask {
triggerSource TaskTriggerSource @default(STANDARD)
+ /// Extra task configuration JSON. Shape depends on triggerSource.
+ /// AGENT: { type: "ai-sdk-chat" }
+ config Json?
+
payloadSchema Json?
@@unique([workerId, slug])
@@ -708,6 +714,49 @@ model BackgroundWorkerTask {
enum TaskTriggerSource {
STANDARD
SCHEDULED
+ AGENT
+}
+
+model PlaygroundConversation {
+ id String @id @default(cuid())
+
+ /// The chat session ID used by the transport
+ chatId String
+
+ /// User-editable conversation title (auto-generated from first message)
+ title String @default("New conversation")
+
+ /// Which agent this conversation is with
+ agentSlug String
+
+ /// The current active run backing this conversation (null if no run yet)
+ runId String?
+ run TaskRun? @relation(fields: [runId], references: [id], onDelete: SetNull, onUpdate: Cascade)
+
+ /// The client data JSON used for this conversation
+ clientData Json?
+
+ /// Accumulated UIMessages from completed turns (for resume without stream replay)
+ messages Json?
+
+ /// Last SSE event ID — resume from this position to avoid replaying old turns
+ lastEventId String?
+
+ project Project @relation(fields: [projectId], references: [id], onDelete: Cascade, onUpdate: Cascade)
+ projectId String
+
+ runtimeEnvironment RuntimeEnvironment @relation(fields: [runtimeEnvironmentId], references: [id], onDelete: Cascade, onUpdate: Cascade)
+ runtimeEnvironmentId String
+
+ /// The user who started this conversation
+ userId String
+
+ createdAt DateTime @default(now())
+ updatedAt DateTime @updatedAt
+
+ @@unique([chatId, runtimeEnvironmentId])
+ @@index([runtimeEnvironmentId, agentSlug, updatedAt(sort: Desc)])
+ @@index([userId, runtimeEnvironmentId])
}
/// Durable, typed, bidirectional I/O primitive. Owns two S2 streams (.out / .in).
@@ -1011,6 +1060,8 @@ model TaskRun {
/// (OSS, or pre-backfill); reads fall back to the global basin.
streamBasinName String?
+ playgroundConversations PlaygroundConversation[]
+
@@unique([oneTimeUseToken])
@@unique([runtimeEnvironmentId, taskIdentifier, idempotencyKey])
// Finding child runs
diff --git a/packages/cli-v3/src/dev/devSupervisor.ts b/packages/cli-v3/src/dev/devSupervisor.ts
index 59b2d2a47..b528c6eab 100644
--- a/packages/cli-v3/src/dev/devSupervisor.ts
+++ b/packages/cli-v3/src/dev/devSupervisor.ts
@@ -1,3 +1,5 @@
+import { trail } from "agentcrumbs"; // @crumbs
+const _cliCrumb = trail("cli"); // @crumbs
import { spawn, type ChildProcess } from "node:child_process";
import { readFileSync, writeFileSync, renameSync, unlinkSync, existsSync, mkdirSync } from "node:fs";
import { join } from "node:path";
@@ -345,6 +347,23 @@ class DevSupervisor implements WorkerRuntime {
const sourceFiles = resolveSourceFiles(manifest.sources, backgroundWorker.manifest.tasks);
+ // #region @crumbs
+ const _agentTasksSupervisor = (backgroundWorker.manifest.tasks as any[]).filter(
+ (t: any) => t.triggerSource || t.agentConfig
+ );
+ _cliCrumb("devSupervisor sending worker metadata to API", {
+ totalTasks: backgroundWorker.manifest.tasks.length,
+ agentTasks: _agentTasksSupervisor.map((t: any) => ({
+ id: t.id,
+ triggerSource: t.triggerSource,
+ agentConfig: t.agentConfig,
+ })),
+ manifestTaskKeys: backgroundWorker.manifest.tasks[0]
+ ? Object.keys(backgroundWorker.manifest.tasks[0])
+ : [],
+ });
+ // #endregion @crumbs
+
const backgroundWorkerBody: CreateBackgroundWorkerRequestBody = {
localOnly: true,
metadata: {
diff --git a/packages/cli-v3/src/entryPoints/dev-index-worker.ts b/packages/cli-v3/src/entryPoints/dev-index-worker.ts
index 53b95ad04..4d9810984 100644
--- a/packages/cli-v3/src/entryPoints/dev-index-worker.ts
+++ b/packages/cli-v3/src/entryPoints/dev-index-worker.ts
@@ -1,3 +1,5 @@
+import { trail } from "agentcrumbs"; // @crumbs
+const _cliCrumb = trail("cli"); // @crumbs
import {
BuildManifest,
type HandleErrorFunction,
@@ -119,6 +121,18 @@ const { buildManifest, importErrors, config, timings } = await bootstrap();
let tasks = await convertSchemasToJsonSchemas(resourceCatalog.listTaskManifests());
+// #region @crumbs
+const _agentTasks = tasks.filter((t: any) => t.triggerSource || t.agentConfig);
+_cliCrumb("dev-index-worker tasks after listTaskManifests", {
+ totalTasks: tasks.length,
+ agentTasks: _agentTasks.map((t: any) => ({
+ id: t.id,
+ triggerSource: t.triggerSource,
+ agentConfig: t.agentConfig,
+ })),
+});
+// #endregion @crumbs
+
// If the config has retry defaults, we need to apply them to all tasks that don't have any retry settings
if (config.retries?.default) {
tasks = tasks.map((task) => {
diff --git a/packages/core/src/v3/resource-catalog/standardResourceCatalog.ts b/packages/core/src/v3/resource-catalog/standardResourceCatalog.ts
index ea134a456..f20b168c3 100644
--- a/packages/core/src/v3/resource-catalog/standardResourceCatalog.ts
+++ b/packages/core/src/v3/resource-catalog/standardResourceCatalog.ts
@@ -1,3 +1,5 @@
+import { trail } from "agentcrumbs"; // @crumbs
+const _coreCrumb = trail("core"); // @crumbs
import {
PromptManifest,
PromptMetadata,
@@ -73,6 +75,15 @@ export class StandardResourceCatalog implements ResourceCatalog {
return;
}
+ // #region @crumbs
+ _coreCrumb("registerTaskMetadata", {
+ taskId: task.id,
+ triggerSource: metadata.triggerSource,
+ agentConfig: metadata.agentConfig,
+ metadataKeys: Object.keys(metadata),
+ });
+ // #endregion @crumbs
+
this._taskFileMetadata.set(task.id, {
...this._currentFileContext,
});
@@ -86,25 +97,31 @@ export class StandardResourceCatalog implements ResourceCatalog {
}
updateTaskMetadata(id: string, updates: Partial): void {
+ const { fns, schema, ...metadataUpdates } = updates;
+
const existingMetadata = this._taskMetadata.get(id);
- if (existingMetadata) {
+ if (existingMetadata && Object.keys(metadataUpdates).length > 0) {
this._taskMetadata.set(id, {
...existingMetadata,
- ...updates,
+ ...metadataUpdates,
});
}
- if (updates.fns) {
+ if (fns) {
const existingFunctions = this._taskFunctions.get(id);
if (existingFunctions) {
this._taskFunctions.set(id, {
...existingFunctions,
- ...updates.fns,
+ ...fns,
});
}
}
+
+ if (schema) {
+ this._taskSchemas.set(id, schema);
+ }
}
// Return all the tasks, without the functions
@@ -123,6 +140,18 @@ export class StandardResourceCatalog implements ResourceCatalog {
...fileMetadata,
};
+ // #region @crumbs
+ if (metadata.triggerSource || metadata.agentConfig) {
+ _coreCrumb("listTaskManifests building manifest", {
+ taskId: id,
+ triggerSource: metadata.triggerSource,
+ agentConfig: metadata.agentConfig,
+ manifestTriggerSource: taskManifest.triggerSource,
+ manifestAgentConfig: (taskManifest as any).agentConfig,
+ });
+ }
+ // #endregion @crumbs
+
result.push(taskManifest);
}
diff --git a/packages/core/src/v3/schemas/api.ts b/packages/core/src/v3/schemas/api.ts
index 0db92a67c..8089fe0e4 100644
--- a/packages/core/src/v3/schemas/api.ts
+++ b/packages/core/src/v3/schemas/api.ts
@@ -1115,6 +1115,7 @@ const CommonRunFields = {
baseCostInCents: z.number(),
durationMs: z.number(),
metadata: z.record(z.any()).optional(),
+ taskKind: z.string().optional(),
};
const RetrieveRunCommandFields = {
diff --git a/packages/core/src/v3/schemas/resources.ts b/packages/core/src/v3/schemas/resources.ts
index e681c7284..753324d12 100644
--- a/packages/core/src/v3/schemas/resources.ts
+++ b/packages/core/src/v3/schemas/resources.ts
@@ -2,6 +2,12 @@ import { z } from "zod";
import { QueueManifest, RetryOptions, ScheduleMetadata } from "./schemas.js";
import { MachineConfig } from "./common.js";
+export const AgentConfig = z.object({
+ type: z.string(), // "ai-sdk-chat" initially, extensible for future agent types
+});
+
+export type AgentConfig = z.infer;
+
export const TaskResource = z.object({
id: z.string(),
description: z.string().optional(),
@@ -11,6 +17,7 @@ export const TaskResource = z.object({
retry: RetryOptions.optional(),
machine: MachineConfig.optional(),
triggerSource: z.string().optional(),
+ agentConfig: AgentConfig.optional(),
schedule: ScheduleMetadata.optional(),
maxDuration: z.number().optional(),
ttl: z.string().or(z.number().nonnegative().int()).optional(),
diff --git a/packages/core/src/v3/schemas/runEngine.ts b/packages/core/src/v3/schemas/runEngine.ts
index b9e41c9a8..5ea22960b 100644
--- a/packages/core/src/v3/schemas/runEngine.ts
+++ b/packages/core/src/v3/schemas/runEngine.ts
@@ -15,11 +15,15 @@ export const TriggerAction = z.enum(["trigger", "replay", "test"]).or(anyString)
export type TriggerAction = z.infer;
+export const TaskKind = z.enum(["STANDARD", "SCHEDULED", "AGENT"]).or(anyString);
+export type TaskKind = z.infer;
+
export const RunAnnotations = z.object({
triggerSource: TriggerSource,
triggerAction: TriggerAction,
rootTriggerSource: TriggerSource,
rootScheduleId: z.string().optional(),
+ taskKind: TaskKind.optional(),
});
export type RunAnnotations = z.infer;
diff --git a/packages/core/src/v3/schemas/schemas.ts b/packages/core/src/v3/schemas/schemas.ts
index 5fb85f80a..d3ce05be5 100644
--- a/packages/core/src/v3/schemas/schemas.ts
+++ b/packages/core/src/v3/schemas/schemas.ts
@@ -180,6 +180,10 @@ export const ScheduleMetadata = z.object({
environments: z.array(EnvironmentType).optional(),
});
+const AgentConfig = z.object({
+ type: z.string(),
+});
+
const taskMetadata = {
id: z.string(),
description: z.string().optional(),
@@ -187,6 +191,7 @@ const taskMetadata = {
retry: RetryOptions.optional(),
machine: MachineConfig.optional(),
triggerSource: z.string().optional(),
+ agentConfig: AgentConfig.optional(),
schedule: ScheduleMetadata.optional(),
maxDuration: z.number().optional(),
ttl: z.string().or(z.number().nonnegative().int()).optional(),
diff --git a/packages/core/src/v3/types/tasks.ts b/packages/core/src/v3/types/tasks.ts
index 3a9c6aed4..978a6e5bd 100644
--- a/packages/core/src/v3/types/tasks.ts
+++ b/packages/core/src/v3/types/tasks.ts
@@ -387,6 +387,12 @@ type CommonTaskOptions<
* Should be a valid JSON Schema Draft 7 object.
*/
jsonSchema?: JSONSchema;
+
+ /** @internal Set by SDK internals (e.g. `chat.agent()`, `schedules.task()`). */
+ triggerSource?: string;
+
+ /** @internal Agent configuration, only set when `triggerSource` is `"agent"`. */
+ agentConfig?: { type: string };
};
export type TaskOptions<
diff --git a/packages/trigger-sdk/src/v3/ai.ts b/packages/trigger-sdk/src/v3/ai.ts
index d0a940288..695d5c95e 100644
--- a/packages/trigger-sdk/src/v3/ai.ts
+++ b/packages/trigger-sdk/src/v3/ai.ts
@@ -44,10 +44,11 @@ import { metadata } from "./metadata.js";
import type { ResolvedPrompt } from "./prompt.js";
import { streams } from "./streams.js";
import { createTask, trigger as triggerTaskInternal } from "./shared.js";
+import { resourceCatalog } from "@trigger.dev/core/v3";
import type { TriggerChatTaskParams, TriggerChatTaskResult } from "./chat.js";
import { tracer } from "./tracer.js";
-/** Re-export for typing `ctx` in `chat.task` hooks without importing `@trigger.dev/core`. */
+/** Re-export for typing `ctx` in `chat.agent` hooks without importing `@trigger.dev/core`. */
export type { TaskRunContext } from "@trigger.dev/core/v3";
import {
CHAT_STREAM_KEY as _CHAT_STREAM_KEY,
@@ -69,7 +70,7 @@ function toModelMessages(messages: UIMessage[]): Promise {
export type ToolCallExecutionOptions = {
toolCallId: string;
experimental_context?: unknown;
- /** Chat context — only present when the tool runs inside a chat.task turn. */
+ /** Chat context — only present when the tool runs inside a chat.agent turn. */
chatId?: string;
turn?: number;
continuation?: boolean;
@@ -78,7 +79,7 @@ export type ToolCallExecutionOptions = {
chatLocals?: Record;
};
-/** Chat context stored in locals during each chat.task turn for auto-detection. */
+/** Chat context stored in locals during each chat.agent turn for auto-detection. */
type ChatTurnContext = {
chatId: string;
turn: number;
@@ -89,14 +90,14 @@ const chatTurnContextKey = locals.create("chat.turnContext");
type ToolResultContent = Array<
| {
- type: "text";
- text: string;
- }
+ type: "text";
+ text: string;
+ }
| {
- type: "image";
- data: string;
- mimeType?: string;
- }
+ type: "image";
+ data: string;
+ mimeType?: string;
+ }
>;
export type ToolOptions = {
@@ -302,7 +303,7 @@ function getToolCallId(): string | undefined {
}
/**
- * Get the chat context from inside a subtask invoked via `ai.toolExecute()` (or legacy `ai.tool()`) within a `chat.task`.
+ * Get the chat context from inside a subtask invoked via `ai.toolExecute()` (or legacy `ai.tool()`) within a `chat.agent`.
* Pass `typeof yourChatTask` as the type parameter to get typed `clientData`.
* Returns `undefined` if the parent is not a chat task.
*
@@ -341,8 +342,8 @@ function getToolChatContextOrThrow(): ChatT
const ctx = getToolChatContext();
if (!ctx) {
throw new Error(
- "ai.chatContextOrThrow() called outside of a chat.task context. " +
- "This helper can only be used inside a subtask invoked via ai.toolExecute() (or legacy ai.tool()) from a chat.task."
+ "ai.chatContextOrThrow() called outside of a chat.agent context. " +
+ "This helper can only be used inside a subtask invoked via ai.toolExecute() (or legacy ai.tool()) from a chat.agent."
);
}
return ctx;
@@ -385,7 +386,7 @@ export const ai = {
currentToolOptions: getToolOptionsFromMetadata,
/** Get the tool call ID from inside a subtask invoked via `ai.toolExecute()` (or legacy `ai.tool()`). */
toolCallId: getToolCallId,
- /** Get chat context (chatId, turn, clientData, etc.) from inside a subtask of a `chat.task`. Returns undefined if not in a chat context. */
+ /** Get chat context (chatId, turn, clientData, etc.) from inside a subtask of a `chat.agent`. Returns undefined if not in a chat context. */
chatContext: getToolChatContext,
/** Get chat context or throw if not in a chat context. Pass `typeof yourChatTask` for typed clientData. */
chatContextOrThrow: getToolChatContextOrThrow,
@@ -420,7 +421,7 @@ function createChatAccessToken(
/**
* The default stream key used for chat transport communication.
- * Both `TriggerChatTransport` (frontend) and `pipeChat`/`chatTask` (backend)
+ * Both `TriggerChatTransport` (frontend) and `pipeChat`/`chatAgent` (backend)
* use this key by default.
*/
export const CHAT_STREAM_KEY = _CHAT_STREAM_KEY;
@@ -432,7 +433,7 @@ export { CHAT_MESSAGES_STREAM_ID, CHAT_STOP_STREAM_ID };
* Typed chat output stream. Provides `.writer()`, `.pipe()`, `.append()`,
* and `.read()` methods pre-bound to the chat stream key and typed to `UIMessageChunk`.
*
- * Use from within a `chat.task` run to write custom chunks:
+ * Use from within a `chat.agent` run to write custom chunks:
* ```ts
* const { waitUntilComplete } = chat.stream.writer({
* execute: ({ write }) => {
@@ -561,7 +562,7 @@ export type ChatTaskWirePayload = {
};
/**
- * Abort signals provided to the `chatTask` run function.
+ * Abort signals provided to the `chatAgent` run function.
*/
export type ChatTaskSignals = {
/** Combined signal — fires on run cancel OR stop generation. Pass to `streamText`. */
@@ -612,7 +613,7 @@ export type ChatTaskSignals = {
};
/**
- * The full payload passed to a `chatTask` run function.
+ * The full payload passed to a `chatAgent` run function.
* Extends `ChatTaskPayload` (the wire payload) with abort signals.
*/
export type ChatTaskRunPayload = ChatTaskPayload &
@@ -653,7 +654,7 @@ const chatBackgroundQueueKey = locals.create("chat.backgroundQue
*/
const chatPipeCountKey = locals.create("chat.pipeCount");
const chatStopControllerKey = locals.create("chat.stopController");
-/** Static (task-level) UIMessageStream options, set once during chatTask setup. @internal */
+/** Static (task-level) UIMessageStream options, set once during chatAgent setup. @internal */
const chatUIStreamStaticKey = locals.create>(
"chat.uiMessageStreamOptions.static"
);
@@ -752,8 +753,8 @@ interface CompactionState {
const chatCompactionStateKey = locals.create("chat.compaction");
const chatOnCompactedKey =
locals.create<(event: CompactedEvent) => Promise | void>("chat.onCompacted");
-/** @internal Full task `ctx` for the active `chat.task` run (for hooks invoked from nested compaction). */
-const chatTaskRunContextKey = locals.create("chat.taskRunContext");
+/** @internal Full task `ctx` for the active `chat.agent` run (for hooks invoked from nested compaction). */
+const chatAgentRunContextKey = locals.create("chat.agentRunContext");
const chatPrepareMessagesKey =
locals.create<(event: PrepareMessagesEvent) => ModelMessage[] | Promise>(
"chat.prepareMessages"
@@ -767,13 +768,13 @@ export type SummarizeEvent = {
messages: ModelMessage[];
/** Full usage object from the triggering step/turn. */
usage?: LanguageModelUsage;
- /** Cumulative token usage across all completed turns. Present in chat.task contexts. */
+ /** Cumulative token usage across all completed turns. Present in chat.agent contexts. */
totalUsage?: LanguageModelUsage;
- /** The chat session ID (if running inside a chat.task). */
+ /** The chat session ID (if running inside a chat.agent). */
chatId?: string;
- /** The current turn number (0-indexed, if inside a chat.task). */
+ /** The current turn number (0-indexed, if inside a chat.agent). */
turn?: number;
- /** Custom data from the frontend (if inside a chat.task). */
+ /** Custom data from the frontend (if inside a chat.agent). */
clientData?: unknown;
/**
* Where compaction is running:
@@ -810,13 +811,13 @@ export type CompactMessagesEvent = {
};
/**
- * Options for the `compaction` field on `chat.task()`.
+ * Options for the `compaction` field on `chat.agent()`.
*
* Handles compaction automatically in both the inner loop (prepareStep, between
* tool-call steps) and the outer loop (between turns, for single-step responses
* where prepareStep never fires).
*/
-export type ChatTaskCompactionOptions = {
+export type ChatAgentCompactionOptions = {
/** Decide whether to compact. Return true to trigger compaction. */
shouldCompact: (event: ShouldCompactEvent) => boolean | Promise;
/** Generate a summary from the current messages. Return the summary text. */
@@ -861,8 +862,8 @@ export type ChatTaskCompactionOptions = {
};
/** @internal */
-const chatTaskCompactionKey =
- locals.create>("chat.taskCompaction");
+const chatAgentCompactionKey =
+ locals.create>("chat.agentCompaction");
// ---------------------------------------------------------------------------
// Pending messages — mid-execution message injection via prepareStep
@@ -917,7 +918,7 @@ export type PendingMessagesInjectedEvent = {
};
/**
- * Options for the `pendingMessages` field on `chat.task()`, `chat.createSession()`,
+ * Options for the `pendingMessages` field on `chat.agent()`, `chat.createSession()`,
* or `ChatMessageAccumulator`.
*
* Configures how messages that arrive during streaming are handled. When
@@ -968,9 +969,9 @@ export type PrepareMessagesEvent = {
messages: ModelMessage[];
/** Why messages are being prepared. */
reason:
- | "run" // Messages being passed to run() for streamText
- | "compaction-rebuild" // Rebuilding from a previous compaction summary
- | "compaction-result"; // Fresh compaction just produced these messages
+ | "run" // Messages being passed to run() for streamText
+ | "compaction-rebuild" // Rebuilding from a previous compaction summary
+ | "compaction-result"; // Fresh compaction just produced these messages
/** The chat session ID. */
chatId: string;
/** The current turn number (0-indexed). */
@@ -992,7 +993,7 @@ export type CompactionChunkData = {
* Event passed to the `onCompacted` callback.
*/
export type CompactedEvent = {
- /** Task run context — same as `task` lifecycle hooks and `chat.task` `run({ ctx })`. */
+ /** Task run context — same as `task` lifecycle hooks and `chat.agent` `run({ ctx })`. */
ctx: TaskRunContext;
/** The generated summary text. */
summary: string;
@@ -1010,9 +1011,9 @@ export type CompactedEvent = {
outputTokens: number | undefined;
/** The step number where compaction occurred (0-indexed). */
stepNumber: number;
- /** The chat session ID (if running inside a chat.task). */
+ /** The chat session ID (if running inside a chat.agent). */
chatId?: string;
- /** The current turn number (if running inside a chat.task). */
+ /** The current turn number (if running inside a chat.agent). */
turn?: number;
/** Stream writer — write custom `UIMessageChunk` parts to the chat stream. Lazy: no overhead if unused. */
writer: ChatWriter;
@@ -1032,13 +1033,13 @@ export type ShouldCompactEvent = {
outputTokens: number | undefined;
/** Full usage object from the triggering step/turn. */
usage?: LanguageModelUsage;
- /** Cumulative token usage across all completed turns. Present in chat.task contexts. */
+ /** Cumulative token usage across all completed turns. Present in chat.agent contexts. */
totalUsage?: LanguageModelUsage;
- /** The chat session ID (if running inside a chat.task). */
+ /** The chat session ID (if running inside a chat.agent). */
chatId?: string;
- /** The current turn number (0-indexed, if inside a chat.task). */
+ /** The current turn number (0-indexed, if inside a chat.agent). */
turn?: number;
- /** Custom data from the frontend (if inside a chat.task). */
+ /** Custom data from the frontend (if inside a chat.agent). */
clientData?: unknown;
/**
* Where this check is running:
@@ -1235,18 +1236,18 @@ async function chatCompact(
const shouldTrigger = options.shouldCompact
? await options.shouldCompact({
- messages,
- totalTokens,
- inputTokens,
- outputTokens,
- usage: currentStep.usage,
- source: "inner",
- stepNumber,
- steps,
- chatId: turnCtx?.chatId,
- turn: turnCtx?.turn,
- clientData: turnCtx?.clientData,
- })
+ messages,
+ totalTokens,
+ inputTokens,
+ outputTokens,
+ usage: currentStep.usage,
+ source: "inner",
+ stepNumber,
+ steps,
+ chatId: turnCtx?.chatId,
+ turn: turnCtx?.turn,
+ clientData: turnCtx?.clientData,
+ })
: totalTokens != null && options.threshold != null && totalTokens > options.threshold;
if (!shouldTrigger) {
@@ -1294,7 +1295,7 @@ async function chatCompact(
const onCompactedHook = locals.get(chatOnCompactedKey);
if (onCompactedHook) {
await onCompactedHook({
- ctx: locals.get(chatTaskRunContextKey)!,
+ ctx: locals.get(chatAgentRunContextKey)!,
summary,
messages,
messageCount: messages.length,
@@ -1557,16 +1558,16 @@ function isCompactionSafe(messages: UIMessage[]): boolean {
export type ChatPromptValue =
| ResolvedPrompt
| {
- text: string;
- model: undefined;
- config: undefined;
- promptId: string;
- version: number;
- labels: string[];
- toAISDKTelemetry: (additionalMetadata?: Record) => {
- experimental_telemetry: { isEnabled: true; metadata: Record };
- };
+ text: string;
+ model: undefined;
+ config: undefined;
+ promptId: string;
+ version: number;
+ labels: string[];
+ toAISDKTelemetry: (additionalMetadata?: Record) => {
+ experimental_telemetry: { isEnabled: true; metadata: Record };
};
+ };
/** @internal */
const chatPromptKey = locals.create("chat.prompt");
@@ -1658,7 +1659,7 @@ function toStreamTextOptions(options?: ToStreamTextOptionsOptions): Record =
| {
- /** Suspend is happening after onPreload, before the first message. */
- phase: "preload";
- /** Task run context. */
- ctx: TaskRunContext;
- /** The chat session ID. */
- chatId: string;
- /** The Trigger.dev run ID. */
- runId: string;
- /** Custom data from the frontend. */
- clientData?: TClientData;
- }
+ /** Suspend is happening after onPreload, before the first message. */
+ phase: "preload";
+ /** Task run context. */
+ ctx: TaskRunContext;
+ /** The chat session ID. */
+ chatId: string;
+ /** The Trigger.dev run ID. */
+ runId: string;
+ /** Custom data from the frontend. */
+ clientData?: TClientData;
+ }
| {
- /** Suspend is happening after a completed turn, waiting for the next message. */
- phase: "turn";
- /** Task run context. */
- ctx: TaskRunContext;
- /** The chat session ID. */
- chatId: string;
- /** The Trigger.dev run ID. */
- runId: string;
- /** The turn number (0-indexed) that just completed. */
- turn: number;
- /** The accumulated model messages after the completed turn. */
- messages: ModelMessage[];
- /** The accumulated UI messages after the completed turn. */
- uiMessages: TUIM[];
- /** Custom data from the frontend. */
- clientData?: TClientData;
- };
+ /** Suspend is happening after a completed turn, waiting for the next message. */
+ phase: "turn";
+ /** Task run context. */
+ ctx: TaskRunContext;
+ /** The chat session ID. */
+ chatId: string;
+ /** The Trigger.dev run ID. */
+ runId: string;
+ /** The turn number (0-indexed) that just completed. */
+ turn: number;
+ /** The accumulated model messages after the completed turn. */
+ messages: ModelMessage[];
+ /** The accumulated UI messages after the completed turn. */
+ uiMessages: TUIM[];
+ /** Custom data from the frontend. */
+ clientData?: TClientData;
+ };
/**
* Discriminated event passed to the `onChatResume` callback.
@@ -2063,37 +2064,37 @@ export type ChatSuspendEvent =
| {
- /** First message arrived after preload suspension. */
- phase: "preload";
- /** Task run context. */
- ctx: TaskRunContext;
- /** The chat session ID. */
- chatId: string;
- /** The Trigger.dev run ID. */
- runId: string;
- /** Custom data from the frontend. */
- clientData?: TClientData;
- }
+ /** First message arrived after preload suspension. */
+ phase: "preload";
+ /** Task run context. */
+ ctx: TaskRunContext;
+ /** The chat session ID. */
+ chatId: string;
+ /** The Trigger.dev run ID. */
+ runId: string;
+ /** Custom data from the frontend. */
+ clientData?: TClientData;
+ }
| {
- /** Next message arrived after turn suspension. */
- phase: "turn";
- /** Task run context. */
- ctx: TaskRunContext;
- /** The chat session ID. */
- chatId: string;
- /** The Trigger.dev run ID. */
- runId: string;
- /** The turn number that was completed before suspension. */
- turn: number;
- /** The accumulated model messages (from before suspension). */
- messages: ModelMessage[];
- /** The accumulated UI messages (from before suspension). */
- uiMessages: TUIM[];
- /** Custom data from the frontend. */
- clientData?: TClientData;
- };
+ /** Next message arrived after turn suspension. */
+ phase: "turn";
+ /** Task run context. */
+ ctx: TaskRunContext;
+ /** The chat session ID. */
+ chatId: string;
+ /** The Trigger.dev run ID. */
+ runId: string;
+ /** The turn number that was completed before suspension. */
+ turn: number;
+ /** The accumulated model messages (from before suspension). */
+ messages: ModelMessage[];
+ /** The accumulated UI messages (from before suspension). */
+ uiMessages: TUIM[];
+ /** Custom data from the frontend. */
+ clientData?: TClientData;
+ };
-export type ChatTaskOptions<
+export type ChatAgentOptions<
TIdentifier extends string,
TClientDataSchema extends TaskSchema | undefined = undefined,
TUIMessage extends UIMessage = UIMessage,
@@ -2114,7 +2115,7 @@ export type ChatTaskOptions<
* ```ts
* import { z } from "zod";
*
- * chat.task({
+ * chat.agent({
* id: "my-chat",
* clientDataSchema: z.object({ model: z.string().optional(), userId: z.string() }),
* run: async ({ messages, clientData, ctx, signal }) => {
@@ -2235,7 +2236,7 @@ export type ChatTaskOptions<
*
* @example
* ```ts
- * chat.task({
+ * chat.agent({
* id: "my-chat",
* compaction: {
* shouldCompact: ({ totalTokens }) => (totalTokens ?? 0) > 80_000,
@@ -2249,7 +2250,7 @@ export type ChatTaskOptions<
* });
* ```
*/
- compaction?: ChatTaskCompactionOptions;
+ compaction?: ChatAgentCompactionOptions;
/**
* Configure how messages that arrive during streaming are handled.
@@ -2384,7 +2385,7 @@ export type ChatTaskOptions<
*
* @example
* ```ts
- * chat.task({
+ * chat.agent({
* id: "my-chat",
* uiMessageStreamOptions: {
* sendReasoning: true,
@@ -2472,7 +2473,7 @@ export type ChatTaskOptions<
* import { streamText, convertToModelMessages } from "ai";
* import { openai } from "@ai-sdk/openai";
*
- * export const myChat = chat.task({
+ * export const myChat = chat.agent({
* id: "my-chat",
* run: async ({ messages, signal }) => {
* return streamText({
@@ -2484,12 +2485,12 @@ export type ChatTaskOptions<
* });
* ```
*/
-function chatTask<
+function chatAgent<
TIdentifier extends string,
TClientDataSchema extends TaskSchema | undefined = undefined,
TUIMessage extends UIMessage = UIMessage,
>(
- options: ChatTaskOptions
+ options: ChatAgentOptions
): Task>, unknown> {
const {
run: userRun,
@@ -2518,17 +2519,20 @@ function chatTask<
const parseClientData = clientDataSchema ? getSchemaParseFn(clientDataSchema) : undefined;
- return createTask<
+ const task = createTask<
TIdentifier,
ChatTaskWirePayload>,
unknown
>({
+ retry: { maxAttempts: 1 },
...restOptions,
+ triggerSource: "agent",
+ agentConfig: { type: "ai-sdk-chat" },
run: async (
payload: ChatTaskWirePayload>,
{ signal: runSignal, ctx }
) => {
- locals.set(chatTaskRunContextKey, ctx);
+ locals.set(chatAgentRunContextKey, ctx);
// Set gen_ai.conversation.id on the run-level span for dashboard context
const activeSpan = trace.getActiveSpan();
@@ -2552,8 +2556,8 @@ function chatTask<
if (compaction) {
locals.set(
- chatTaskCompactionKey,
- compaction as unknown as ChatTaskCompactionOptions
+ chatAgentCompactionKey,
+ compaction as unknown as ChatAgentCompactionOptions
);
}
@@ -2659,51 +2663,51 @@ function chatTask<
skipSuspend: exitAfterPreloadIdle,
onSuspend: onChatSuspend
? async () => {
- await tracer.startActiveSpan(
- "onChatSuspend()",
- async () => {
- await onChatSuspend({
- phase: "preload",
- ctx,
- chatId: payload.chatId,
- runId: currentRunId,
- clientData: preloadClientData,
- });
+ await tracer.startActiveSpan(
+ "onChatSuspend()",
+ async () => {
+ await onChatSuspend({
+ phase: "preload",
+ ctx,
+ chatId: payload.chatId,
+ runId: currentRunId,
+ clientData: preloadClientData,
+ });
+ },
+ {
+ attributes: {
+ [SemanticInternalAttributes.STYLE_ICON]: "task-hook-onComplete",
+ [SemanticInternalAttributes.COLLAPSED]: true,
+ "chat.id": payload.chatId,
+ "chat.suspend.phase": "preload",
},
- {
- attributes: {
- [SemanticInternalAttributes.STYLE_ICON]: "task-hook-onComplete",
- [SemanticInternalAttributes.COLLAPSED]: true,
- "chat.id": payload.chatId,
- "chat.suspend.phase": "preload",
- },
- }
- );
- }
+ }
+ );
+ }
: undefined,
onResume: onChatResume
? async () => {
- await tracer.startActiveSpan(
- "onChatResume()",
- async () => {
- await onChatResume({
- phase: "preload",
- ctx,
- chatId: payload.chatId,
- runId: currentRunId,
- clientData: preloadClientData,
- });
+ await tracer.startActiveSpan(
+ "onChatResume()",
+ async () => {
+ await onChatResume({
+ phase: "preload",
+ ctx,
+ chatId: payload.chatId,
+ runId: currentRunId,
+ clientData: preloadClientData,
+ });
+ },
+ {
+ attributes: {
+ [SemanticInternalAttributes.STYLE_ICON]: "task-hook-onStart",
+ [SemanticInternalAttributes.COLLAPSED]: true,
+ "chat.id": payload.chatId,
+ "chat.resume.phase": "preload",
},
- {
- attributes: {
- [SemanticInternalAttributes.STYLE_ICON]: "task-hook-onStart",
- [SemanticInternalAttributes.COLLAPSED]: true,
- "chat.id": payload.chatId,
- "chat.resume.phase": "preload",
- },
- }
- );
- }
+ }
+ );
+ }
: undefined,
});
@@ -2720,730 +2724,839 @@ function chatTask<
}
for (let turn = 0; turn < maxTurns; turn++) {
- // Extract turn-level context before entering the span
- const { metadata: wireMetadata, messages: uiMessages, ...restWire } = currentWirePayload;
- const clientData = (
- parseClientData ? await parseClientData(wireMetadata) : wireMetadata
- ) as inferSchemaOut;
- const lastUserMessage = extractLastUserMessageText(uiMessages);
+ try {
+ // Extract turn-level context before entering the span
+ const { metadata: wireMetadata, messages: uiMessages, ...restWire } = currentWirePayload;
+ const clientData = (
+ parseClientData ? await parseClientData(wireMetadata) : wireMetadata
+ ) as inferSchemaOut;
+ const lastUserMessage = extractLastUserMessageText(uiMessages);
- const turnAttributes: Attributes = {
- "turn.number": turn + 1,
- "gen_ai.conversation.id": currentWirePayload.chatId,
- "gen_ai.operation.name": "chat",
- "chat.trigger": currentWirePayload.trigger,
- [SemanticInternalAttributes.STYLE_ICON]: "tabler-message-chatbot",
- [SemanticInternalAttributes.ENTITY_TYPE]: "chat-turn",
- };
-
- if (lastUserMessage) {
- turnAttributes["chat.user_message"] = lastUserMessage;
-
- // Show a truncated preview of the user message as an accessory
- const preview =
- lastUserMessage.length > 80 ? lastUserMessage.slice(0, 80) + "..." : lastUserMessage;
- Object.assign(
- turnAttributes,
- accessoryAttributes({
- items: [{ text: preview, variant: "normal" }],
- style: "codepath",
- })
- );
- }
-
- if (wireMetadata !== undefined) {
- turnAttributes["chat.client_data"] =
- typeof wireMetadata === "string" ? wireMetadata : JSON.stringify(wireMetadata);
- }
-
- const turnResult = await tracer.startActiveSpan(
- `chat turn ${turn + 1}`,
- async (turnSpan) => {
- locals.set(chatPipeCountKey, 0);
- locals.set(chatDeferKey, new Set());
- locals.set(chatCompactionStateKey, undefined);
- locals.set(chatSteeringQueueKey, []);
- // NOTE: chatBackgroundQueueKey is NOT reset here — messages injected
- // by deferred work from the previous turn's onTurnComplete need to
- // survive into the next turn. The queue is drained before run().
- locals.set(chatInjectedMessageIdsKey, new Set());
-
- // Store chat context for auto-detection by task-tool subtasks (ai.toolExecute / legacy ai.tool)
- locals.set(chatTurnContextKey, {
- chatId: currentWirePayload.chatId,
- turn,
- continuation,
- clientData,
- });
-
- // Per-turn stop controller (reset each turn)
- const stopController = new AbortController();
- currentStopController = stopController;
- locals.set(chatStopControllerKey, stopController);
-
- // Three signals for the user's run function
- const stopSignal = stopController.signal;
- const cancelSignal = runSignal;
- const combinedSignal = AbortSignal.any([runSignal, stopController.signal]);
-
- // Buffer messages that arrive during streaming
- const pendingMessages: ChatTaskWirePayload<
- TUIMessage,
- inferSchemaIn
- >[] = [];
- const pmConfig = locals.get(chatPendingMessagesKey);
- const msgSub = messagesInput.on(async (msg) => {
- // If pendingMessages is configured, route to the steering queue
- // instead of the wire buffer. The frontend handles re-sending
- // non-injected messages via sendMessage on turn complete.
- if (pmConfig) {
- const lastUIMessage = msg.messages?.[msg.messages.length - 1];
- if (lastUIMessage) {
- if (pmConfig.onReceived) {
- try {
- await pmConfig.onReceived({
- message: lastUIMessage as TUIMessage,
- chatId: currentWirePayload.chatId,
- turn,
- });
- } catch {
- /* non-fatal */
- }
- }
-
- try {
- const queue = locals.get(chatSteeringQueueKey) ?? [];
- // Deduplicate by message ID — guards against double-sends
- if (
- lastUIMessage.id &&
- queue.some((e) => e.uiMessage.id === lastUIMessage.id)
- ) {
- return;
- }
- const modelMsgs = await toModelMessages([lastUIMessage]);
- queue.push({
- uiMessage: lastUIMessage as UIMessage,
- modelMessages: modelMsgs,
- });
- locals.set(chatSteeringQueueKey, queue);
- } catch {
- /* conversion failed — skip steering queue */
- }
- }
- return; // Don't add to wire buffer — frontend handles non-injected case
- }
-
- // No pendingMessages config — standard wire buffer for next turn
- pendingMessages.push(
- msg as ChatTaskWirePayload>
- );
- });
-
- // Clean up any incomplete tool parts in the incoming history.
- // When a previous run was stopped mid-tool-call, the frontend's
- // useChat state may still contain assistant messages with tool parts
- // in partial/input-available state. These cause API errors (e.g.
- // Anthropic requires every tool_use to have a matching tool_result).
- const cleanedUIMessages = uiMessages.map((msg) =>
- msg.role === "assistant" ? cleanupAbortedParts(msg) : msg
- );
-
- // Convert the incoming UIMessages to model messages and update the accumulator.
- // Turn 1: full history from the frontend → replaces the accumulator.
- // Turn 2+: only the new message(s) → appended to the accumulator.
- const incomingModelMessages = await toModelMessages(cleanedUIMessages);
-
- // Track new messages for this turn (user input + assistant response).
- const turnNewModelMessages: ModelMessage[] = [];
- const turnNewUIMessages: TUIMessage[] = [];
-
- if (turn === 0) {
- accumulatedMessages = incomingModelMessages;
- accumulatedUIMessages = [...cleanedUIMessages];
- // On first turn, the "new" messages are just the last user message
- // (the rest is history). We'll add the response after streaming.
- if (cleanedUIMessages.length > 0) {
- turnNewUIMessages.push(cleanedUIMessages[cleanedUIMessages.length - 1]!);
- const lastModel = incomingModelMessages[incomingModelMessages.length - 1];
- if (lastModel) turnNewModelMessages.push(lastModel);
- }
- } else if (currentWirePayload.trigger === "regenerate-message") {
- // Regenerate: frontend sent full history with last assistant message
- // removed. Reset the accumulator to match.
- accumulatedMessages = incomingModelMessages;
- accumulatedUIMessages = [...cleanedUIMessages];
- // No new user messages for regenerate — just the response (added below)
- } else {
- // Submit: frontend sent only the new user message(s). Append to accumulator.
- accumulatedMessages.push(...incomingModelMessages);
- accumulatedUIMessages.push(...cleanedUIMessages);
- turnNewModelMessages.push(...incomingModelMessages);
- turnNewUIMessages.push(...cleanedUIMessages);
- }
-
- // Mint a scoped public access token once per turn, reused for
- // onChatStart, onTurnStart, onTurnComplete, and the turn-complete chunk.
- const currentRunId = ctx.run.id;
- let turnAccessToken = "";
- if (currentRunId) {
- try {
- turnAccessToken = await auth.createPublicToken({
- scopes: {
- read: { runs: currentRunId },
- write: { inputStreams: currentRunId },
- },
- expirationTime: chatAccessTokenTTL,
- });
- } catch {
- // Token creation failed
- }
- }
-
- // Fire onChatStart on the first turn
- if (turn === 0 && onChatStart) {
- await tracer.startActiveSpan(
- "onChatStart()",
- async () => {
- await withChatWriter(async (writer) => {
- await onChatStart({
- ctx,
- chatId: currentWirePayload.chatId,
- messages: accumulatedMessages,
- clientData,
- runId: currentRunId,
- chatAccessToken: turnAccessToken,
- continuation,
- previousRunId,
- preloaded,
- writer,
- });
- });
- },
- {
- attributes: {
- [SemanticInternalAttributes.STYLE_ICON]: "task-hook-onStart",
- [SemanticInternalAttributes.COLLAPSED]: true,
- "chat.id": currentWirePayload.chatId,
- "chat.messages.count": accumulatedMessages.length,
- "chat.continuation": continuation,
- "chat.preloaded": preloaded,
- ...(previousRunId ? { "chat.previous_run_id": previousRunId } : {}),
- },
- }
- );
- }
-
- // Fire onTurnStart before running user code — persist messages
- // so a mid-stream page refresh still shows the user's message.
- if (onTurnStart) {
- await tracer.startActiveSpan(
- "onTurnStart()",
- async () => {
- await withChatWriter(async (writer) => {
- await onTurnStart({
- ctx,
- chatId: currentWirePayload.chatId,
- messages: accumulatedMessages,
- uiMessages: accumulatedUIMessages,
- turn,
- runId: currentRunId,
- chatAccessToken: turnAccessToken,
- clientData,
- continuation,
- previousRunId,
- preloaded,
- previousTurnUsage,
- totalUsage: cumulativeUsage,
- writer,
- });
- });
-
- // Check if onTurnStart replaced messages (compaction)
- const turnStartOverride = locals.get(chatOverrideMessagesKey);
- if (turnStartOverride) {
- locals.set(chatOverrideMessagesKey, undefined);
- accumulatedUIMessages = [...turnStartOverride] as TUIMessage[];
- accumulatedMessages = await toModelMessages(turnStartOverride);
- }
- },
- {
- attributes: {
- [SemanticInternalAttributes.STYLE_ICON]: "task-hook-onStart",
- [SemanticInternalAttributes.COLLAPSED]: true,
- "chat.id": currentWirePayload.chatId,
- "chat.turn": turn + 1,
- "chat.messages.count": accumulatedMessages.length,
- "chat.trigger": currentWirePayload.trigger,
- "chat.continuation": continuation,
- "chat.preloaded": preloaded,
- ...(previousRunId ? { "chat.previous_run_id": previousRunId } : {}),
- },
- }
- );
- }
-
- // Captured by the onFinish callback below — works even on abort/stop.
- let capturedResponseMessage: TUIMessage | undefined;
-
- // Promise that resolves when the AI SDK's onFinish fires.
- // On abort, the stream's cancel() handler calls onFinish
- // asynchronously AFTER pipeChat resolves, so we must await
- // this to avoid a race where we check capturedResponseMessage
- // before it's been set.
- let resolveOnFinish: () => void;
- const onFinishPromise = new Promise((r) => {
- resolveOnFinish = r;
- });
- let onFinishAttached = false;
- let runResult: unknown;
-
- try {
- // Drain any messages injected by background work (e.g. self-review from previous turn)
- const bgQueue = locals.get(chatBackgroundQueueKey);
- if (bgQueue && bgQueue.length > 0) {
- accumulatedMessages.push(...bgQueue.splice(0));
- }
-
- runResult = await userRun({
- ...restWire,
- messages: await applyPrepareMessages(accumulatedMessages, "run"),
- clientData,
- continuation,
- previousRunId,
- preloaded,
- previousTurnUsage,
- totalUsage: cumulativeUsage,
- ctx,
- signal: combinedSignal,
- cancelSignal,
- stopSignal,
- } as any);
-
- // Auto-pipe if the run function returned a StreamTextResult or similar,
- // but only if pipeChat() wasn't already called manually during this turn.
- // We call toUIMessageStream ourselves to attach onFinish for response capture.
- if ((locals.get(chatPipeCountKey) ?? 0) === 0 && isUIMessageStreamable(runResult)) {
- onFinishAttached = true;
- const uiStream = runResult.toUIMessageStream({
- ...resolveUIMessageStreamOptions(),
- onFinish: ({ responseMessage }: { responseMessage: UIMessage }) => {
- capturedResponseMessage = responseMessage as TUIMessage;
- resolveOnFinish!();
- },
- });
- await pipeChat(uiStream, { signal: combinedSignal, spanName: "stream response" });
- }
- } catch (error) {
- // Handle AbortError from streamText gracefully
- if (error instanceof Error && error.name === "AbortError") {
- if (runSignal.aborted) {
- return "exit"; // Full run cancellation — exit
- }
- // Stop generation — fall through to continue the loop
- } else {
- throw error;
- }
- } finally {
- msgSub.off();
- }
-
- // Wait for onFinish to fire — on abort this may resolve slightly
- // after pipeChat, since the stream's cancel() handler is async.
- if (onFinishAttached) {
- await onFinishPromise;
- }
-
- // Capture token usage from the streamText result (if available).
- // totalUsage is a PromiseLike that resolves after the stream is consumed.
- let turnUsage: LanguageModelUsage | undefined;
- if (runResult != null && typeof (runResult as any).totalUsage?.then === "function") {
- try {
- turnUsage = await (runResult as any).totalUsage;
- } catch {
- /* non-fatal — usage capture failed */
- }
- }
- if (turnUsage) {
- cumulativeUsage = addUsage(cumulativeUsage, turnUsage);
- previousTurnUsage = turnUsage;
-
- // Add usage attributes to the turn span
- if (turnUsage.inputTokens != null) {
- turnSpan.setAttribute("gen_ai.usage.input_tokens", turnUsage.inputTokens);
- }
- if (turnUsage.outputTokens != null) {
- turnSpan.setAttribute("gen_ai.usage.output_tokens", turnUsage.outputTokens);
- }
- if (turnUsage.totalTokens != null) {
- turnSpan.setAttribute("gen_ai.usage.total_tokens", turnUsage.totalTokens);
- }
- if (cumulativeUsage.totalTokens != null) {
- turnSpan.setAttribute(
- "gen_ai.usage.cumulative_total_tokens",
- cumulativeUsage.totalTokens
- );
- }
- if (cumulativeUsage.inputTokens != null) {
- turnSpan.setAttribute(
- "gen_ai.usage.cumulative_input_tokens",
- cumulativeUsage.inputTokens
- );
- }
- if (cumulativeUsage.outputTokens != null) {
- turnSpan.setAttribute(
- "gen_ai.usage.cumulative_output_tokens",
- cumulativeUsage.outputTokens
- );
- }
- }
-
- // Check if run() (e.g. via prepareStep) replaced messages during this turn.
- // This supports intra-turn compaction — the compacted messages become the
- // new base, and the response gets appended on top.
- const runOverride = locals.get(chatOverrideMessagesKey);
- if (runOverride) {
- locals.set(chatOverrideMessagesKey, undefined);
- accumulatedUIMessages = [...runOverride] as TUIMessage[];
- accumulatedMessages = await toModelMessages(runOverride);
- }
-
- // Check if compaction set a model-only override (preserves UI messages).
- // Apply compactUIMessages/compactModelMessages callbacks if configured.
- const modelOnlyOverride = locals.get(chatOverrideModelMessagesKey);
- if (modelOnlyOverride) {
- const compactionSummary = locals.get(chatCompactionStateKey)?.summary ?? "";
- const taskCompactionConfig = locals.get(chatTaskCompactionKey);
- locals.set(chatOverrideModelMessagesKey, undefined);
-
- const compactEvent: CompactMessagesEvent = {
- summary: compactionSummary,
- uiMessages: accumulatedUIMessages,
- modelMessages: accumulatedMessages,
- chatId: currentWirePayload.chatId,
- turn,
- clientData,
- source: "inner",
- };
-
- // Apply model messages: callback or default (use override)
- accumulatedMessages = taskCompactionConfig?.compactModelMessages
- ? await taskCompactionConfig.compactModelMessages(compactEvent)
- : modelOnlyOverride;
-
- // Apply UI messages: callback or default (preserve all)
- if (taskCompactionConfig?.compactUIMessages) {
- accumulatedUIMessages = (await taskCompactionConfig.compactUIMessages(
- compactEvent
- )) as TUIMessage[];
- }
- }
-
- // Determine if the user stopped generation this turn (not a full run cancel).
- const wasStopped = stopController.signal.aborted && !runSignal.aborted;
-
- // Append the assistant's response (partial or complete) to the accumulator.
- // The onFinish callback fires even on abort/stop, so partial responses
- // from stopped generation are captured correctly.
- let rawResponseMessage: TUIMessage | undefined;
- if (capturedResponseMessage) {
- // Keep the raw message before cleanup for users who want custom handling
- rawResponseMessage = capturedResponseMessage;
- // Clean up aborted parts (streaming tool calls, reasoning) when stopped
- if (wasStopped) {
- capturedResponseMessage = cleanupAbortedParts(capturedResponseMessage);
- }
- // Ensure the response message has an ID (the stream's onFinish
- // may produce a message with an empty ID since IDs are normally
- // assigned by the frontend's useChat).
- if (!capturedResponseMessage.id) {
- capturedResponseMessage = { ...capturedResponseMessage, id: generateMessageId() };
- }
- accumulatedUIMessages.push(capturedResponseMessage);
- turnNewUIMessages.push(capturedResponseMessage);
- try {
- const responseModelMessages = await toModelMessages([
- stripProviderMetadata(capturedResponseMessage),
- ]);
- accumulatedMessages.push(...responseModelMessages);
- turnNewModelMessages.push(...responseModelMessages);
- } catch {
- // Conversion failed — skip accumulation for this turn
- }
- }
- // TODO: When the user calls `pipeChat` manually instead of returning a
- // StreamTextResult, we don't have access to onFinish. A future iteration
- // should let manual-mode users report back response messages for
- // accumulation (e.g. via a `chat.addMessages()` helper).
-
- if (runSignal.aborted) return "exit";
-
- // Await deferred background work (e.g. DB writes from onTurnStart)
- // before firing hooks so they can rely on the work being done.
- const deferredWork = locals.get(chatDeferKey);
- if (deferredWork && deferredWork.size > 0) {
- await Promise.race([
- Promise.allSettled(deferredWork),
- new Promise((r) => setTimeout(r, 5_000)),
- ]);
- }
-
- // Outer-loop compaction: runs between turns for single-step responses
- // where prepareStep never fires (no tool calls = no step boundaries).
- // Only triggers when: task has compaction configured, prepareStep didn't
- // already compact this turn, and shouldCompact returns true.
- const outerCompaction = locals.get(chatTaskCompactionKey);
- const innerCompactionState = locals.get(chatCompactionStateKey);
-
- if (outerCompaction && !innerCompactionState && turnUsage && !wasStopped) {
- const shouldTrigger = await outerCompaction.shouldCompact({
- messages: accumulatedMessages,
- totalTokens: turnUsage.totalTokens,
- inputTokens: turnUsage.inputTokens,
- outputTokens: turnUsage.outputTokens,
- usage: turnUsage,
- totalUsage: cumulativeUsage,
- chatId: currentWirePayload.chatId,
- turn,
- clientData,
- source: "outer",
- });
-
- if (shouldTrigger) {
- await tracer.startActiveSpan(
- "context compaction (outer loop)",
- async (compactionSpan) => {
- const compactionId = generateMessageId();
-
- const { waitUntilComplete } = streams.writer(CHAT_STREAM_KEY, {
- spanName: "stream compaction chunks",
- collapsed: true,
- execute: async ({ write, merge }) => {
- write({
- type: "data-compaction",
- id: compactionId,
- data: { status: "compacting", totalTokens: turnUsage.totalTokens },
- });
-
- const summary = await outerCompaction.summarize({
- messages: accumulatedMessages,
- usage: turnUsage,
- totalUsage: cumulativeUsage,
- chatId: currentWirePayload.chatId,
- turn,
- clientData,
- source: "outer",
- });
-
- // Apply compactModelMessages/compactUIMessages callbacks, or defaults.
-
- const outerCompactEvent: CompactMessagesEvent = {
- summary,
- uiMessages: accumulatedUIMessages,
- modelMessages: accumulatedMessages,
- chatId: currentWirePayload.chatId,
- turn,
- clientData,
- source: "outer",
- };
-
- // Model messages: callback or default (replace with summary)
- accumulatedMessages = outerCompaction.compactModelMessages
- ? await outerCompaction.compactModelMessages(outerCompactEvent)
- : [
- {
- role: "assistant" as const,
- content: [
- {
- type: "text" as const,
- text: `[Conversation summary]\n\n${summary}`,
- },
- ],
- },
- ];
-
- // UI messages: callback or default (preserve all)
- if (outerCompaction.compactUIMessages) {
- accumulatedUIMessages = (await outerCompaction.compactUIMessages(
- outerCompactEvent
- )) as TUIMessage[];
- }
-
- // Fire onCompacted hook
- const onCompactedHook = locals.get(chatOnCompactedKey);
- if (onCompactedHook) {
- await onCompactedHook({
- ctx,
- summary,
- messages: accumulatedMessages,
- messageCount: accumulatedMessages.length,
- usage: turnUsage,
- totalTokens: turnUsage.totalTokens,
- inputTokens: turnUsage.inputTokens,
- outputTokens: turnUsage.outputTokens,
- stepNumber: -1, // outer loop, not a step
- chatId: currentWirePayload.chatId,
- turn,
- writer: { write, merge },
- });
- }
-
- compactionSpan.setAttribute("compaction.summary_length", summary.length);
-
- write({
- type: "data-compaction",
- id: compactionId,
- data: { status: "complete", totalTokens: turnUsage.totalTokens },
- });
- },
- });
- await waitUntilComplete();
- },
- {
- attributes: {
- [SemanticInternalAttributes.STYLE_ICON]: "tabler-scissors",
- "compaction.total_tokens": turnUsage.totalTokens ?? 0,
- "compaction.input_tokens": turnUsage.inputTokens ?? 0,
- "compaction.message_count": accumulatedMessages.length,
- "compaction.outer_loop": true,
- "compaction.turn": turn,
- ...(currentWirePayload.chatId
- ? { "compaction.chat_id": currentWirePayload.chatId }
- : {}),
- ...accessoryAttributes({
- items: [
- { text: `${turnUsage.totalTokens ?? 0} tokens`, variant: "normal" },
- { text: `${accumulatedMessages.length} msgs`, variant: "normal" },
- { text: "outer loop", variant: "normal" },
- ],
- style: "codepath",
- }),
- },
- }
- );
- }
- }
-
- const turnCompleteEvent = {
- ctx,
- chatId: currentWirePayload.chatId,
- messages: accumulatedMessages,
- uiMessages: accumulatedUIMessages,
- newMessages: turnNewModelMessages,
- newUIMessages: turnNewUIMessages,
- responseMessage: capturedResponseMessage,
- rawResponseMessage,
- turn,
- runId: currentRunId,
- chatAccessToken: turnAccessToken,
- clientData,
- stopped: wasStopped,
- continuation,
- previousRunId,
- preloaded,
- usage: turnUsage,
- totalUsage: cumulativeUsage,
+ const turnAttributes: Attributes = {
+ "turn.number": turn + 1,
+ "gen_ai.conversation.id": currentWirePayload.chatId,
+ "gen_ai.operation.name": "chat",
+ "chat.trigger": currentWirePayload.trigger,
+ [SemanticInternalAttributes.STYLE_ICON]: "tabler-message-chatbot",
+ [SemanticInternalAttributes.ENTITY_TYPE]: "chat-turn",
};
- // Fire onBeforeTurnComplete — stream is still open so the hook
- // can write custom chunks to the frontend (e.g. compaction progress).
- if (onBeforeTurnComplete) {
- await tracer.startActiveSpan(
- "onBeforeTurnComplete()",
- async () => {
- await withChatWriter(async (writer) => {
- await onBeforeTurnComplete({ ...turnCompleteEvent, writer });
- });
+ if (lastUserMessage) {
+ turnAttributes["chat.user_message"] = lastUserMessage;
- // Check if the hook replaced messages (compaction)
- const override = locals.get(chatOverrideMessagesKey);
- if (override) {
- locals.set(chatOverrideMessagesKey, undefined);
- accumulatedUIMessages = [...override] as TUIMessage[];
- accumulatedMessages = await toModelMessages(override);
- // Update event so onTurnComplete sees compacted messages
- turnCompleteEvent.messages = accumulatedMessages;
- turnCompleteEvent.uiMessages = accumulatedUIMessages;
- }
- },
- {
- attributes: {
- [SemanticInternalAttributes.STYLE_ICON]: "task-hook-onComplete",
- [SemanticInternalAttributes.COLLAPSED]: true,
- "chat.id": currentWirePayload.chatId,
- "chat.turn": turn + 1,
- },
- }
+ // Show a truncated preview of the user message as an accessory
+ const preview =
+ lastUserMessage.length > 80 ? lastUserMessage.slice(0, 80) + "..." : lastUserMessage;
+ Object.assign(
+ turnAttributes,
+ accessoryAttributes({
+ items: [{ text: preview, variant: "normal" }],
+ style: "codepath",
+ })
);
}
- // Write turn-complete control chunk — closes the frontend stream.
- const turnCompleteResult = await writeTurnCompleteChunk(
- currentWirePayload.chatId,
- turnAccessToken
+ if (wireMetadata !== undefined) {
+ turnAttributes["chat.client_data"] =
+ typeof wireMetadata === "string" ? wireMetadata : JSON.stringify(wireMetadata);
+ }
+
+ const turnResult = await tracer.startActiveSpan(
+ `chat turn ${turn + 1}`,
+ async (turnSpan) => {
+ // (errors are caught by the outer try/catch which writes an error chunk)
+ locals.set(chatPipeCountKey, 0);
+ locals.set(chatDeferKey, new Set());
+ locals.set(chatCompactionStateKey, undefined);
+ locals.set(chatSteeringQueueKey, []);
+ // NOTE: chatBackgroundQueueKey is NOT reset here — messages injected
+ // by deferred work from the previous turn's onTurnComplete need to
+ // survive into the next turn. The queue is drained before run().
+ locals.set(chatInjectedMessageIdsKey, new Set());
+
+ // Store chat context for auto-detection by task-tool subtasks (ai.toolExecute / legacy ai.tool)
+ locals.set(chatTurnContextKey, {
+ chatId: currentWirePayload.chatId,
+ turn,
+ continuation,
+ clientData,
+ });
+
+ // Per-turn stop controller (reset each turn)
+ const stopController = new AbortController();
+ currentStopController = stopController;
+ locals.set(chatStopControllerKey, stopController);
+
+ // Three signals for the user's run function
+ const stopSignal = stopController.signal;
+ const cancelSignal = runSignal;
+ const combinedSignal = AbortSignal.any([runSignal, stopController.signal]);
+
+ // Buffer messages that arrive during streaming
+ const pendingMessages: ChatTaskWirePayload<
+ TUIMessage,
+ inferSchemaIn
+ >[] = [];
+ const pmConfig = locals.get(chatPendingMessagesKey);
+ const msgSub = messagesInput.on(async (msg) => {
+ // If pendingMessages is configured, route to the steering queue
+ // instead of the wire buffer. The frontend handles re-sending
+ // non-injected messages via sendMessage on turn complete.
+ if (pmConfig) {
+ const lastUIMessage = msg.messages?.[msg.messages.length - 1];
+ if (lastUIMessage) {
+ if (pmConfig.onReceived) {
+ try {
+ await pmConfig.onReceived({
+ message: lastUIMessage as TUIMessage,
+ chatId: currentWirePayload.chatId,
+ turn,
+ });
+ } catch {
+ /* non-fatal */
+ }
+ }
+
+ try {
+ const queue = locals.get(chatSteeringQueueKey) ?? [];
+ // Deduplicate by message ID — guards against double-sends
+ if (
+ lastUIMessage.id &&
+ queue.some((e) => e.uiMessage.id === lastUIMessage.id)
+ ) {
+ return;
+ }
+ const modelMsgs = await toModelMessages([lastUIMessage]);
+ queue.push({
+ uiMessage: lastUIMessage as UIMessage,
+ modelMessages: modelMsgs,
+ });
+ locals.set(chatSteeringQueueKey, queue);
+ } catch {
+ /* conversion failed — skip steering queue */
+ }
+ }
+ return; // Don't add to wire buffer — frontend handles non-injected case
+ }
+
+ // No pendingMessages config — standard wire buffer for next turn
+ pendingMessages.push(
+ msg as ChatTaskWirePayload>
+ );
+ });
+
+ // Clean up any incomplete tool parts in the incoming history.
+ // When a previous run was stopped mid-tool-call, the frontend's
+ // useChat state may still contain assistant messages with tool parts
+ // in partial/input-available state. These cause API errors (e.g.
+ // Anthropic requires every tool_use to have a matching tool_result).
+ const cleanedUIMessages = uiMessages.map((msg) =>
+ msg.role === "assistant" ? cleanupAbortedParts(msg) : msg
+ );
+
+ // Convert the incoming UIMessages to model messages and update the accumulator.
+ // Turn 1: full history from the frontend → replaces the accumulator.
+ // Turn 2+: only the new message(s) → appended to the accumulator.
+ const incomingModelMessages = await toModelMessages(cleanedUIMessages);
+
+ // Track new messages for this turn (user input + assistant response).
+ const turnNewModelMessages: ModelMessage[] = [];
+ const turnNewUIMessages: TUIMessage[] = [];
+
+ if (turn === 0) {
+ accumulatedMessages = incomingModelMessages;
+ accumulatedUIMessages = [...cleanedUIMessages];
+ // On first turn, the "new" messages are just the last user message
+ // (the rest is history). We'll add the response after streaming.
+ if (cleanedUIMessages.length > 0) {
+ turnNewUIMessages.push(cleanedUIMessages[cleanedUIMessages.length - 1]!);
+ const lastModel = incomingModelMessages[incomingModelMessages.length - 1];
+ if (lastModel) turnNewModelMessages.push(lastModel);
+ }
+ } else if (currentWirePayload.trigger === "regenerate-message") {
+ // Regenerate: frontend sent full history with last assistant message
+ // removed. Reset the accumulator to match.
+ accumulatedMessages = incomingModelMessages;
+ accumulatedUIMessages = [...cleanedUIMessages];
+ // No new user messages for regenerate — just the response (added below)
+ } else {
+ // Submit: frontend sent only the new user message(s). Append to accumulator.
+ accumulatedMessages.push(...incomingModelMessages);
+ accumulatedUIMessages.push(...cleanedUIMessages);
+ turnNewModelMessages.push(...incomingModelMessages);
+ turnNewUIMessages.push(...cleanedUIMessages);
+ }
+
+ // Mint a scoped public access token once per turn, reused for
+ // onChatStart, onTurnStart, onTurnComplete, and the turn-complete chunk.
+ const currentRunId = ctx.run.id;
+ let turnAccessToken = "";
+ if (currentRunId) {
+ try {
+ turnAccessToken = await auth.createPublicToken({
+ scopes: {
+ read: { runs: currentRunId },
+ write: { inputStreams: currentRunId },
+ },
+ expirationTime: chatAccessTokenTTL,
+ });
+ } catch {
+ // Token creation failed
+ }
+ }
+
+ // Fire onChatStart on the first turn
+ if (turn === 0 && onChatStart) {
+ await tracer.startActiveSpan(
+ "onChatStart()",
+ async () => {
+ await withChatWriter(async (writer) => {
+ await onChatStart({
+ ctx,
+ chatId: currentWirePayload.chatId,
+ messages: accumulatedMessages,
+ clientData,
+ runId: currentRunId,
+ chatAccessToken: turnAccessToken,
+ continuation,
+ previousRunId,
+ preloaded,
+ writer,
+ });
+ });
+ },
+ {
+ attributes: {
+ [SemanticInternalAttributes.STYLE_ICON]: "task-hook-onStart",
+ [SemanticInternalAttributes.COLLAPSED]: true,
+ "chat.id": currentWirePayload.chatId,
+ "chat.messages.count": accumulatedMessages.length,
+ "chat.continuation": continuation,
+ "chat.preloaded": preloaded,
+ ...(previousRunId ? { "chat.previous_run_id": previousRunId } : {}),
+ },
+ }
+ );
+ }
+
+ // Fire onTurnStart before running user code — persist messages
+ // so a mid-stream page refresh still shows the user's message.
+ if (onTurnStart) {
+ await tracer.startActiveSpan(
+ "onTurnStart()",
+ async () => {
+ await withChatWriter(async (writer) => {
+ await onTurnStart({
+ ctx,
+ chatId: currentWirePayload.chatId,
+ messages: accumulatedMessages,
+ uiMessages: accumulatedUIMessages,
+ turn,
+ runId: currentRunId,
+ chatAccessToken: turnAccessToken,
+ clientData,
+ continuation,
+ previousRunId,
+ preloaded,
+ previousTurnUsage,
+ totalUsage: cumulativeUsage,
+ writer,
+ });
+ });
+
+ // Check if onTurnStart replaced messages (compaction)
+ const turnStartOverride = locals.get(chatOverrideMessagesKey);
+ if (turnStartOverride) {
+ locals.set(chatOverrideMessagesKey, undefined);
+ accumulatedUIMessages = [...turnStartOverride] as TUIMessage[];
+ accumulatedMessages = await toModelMessages(turnStartOverride);
+ }
+ },
+ {
+ attributes: {
+ [SemanticInternalAttributes.STYLE_ICON]: "task-hook-onStart",
+ [SemanticInternalAttributes.COLLAPSED]: true,
+ "chat.id": currentWirePayload.chatId,
+ "chat.turn": turn + 1,
+ "chat.messages.count": accumulatedMessages.length,
+ "chat.trigger": currentWirePayload.trigger,
+ "chat.continuation": continuation,
+ "chat.preloaded": preloaded,
+ ...(previousRunId ? { "chat.previous_run_id": previousRunId } : {}),
+ },
+ }
+ );
+ }
+
+ // Captured by the onFinish callback below — works even on abort/stop.
+ let capturedResponseMessage: TUIMessage | undefined;
+
+ // Promise that resolves when the AI SDK's onFinish fires.
+ // On abort, the stream's cancel() handler calls onFinish
+ // asynchronously AFTER pipeChat resolves, so we must await
+ // this to avoid a race where we check capturedResponseMessage
+ // before it's been set.
+ let resolveOnFinish: () => void;
+ const onFinishPromise = new Promise((r) => {
+ resolveOnFinish = r;
+ });
+ let onFinishAttached = false;
+ let runResult: unknown;
+
+ try {
+ // Drain any messages injected by background work (e.g. self-review from previous turn)
+ const bgQueue = locals.get(chatBackgroundQueueKey);
+ if (bgQueue && bgQueue.length > 0) {
+ accumulatedMessages.push(...bgQueue.splice(0));
+ }
+
+ runResult = await userRun({
+ ...restWire,
+ messages: await applyPrepareMessages(accumulatedMessages, "run"),
+ clientData,
+ continuation,
+ previousRunId,
+ preloaded,
+ previousTurnUsage,
+ totalUsage: cumulativeUsage,
+ ctx,
+ signal: combinedSignal,
+ cancelSignal,
+ stopSignal,
+ } as any);
+
+ // Auto-pipe if the run function returned a StreamTextResult or similar,
+ // but only if pipeChat() wasn't already called manually during this turn.
+ // We call toUIMessageStream ourselves to attach onFinish for response capture.
+ if ((locals.get(chatPipeCountKey) ?? 0) === 0 && isUIMessageStreamable(runResult)) {
+ onFinishAttached = true;
+ const uiStream = runResult.toUIMessageStream({
+ ...resolveUIMessageStreamOptions(),
+ onFinish: ({ responseMessage }: { responseMessage: UIMessage }) => {
+ capturedResponseMessage = responseMessage as TUIMessage;
+ resolveOnFinish!();
+ },
+ });
+ await pipeChat(uiStream, { signal: combinedSignal, spanName: "stream response" });
+ }
+ } catch (error) {
+ // Handle AbortError from streamText gracefully
+ if (error instanceof Error && error.name === "AbortError") {
+ if (runSignal.aborted) {
+ return "exit"; // Full run cancellation — exit
+ }
+ // Stop generation — fall through to continue the loop
+ } else {
+ throw error;
+ }
+ } finally {
+ msgSub.off();
+ }
+
+ // Wait for onFinish to fire — on abort this may resolve slightly
+ // after pipeChat, since the stream's cancel() handler is async.
+ if (onFinishAttached) {
+ await onFinishPromise;
+ }
+
+ // Capture token usage from the streamText result (if available).
+ // totalUsage is a PromiseLike that resolves after the stream is consumed.
+ let turnUsage: LanguageModelUsage | undefined;
+ if (runResult != null && typeof (runResult as any).totalUsage?.then === "function") {
+ try {
+ turnUsage = await (runResult as any).totalUsage;
+ } catch {
+ /* non-fatal — usage capture failed */
+ }
+ }
+ if (turnUsage) {
+ cumulativeUsage = addUsage(cumulativeUsage, turnUsage);
+ previousTurnUsage = turnUsage;
+
+ // Add usage attributes to the turn span
+ if (turnUsage.inputTokens != null) {
+ turnSpan.setAttribute("gen_ai.usage.input_tokens", turnUsage.inputTokens);
+ }
+ if (turnUsage.outputTokens != null) {
+ turnSpan.setAttribute("gen_ai.usage.output_tokens", turnUsage.outputTokens);
+ }
+ if (turnUsage.totalTokens != null) {
+ turnSpan.setAttribute("gen_ai.usage.total_tokens", turnUsage.totalTokens);
+ }
+ if (cumulativeUsage.totalTokens != null) {
+ turnSpan.setAttribute(
+ "gen_ai.usage.cumulative_total_tokens",
+ cumulativeUsage.totalTokens
+ );
+ }
+ if (cumulativeUsage.inputTokens != null) {
+ turnSpan.setAttribute(
+ "gen_ai.usage.cumulative_input_tokens",
+ cumulativeUsage.inputTokens
+ );
+ }
+ if (cumulativeUsage.outputTokens != null) {
+ turnSpan.setAttribute(
+ "gen_ai.usage.cumulative_output_tokens",
+ cumulativeUsage.outputTokens
+ );
+ }
+ }
+
+ // Check if run() (e.g. via prepareStep) replaced messages during this turn.
+ // This supports intra-turn compaction — the compacted messages become the
+ // new base, and the response gets appended on top.
+ const runOverride = locals.get(chatOverrideMessagesKey);
+ if (runOverride) {
+ locals.set(chatOverrideMessagesKey, undefined);
+ accumulatedUIMessages = [...runOverride] as TUIMessage[];
+ accumulatedMessages = await toModelMessages(runOverride);
+ }
+
+ // Check if compaction set a model-only override (preserves UI messages).
+ // Apply compactUIMessages/compactModelMessages callbacks if configured.
+ const modelOnlyOverride = locals.get(chatOverrideModelMessagesKey);
+ if (modelOnlyOverride) {
+ const compactionSummary = locals.get(chatCompactionStateKey)?.summary ?? "";
+ const taskCompactionConfig = locals.get(chatAgentCompactionKey);
+ locals.set(chatOverrideModelMessagesKey, undefined);
+
+ const compactEvent: CompactMessagesEvent = {
+ summary: compactionSummary,
+ uiMessages: accumulatedUIMessages,
+ modelMessages: accumulatedMessages,
+ chatId: currentWirePayload.chatId,
+ turn,
+ clientData,
+ source: "inner",
+ };
+
+ // Apply model messages: callback or default (use override)
+ accumulatedMessages = taskCompactionConfig?.compactModelMessages
+ ? await taskCompactionConfig.compactModelMessages(compactEvent)
+ : modelOnlyOverride;
+
+ // Apply UI messages: callback or default (preserve all)
+ if (taskCompactionConfig?.compactUIMessages) {
+ accumulatedUIMessages = (await taskCompactionConfig.compactUIMessages(
+ compactEvent
+ )) as TUIMessage[];
+ }
+ }
+
+ // Determine if the user stopped generation this turn (not a full run cancel).
+ const wasStopped = stopController.signal.aborted && !runSignal.aborted;
+
+ // Append the assistant's response (partial or complete) to the accumulator.
+ // The onFinish callback fires even on abort/stop, so partial responses
+ // from stopped generation are captured correctly.
+ let rawResponseMessage: TUIMessage | undefined;
+ if (capturedResponseMessage) {
+ // Keep the raw message before cleanup for users who want custom handling
+ rawResponseMessage = capturedResponseMessage;
+ // Clean up aborted parts (streaming tool calls, reasoning) when stopped
+ if (wasStopped) {
+ capturedResponseMessage = cleanupAbortedParts(capturedResponseMessage);
+ }
+ // Ensure the response message has an ID (the stream's onFinish
+ // may produce a message with an empty ID since IDs are normally
+ // assigned by the frontend's useChat).
+ if (!capturedResponseMessage.id) {
+ capturedResponseMessage = { ...capturedResponseMessage, id: generateMessageId() };
+ }
+ accumulatedUIMessages.push(capturedResponseMessage);
+ turnNewUIMessages.push(capturedResponseMessage);
+ try {
+ const responseModelMessages = await toModelMessages([
+ stripProviderMetadata(capturedResponseMessage),
+ ]);
+ accumulatedMessages.push(...responseModelMessages);
+ turnNewModelMessages.push(...responseModelMessages);
+ } catch {
+ // Conversion failed — skip accumulation for this turn
+ }
+ }
+ // TODO: When the user calls `pipeChat` manually instead of returning a
+ // StreamTextResult, we don't have access to onFinish. A future iteration
+ // should let manual-mode users report back response messages for
+ // accumulation (e.g. via a `chat.addMessages()` helper).
+
+ if (runSignal.aborted) return "exit";
+
+ // Await deferred background work (e.g. DB writes from onTurnStart)
+ // before firing hooks so they can rely on the work being done.
+ const deferredWork = locals.get(chatDeferKey);
+ if (deferredWork && deferredWork.size > 0) {
+ await Promise.race([
+ Promise.allSettled(deferredWork),
+ new Promise((r) => setTimeout(r, 5_000)),
+ ]);
+ }
+
+ // Outer-loop compaction: runs between turns for single-step responses
+ // where prepareStep never fires (no tool calls = no step boundaries).
+ // Only triggers when: task has compaction configured, prepareStep didn't
+ // already compact this turn, and shouldCompact returns true.
+ const outerCompaction = locals.get(chatAgentCompactionKey);
+ const innerCompactionState = locals.get(chatCompactionStateKey);
+
+ if (outerCompaction && !innerCompactionState && turnUsage && !wasStopped) {
+ const shouldTrigger = await outerCompaction.shouldCompact({
+ messages: accumulatedMessages,
+ totalTokens: turnUsage.totalTokens,
+ inputTokens: turnUsage.inputTokens,
+ outputTokens: turnUsage.outputTokens,
+ usage: turnUsage,
+ totalUsage: cumulativeUsage,
+ chatId: currentWirePayload.chatId,
+ turn,
+ clientData,
+ source: "outer",
+ });
+
+ if (shouldTrigger) {
+ await tracer.startActiveSpan(
+ "context compaction (outer loop)",
+ async (compactionSpan) => {
+ const compactionId = generateMessageId();
+
+ const { waitUntilComplete } = streams.writer(CHAT_STREAM_KEY, {
+ spanName: "stream compaction chunks",
+ collapsed: true,
+ execute: async ({ write, merge }) => {
+ write({
+ type: "data-compaction",
+ id: compactionId,
+ data: { status: "compacting", totalTokens: turnUsage.totalTokens },
+ });
+
+ const summary = await outerCompaction.summarize({
+ messages: accumulatedMessages,
+ usage: turnUsage,
+ totalUsage: cumulativeUsage,
+ chatId: currentWirePayload.chatId,
+ turn,
+ clientData,
+ source: "outer",
+ });
+
+ // Apply compactModelMessages/compactUIMessages callbacks, or defaults.
+
+ const outerCompactEvent: CompactMessagesEvent = {
+ summary,
+ uiMessages: accumulatedUIMessages,
+ modelMessages: accumulatedMessages,
+ chatId: currentWirePayload.chatId,
+ turn,
+ clientData,
+ source: "outer",
+ };
+
+ // Model messages: callback or default (replace with summary)
+ accumulatedMessages = outerCompaction.compactModelMessages
+ ? await outerCompaction.compactModelMessages(outerCompactEvent)
+ : [
+ {
+ role: "assistant" as const,
+ content: [
+ {
+ type: "text" as const,
+ text: `[Conversation summary]\n\n${summary}`,
+ },
+ ],
+ },
+ ];
+
+ // UI messages: callback or default (preserve all)
+ if (outerCompaction.compactUIMessages) {
+ accumulatedUIMessages = (await outerCompaction.compactUIMessages(
+ outerCompactEvent
+ )) as TUIMessage[];
+ }
+
+ // Fire onCompacted hook
+ const onCompactedHook = locals.get(chatOnCompactedKey);
+ if (onCompactedHook) {
+ await onCompactedHook({
+ ctx,
+ summary,
+ messages: accumulatedMessages,
+ messageCount: accumulatedMessages.length,
+ usage: turnUsage,
+ totalTokens: turnUsage.totalTokens,
+ inputTokens: turnUsage.inputTokens,
+ outputTokens: turnUsage.outputTokens,
+ stepNumber: -1, // outer loop, not a step
+ chatId: currentWirePayload.chatId,
+ turn,
+ writer: { write, merge },
+ });
+ }
+
+ compactionSpan.setAttribute("compaction.summary_length", summary.length);
+
+ write({
+ type: "data-compaction",
+ id: compactionId,
+ data: { status: "complete", totalTokens: turnUsage.totalTokens },
+ });
+ },
+ });
+ await waitUntilComplete();
+ },
+ {
+ attributes: {
+ [SemanticInternalAttributes.STYLE_ICON]: "tabler-scissors",
+ "compaction.total_tokens": turnUsage.totalTokens ?? 0,
+ "compaction.input_tokens": turnUsage.inputTokens ?? 0,
+ "compaction.message_count": accumulatedMessages.length,
+ "compaction.outer_loop": true,
+ "compaction.turn": turn,
+ ...(currentWirePayload.chatId
+ ? { "compaction.chat_id": currentWirePayload.chatId }
+ : {}),
+ ...accessoryAttributes({
+ items: [
+ { text: `${turnUsage.totalTokens ?? 0} tokens`, variant: "normal" },
+ { text: `${accumulatedMessages.length} msgs`, variant: "normal" },
+ { text: "outer loop", variant: "normal" },
+ ],
+ style: "codepath",
+ }),
+ },
+ }
+ );
+ }
+ }
+
+ const turnCompleteEvent = {
+ ctx,
+ chatId: currentWirePayload.chatId,
+ messages: accumulatedMessages,
+ uiMessages: accumulatedUIMessages,
+ newMessages: turnNewModelMessages,
+ newUIMessages: turnNewUIMessages,
+ responseMessage: capturedResponseMessage,
+ rawResponseMessage,
+ turn,
+ runId: currentRunId,
+ chatAccessToken: turnAccessToken,
+ clientData,
+ stopped: wasStopped,
+ continuation,
+ previousRunId,
+ preloaded,
+ usage: turnUsage,
+ totalUsage: cumulativeUsage,
+ };
+
+ // Fire onBeforeTurnComplete — stream is still open so the hook
+ // can write custom chunks to the frontend (e.g. compaction progress).
+ if (onBeforeTurnComplete) {
+ await tracer.startActiveSpan(
+ "onBeforeTurnComplete()",
+ async () => {
+ await withChatWriter(async (writer) => {
+ await onBeforeTurnComplete({ ...turnCompleteEvent, writer });
+ });
+
+ // Check if the hook replaced messages (compaction)
+ const override = locals.get(chatOverrideMessagesKey);
+ if (override) {
+ locals.set(chatOverrideMessagesKey, undefined);
+ accumulatedUIMessages = [...override] as TUIMessage[];
+ accumulatedMessages = await toModelMessages(override);
+ // Update event so onTurnComplete sees compacted messages
+ turnCompleteEvent.messages = accumulatedMessages;
+ turnCompleteEvent.uiMessages = accumulatedUIMessages;
+ }
+ },
+ {
+ attributes: {
+ [SemanticInternalAttributes.STYLE_ICON]: "task-hook-onComplete",
+ [SemanticInternalAttributes.COLLAPSED]: true,
+ "chat.id": currentWirePayload.chatId,
+ "chat.turn": turn + 1,
+ },
+ }
+ );
+ }
+
+ // Write turn-complete control chunk — closes the frontend stream.
+ const turnCompleteResult = await writeTurnCompleteChunk(
+ currentWirePayload.chatId,
+ turnAccessToken
+ );
+
+ // Fire onTurnComplete — stream is closed, use for persistence.
+ if (onTurnComplete) {
+ await tracer.startActiveSpan(
+ "onTurnComplete()",
+ async () => {
+ await onTurnComplete({
+ ...turnCompleteEvent,
+ lastEventId: turnCompleteResult.lastEventId,
+ });
+
+ // Check if onTurnComplete replaced messages (compaction)
+ const turnCompleteOverride = locals.get(chatOverrideMessagesKey);
+ if (turnCompleteOverride) {
+ locals.set(chatOverrideMessagesKey, undefined);
+ accumulatedUIMessages = [...turnCompleteOverride] as TUIMessage[];
+ accumulatedMessages = await toModelMessages(turnCompleteOverride);
+ }
+ },
+ {
+ attributes: {
+ [SemanticInternalAttributes.STYLE_ICON]: "task-hook-onComplete",
+ [SemanticInternalAttributes.COLLAPSED]: true,
+ "chat.id": currentWirePayload.chatId,
+ "chat.turn": turn + 1,
+ "chat.stopped": wasStopped,
+ "chat.continuation": continuation,
+ "chat.preloaded": preloaded,
+ ...(previousRunId ? { "chat.previous_run_id": previousRunId } : {}),
+ "chat.messages.count": accumulatedMessages.length,
+ "chat.response.parts.count": capturedResponseMessage?.parts?.length ?? 0,
+ "chat.new_messages.count": turnNewUIMessages.length,
+ ...(turnUsage?.inputTokens != null
+ ? { "gen_ai.usage.input_tokens": turnUsage.inputTokens }
+ : {}),
+ ...(turnUsage?.outputTokens != null
+ ? { "gen_ai.usage.output_tokens": turnUsage.outputTokens }
+ : {}),
+ ...(turnUsage?.totalTokens != null
+ ? { "gen_ai.usage.total_tokens": turnUsage.totalTokens }
+ : {}),
+ ...(cumulativeUsage.totalTokens != null
+ ? { "gen_ai.usage.cumulative_total_tokens": cumulativeUsage.totalTokens }
+ : {}),
+ },
+ }
+ );
+ }
+
+ // NOTE: We intentionally do NOT await deferred work from onTurnComplete here.
+ // Promises deferred in onTurnComplete (e.g. background self-review via
+ // chat.defer + chat.inject) run during the idle wait. If they complete
+ // before the next message, their injected context is picked up in prepareStep.
+ // The pre-onBeforeTurnComplete drain handles promises from onTurnStart/run().
+
+ // If messages arrived during streaming (without pendingMessages config),
+ // use the first one immediately as the next turn.
+ if (pendingMessages.length > 0) {
+ currentWirePayload = pendingMessages[0]!;
+ return "continue";
+ }
+
+ // Wait for the next message — stay idle briefly, then suspend
+ const effectiveIdleTimeout =
+ (metadata.get(IDLE_TIMEOUT_METADATA_KEY) as number | undefined) ??
+ idleTimeoutInSeconds;
+ const effectiveTurnTimeout =
+ (metadata.get(TURN_TIMEOUT_METADATA_KEY) as string | undefined) ?? turnTimeout;
+
+ const next = await messagesInput.waitWithIdleTimeout({
+ idleTimeoutInSeconds: effectiveIdleTimeout,
+ timeout: effectiveTurnTimeout,
+ spanName: "waiting for next message",
+ onSuspend: onChatSuspend
+ ? async () => {
+ await tracer.startActiveSpan(
+ "onChatSuspend()",
+ async () => {
+ await onChatSuspend({
+ phase: "turn",
+ ctx,
+ chatId: currentWirePayload.chatId,
+ runId: ctx.run.id,
+ turn,
+ messages: accumulatedMessages,
+ uiMessages: accumulatedUIMessages,
+ clientData,
+ });
+ },
+ {
+ attributes: {
+ [SemanticInternalAttributes.STYLE_ICON]: "task-hook-onComplete",
+ [SemanticInternalAttributes.COLLAPSED]: true,
+ "chat.id": currentWirePayload.chatId,
+ "chat.suspend.phase": "turn",
+ "chat.turn": turn + 1,
+ },
+ }
+ );
+ }
+ : undefined,
+ onResume: onChatResume
+ ? async () => {
+ await tracer.startActiveSpan(
+ "onChatResume()",
+ async () => {
+ await onChatResume({
+ phase: "turn",
+ ctx,
+ chatId: currentWirePayload.chatId,
+ runId: ctx.run.id,
+ turn,
+ messages: accumulatedMessages,
+ uiMessages: accumulatedUIMessages,
+ clientData,
+ });
+ },
+ {
+ attributes: {
+ [SemanticInternalAttributes.STYLE_ICON]: "task-hook-onStart",
+ [SemanticInternalAttributes.COLLAPSED]: true,
+ "chat.id": currentWirePayload.chatId,
+ "chat.resume.phase": "turn",
+ "chat.turn": turn + 1,
+ },
+ }
+ );
+ }
+ : undefined,
+ });
+
+ if (!next.ok) {
+ return "exit";
+ }
+
+ currentWirePayload = next.output as ChatTaskWirePayload<
+ TUIMessage,
+ inferSchemaIn
+ >;
+ return "continue";
+ },
+ {
+ attributes: turnAttributes,
+ }
);
- // Fire onTurnComplete — stream is closed, use for persistence.
- if (onTurnComplete) {
- await tracer.startActiveSpan(
- "onTurnComplete()",
- async () => {
- await onTurnComplete({
- ...turnCompleteEvent,
- lastEventId: turnCompleteResult.lastEventId,
- });
-
- // Check if onTurnComplete replaced messages (compaction)
- const turnCompleteOverride = locals.get(chatOverrideMessagesKey);
- if (turnCompleteOverride) {
- locals.set(chatOverrideMessagesKey, undefined);
- accumulatedUIMessages = [...turnCompleteOverride] as TUIMessage[];
- accumulatedMessages = await toModelMessages(turnCompleteOverride);
- }
- },
- {
- attributes: {
- [SemanticInternalAttributes.STYLE_ICON]: "task-hook-onComplete",
- [SemanticInternalAttributes.COLLAPSED]: true,
- "chat.id": currentWirePayload.chatId,
- "chat.turn": turn + 1,
- "chat.stopped": wasStopped,
- "chat.continuation": continuation,
- "chat.preloaded": preloaded,
- ...(previousRunId ? { "chat.previous_run_id": previousRunId } : {}),
- "chat.messages.count": accumulatedMessages.length,
- "chat.response.parts.count": capturedResponseMessage?.parts?.length ?? 0,
- "chat.new_messages.count": turnNewUIMessages.length,
- ...(turnUsage?.inputTokens != null
- ? { "gen_ai.usage.input_tokens": turnUsage.inputTokens }
- : {}),
- ...(turnUsage?.outputTokens != null
- ? { "gen_ai.usage.output_tokens": turnUsage.outputTokens }
- : {}),
- ...(turnUsage?.totalTokens != null
- ? { "gen_ai.usage.total_tokens": turnUsage.totalTokens }
- : {}),
- ...(cumulativeUsage.totalTokens != null
- ? { "gen_ai.usage.cumulative_total_tokens": cumulativeUsage.totalTokens }
- : {}),
- },
- }
- );
+ if (turnResult === "exit") return;
+ // "continue" means proceed to next iteration
+ } catch (turnError) {
+ // Turn error handler: write an error chunk + turn-complete to the stream
+ // so the client sees the error, then wait for the next message instead
+ // of killing the entire run. This keeps the conversation alive.
+ if (turnError instanceof Error && turnError.name === "AbortError" && runSignal.aborted) {
+ // Full run cancellation — exit immediately
+ throw turnError;
}
- // NOTE: We intentionally do NOT await deferred work from onTurnComplete here.
- // Promises deferred in onTurnComplete (e.g. background self-review via
- // chat.defer + chat.inject) run during the idle wait. If they complete
- // before the next message, their injected context is picked up in prepareStep.
- // The pre-onBeforeTurnComplete drain handles promises from onTurnStart/run().
-
- // If messages arrived during streaming (without pendingMessages config),
- // use the first one immediately as the next turn.
- if (pendingMessages.length > 0) {
- currentWirePayload = pendingMessages[0]!;
- return "continue";
+ try {
+ await withChatWriter(async (writer) => {
+ const errorText =
+ turnError instanceof Error ? turnError.message : "An unexpected error occurred";
+ writer.write({ type: "error", errorText } as any);
+ });
+ // Signal turn complete so the client knows this turn is done
+ await writeTurnCompleteChunk(currentWirePayload.chatId);
+ } catch {
+ // Best-effort — if stream write fails, let the run continue anyway
}
- // Wait for the next message — stay idle briefly, then suspend
+ // Wait for the next message — same as after a successful turn
const effectiveIdleTimeout =
(metadata.get(IDLE_TIMEOUT_METADATA_KEY) as number | undefined) ??
idleTimeoutInSeconds;
@@ -3453,93 +3566,40 @@ function chatTask<
const next = await messagesInput.waitWithIdleTimeout({
idleTimeoutInSeconds: effectiveIdleTimeout,
timeout: effectiveTurnTimeout,
- spanName: "waiting for next message",
- onSuspend: onChatSuspend
- ? async () => {
- await tracer.startActiveSpan(
- "onChatSuspend()",
- async () => {
- await onChatSuspend({
- phase: "turn",
- ctx,
- chatId: currentWirePayload.chatId,
- runId: ctx.run.id,
- turn,
- messages: accumulatedMessages,
- uiMessages: accumulatedUIMessages,
- clientData,
- });
- },
- {
- attributes: {
- [SemanticInternalAttributes.STYLE_ICON]: "task-hook-onComplete",
- [SemanticInternalAttributes.COLLAPSED]: true,
- "chat.id": currentWirePayload.chatId,
- "chat.suspend.phase": "turn",
- "chat.turn": turn + 1,
- },
- }
- );
- }
- : undefined,
- onResume: onChatResume
- ? async () => {
- await tracer.startActiveSpan(
- "onChatResume()",
- async () => {
- await onChatResume({
- phase: "turn",
- ctx,
- chatId: currentWirePayload.chatId,
- runId: ctx.run.id,
- turn,
- messages: accumulatedMessages,
- uiMessages: accumulatedUIMessages,
- clientData,
- });
- },
- {
- attributes: {
- [SemanticInternalAttributes.STYLE_ICON]: "task-hook-onStart",
- [SemanticInternalAttributes.COLLAPSED]: true,
- "chat.id": currentWirePayload.chatId,
- "chat.resume.phase": "turn",
- "chat.turn": turn + 1,
- },
- }
- );
- }
- : undefined,
+ spanName: "waiting for next message (after error)",
});
if (!next.ok) {
- return "exit";
+ return; // Timed out — end run gracefully
}
currentWirePayload = next.output as ChatTaskWirePayload<
TUIMessage,
inferSchemaIn
>;
- return "continue";
- },
- {
- attributes: turnAttributes,
+ // Continue to next iteration of the for loop
}
- );
-
- if (turnResult === "exit") return;
- // "continue" means proceed to next iteration
+ }
+ } finally {
+ stopSub.off();
}
- } finally {
- stopSub.off();
- }
- },
+ }
});
+
+ // Register clientDataSchema so the CLI converts it to JSONSchema
+ // and stores it as payloadSchema — used by the Playground UI
+ if (clientDataSchema) {
+ resourceCatalog.updateTaskMetadata(options.id, {
+ schema: clientDataSchema as any,
+ });
+ }
+
+ return task;
}
/**
* Optional config for {@link chat.withUIMessage}. `streamOptions` become default
- * static `toUIMessageStream()` settings; inner `chat.task({ uiMessageStreamOptions })`
+ * static `toUIMessageStream()` settings; inner `chat.agent({ uiMessageStreamOptions })`
* shallow-merges on top (task wins on conflicts).
*/
export type ChatWithUIMessageConfig = {
@@ -3631,19 +3691,19 @@ export interface ChatBuilder<
): ChatBuilder;
/**
- * Create the chat task with the accumulated builder configuration.
+ * Create the chat agent with the accumulated builder configuration.
*
* When `withClientData` was called, `clientDataSchema` is injected automatically
* and omitted from options. Otherwise, it can still be set directly in options
* (backwards compatible).
*/
- task: [TClientDataSchema] extends [undefined]
- ? (
- options: ChatTaskOptions
- ) => Task>, unknown>
- : (
- options: Omit, "clientDataSchema">
- ) => Task>, unknown>;
+ agent: [TClientDataSchema] extends [undefined]
+ ? (
+ options: ChatAgentOptions
+ ) => Task>, unknown>
+ : (
+ options: Omit, "clientDataSchema">
+ ) => Task>, unknown>;
}
/** @internal */
@@ -3769,13 +3829,13 @@ function createChatBuilder<
});
},
- task(options: any) {
+ agent(options: any) {
const mergedUiStream =
config.uiStreamOptions && options.uiMessageStreamOptions
? { ...config.uiStreamOptions, ...options.uiMessageStreamOptions }
: options.uiMessageStreamOptions ?? config.uiStreamOptions;
- return chatTask({
+ return chatAgent({
...options,
...(config.clientDataSchema ? { clientDataSchema: config.clientDataSchema } : {}),
uiMessageStreamOptions: mergedUiStream,
@@ -3797,7 +3857,7 @@ function createChatBuilder<
/**
* Fix the UI message type for a chat task (AI SDK `UIMessage` generics) while
- * keeping `id` and `clientDataSchema` inference on the inner {@link chat.task} call.
+ * keeping `id` and `clientDataSchema` inference on the inner {@link chat.agent} call.
*
* Returns a {@link ChatBuilder} that supports chaining `.withClientData()`,
* hook methods (`.onPreload()`, `.onChatSuspend()`, etc.), and `.task()`.
@@ -3860,7 +3920,7 @@ function withClientData(config: {
* import { chat } from "@trigger.dev/sdk/ai";
*
* // Define a chat task
- * export const myChat = chat.task({
+ * export const myChat = chat.agent({
* id: "my-chat",
* run: async ({ messages, signal }) => {
* return streamText({ model, messages, abortSignal: signal });
@@ -3888,7 +3948,7 @@ const IDLE_TIMEOUT_METADATA_KEY = "chat.idleTimeout";
* waiting for the next user message. When it expires, the run completes
* gracefully and the next message starts a fresh run.
*
- * Call from inside a `chatTask` run function to adjust based on context.
+ * Call from inside a `chatAgent` run function to adjust based on context.
*
* @param duration - A duration string (e.g. `"5m"`, `"1h"`, `"30s"`)
*
@@ -3950,7 +4010,7 @@ function setIdleTimeoutInSeconds(seconds: number): void {
* message metadata, etc.
*
* Per-turn options are merged on top of the static `uiMessageStreamOptions`
- * set on `chat.task()`. Per-turn values win on conflicts.
+ * set on `chat.agent()`. Per-turn values win on conflicts.
*
* @example
* ```ts
@@ -3969,7 +4029,7 @@ function setUIMessageStreamOptions(options: ChatUIMessageStreamOptions
/**
* Check whether the user stopped generation during the current turn.
*
- * Works from **anywhere** inside a `chat.task` run — including inside
+ * Works from **anywhere** inside a `chat.agent` run — including inside
* `streamText`'s `onFinish` callback — without needing to thread the
* `stopSignal` through closures.
*
@@ -4093,14 +4153,14 @@ function injectBackgroundContext(messages: ModelMessage[]): void {
* - Incomplete tool parts removed entirely
* - Reasoning and text parts marked as `"done"`
*
- * `chat.task` calls this automatically when stop is detected before passing
+ * `chat.agent` calls this automatically when stop is detected before passing
* the response to `onTurnComplete`. Use this manually when calling `pipeChat`
* directly and capturing response messages yourself.
*
* @example
* ```ts
* onTurnComplete: async ({ responseMessage, stopped }) => {
- * // Already cleaned automatically by chat.task — but if you captured
+ * // Already cleaned automatically by chat.agent — but if you captured
* // your own message via pipeChat, clean it manually:
* const cleaned = chat.cleanupAbortedParts(myMessage);
* await db.messages.save(cleaned);
@@ -4266,12 +4326,12 @@ async function pipeChatAndCapture(
class ChatMessageAccumulator {
modelMessages: ModelMessage[] = [];
uiMessages: UIMessage[] = [];
- private _compaction?: ChatTaskCompactionOptions;
+ private _compaction?: ChatAgentCompactionOptions;
private _pendingMessages?: PendingMessagesOptions;
private _steeringQueue: SteeringQueueEntry[] = [];
constructor(options?: {
- compaction?: ChatTaskCompactionOptions;
+ compaction?: ChatAgentCompactionOptions;
pendingMessages?: PendingMessagesOptions;
}) {
this._compaction = options?.compaction;
@@ -4358,9 +4418,9 @@ class ChatMessageAccumulator {
*/
prepareStep():
| ((args: {
- messages: ModelMessage[];
- steps: CompactionStep[];
- }) => Promise<{ messages: ModelMessage[] } | undefined>)
+ messages: ModelMessage[];
+ steps: CompactionStep[];
+ }) => Promise<{ messages: ModelMessage[] } | undefined>)
| undefined {
if (!this._compaction && !this._pendingMessages) return undefined;
const comp = this._compaction;
@@ -4449,11 +4509,11 @@ class ChatMessageAccumulator {
this.modelMessages = this._compaction.compactModelMessages
? await this._compaction.compactModelMessages(compactEvent)
: [
- {
- role: "assistant" as const,
- content: [{ type: "text" as const, text: `[Conversation summary]\n\n${summary}` }],
- },
- ];
+ {
+ role: "assistant" as const,
+ content: [{ type: "text" as const, text: `[Conversation summary]\n\n${summary}` }],
+ },
+ ];
if (this._compaction.compactUIMessages) {
this.uiMessages = await this._compaction.compactUIMessages(compactEvent);
@@ -4476,9 +4536,9 @@ export type ChatSessionOptions = {
timeout?: string;
/** Max turns before ending. @default 100 */
maxTurns?: number;
- /** Automatic context compaction — same options as `chat.task({ compaction })`. */
- compaction?: ChatTaskCompactionOptions;
- /** Configure mid-execution message injection — same options as `chat.task({ pendingMessages })`. */
+ /** Automatic context compaction — same options as `chat.agent({ compaction })`. */
+ compaction?: ChatAgentCompactionOptions;
+ /** Configure mid-execution message injection — same options as `chat.agent({ pendingMessages })`. */
pendingMessages?: PendingMessagesOptions;
};
@@ -4538,9 +4598,9 @@ export type ChatTurn = {
*/
prepareStep():
| ((args: {
- messages: ModelMessage[];
- steps: CompactionStep[];
- }) => Promise<{ messages: ModelMessage[] } | undefined>)
+ messages: ModelMessage[];
+ steps: CompactionStep[];
+ }) => Promise<{ messages: ModelMessage[] } | undefined>)
| undefined;
};
@@ -4752,7 +4812,7 @@ function createChatSession(
}
}
- // Outer-loop compaction (same logic as chat.task)
+ // Outer-loop compaction (same logic as chat.agent)
if (sessionCompaction && turnUsage && !turnObj.stopped) {
const shouldTrigger = await sessionCompaction.shouldCompact({
messages: accumulator.modelMessages,
@@ -4791,13 +4851,13 @@ function createChatSession(
accumulator.modelMessages = sessionCompaction.compactModelMessages
? await sessionCompaction.compactModelMessages(compactEvent)
: [
- {
- role: "assistant" as const,
- content: [
- { type: "text" as const, text: `[Conversation summary]\n\n${summary}` },
- ],
- },
- ];
+ {
+ role: "assistant" as const,
+ content: [
+ { type: "text" as const, text: `[Conversation summary]\n\n${summary}` },
+ ],
+ },
+ ];
if (sessionCompaction.compactUIMessages) {
accumulator.uiMessages = await sessionCompaction.compactUIMessages(
@@ -4945,7 +5005,7 @@ export type ChatLocal> = T & {
* const userPrefs = chat.local<{ theme: string; language: string }>({ id: "userPrefs" });
* const gameState = chat.local<{ score: number; streak: number }>({ id: "gameState" });
*
- * export const myChat = chat.task({
+ * export const myChat = chat.agent({
* id: "my-chat",
* onChatStart: async ({ clientData }) => {
* const prefs = await db.prefs.findUnique({ where: { userId: clientData.userId } });
@@ -5041,7 +5101,7 @@ function chatLocal>(options: { id: string }):
if (current === undefined) {
throw new Error(
"chat.local can only be modified after initialization. " +
- "Call local.init() in onChatStart or run() first."
+ "Call local.init() in onChatStart or run() first."
);
}
locals.set(localKey, { ...current, [prop]: value });
@@ -5184,8 +5244,8 @@ function createChatTriggerAction(
}
export const chat = {
- /** Create a chat task. See {@link chatTask}. */
- task: chatTask,
+ /** Create a chat agent. See {@link chatAgent}. */
+ agent: chatAgent,
/** Create a chat task with a fixed {@link UIMessage} subtype and optional default stream options. See {@link withUIMessage}. */
withUIMessage,
/** Create a chat task with a fixed client data schema. See {@link withClientData}. */
diff --git a/packages/trigger-sdk/src/v3/chat.ts b/packages/trigger-sdk/src/v3/chat.ts
index 8d7845280..bda54e346 100644
--- a/packages/trigger-sdk/src/v3/chat.ts
+++ b/packages/trigger-sdk/src/v3/chat.ts
@@ -4,7 +4,7 @@
* Browser-safe module for AI SDK chat transport integration.
* Use this on the frontend with the AI SDK's `useChat` hook.
*
- * For backend helpers (`chatTask`, `pipeChat`), use `@trigger.dev/sdk/ai` instead.
+ * For backend helpers (`chatAgent`, `pipeChat`), use `@trigger.dev/sdk/ai` instead.
*
* @example
* ```tsx
@@ -104,7 +104,7 @@ export type TriggerChatTaskResult = {
type TriggerChatTransportOptionsBase = {
/**
* The Trigger.dev task ID to trigger for chat completions.
- * This task should be defined using `chatTask()` from `@trigger.dev/sdk/ai`,
+ * This task should be defined using `chatAgent()` from `@trigger.dev/sdk/ai`,
* or a regular `task()` that uses `pipeChat()`.
*/
task: string;
@@ -117,7 +117,7 @@ type TriggerChatTransportOptionsBase = {
/**
* The stream key where the task pipes UIMessageChunk data.
- * When using `chatTask()` or `pipeChat()`, this is handled automatically.
+ * When using `chatAgent()` or `pipeChat()`, this is handled automatically.
* Only set this if you're using a custom stream key.
*
* @default "chat"
@@ -664,6 +664,22 @@ export class TriggerChatTransport implements ChatTransport {
this.triggerTaskFn = fn;
}
+ /**
+ * Inject or update a session for a chat. Useful for resuming conversations
+ * from persisted state without recreating the transport.
+ */
+ setSession(
+ chatId: string,
+ session: { runId: string; publicAccessToken: string; lastEventId?: string }
+ ): void {
+ this.sessions.set(chatId, {
+ runId: session.runId,
+ publicAccessToken: session.publicAccessToken,
+ lastEventId: session.lastEventId,
+ });
+ this.notifySessionChange(chatId, this.sessions.get(chatId)!);
+ }
+
/**
* Eagerly trigger a run for a chat before the first message is sent.
* This allows initialization (DB setup, context loading) to happen
@@ -676,15 +692,23 @@ export class TriggerChatTransport implements ChatTransport {
*
* No-op if a session already exists for this chatId.
*/
- async preload(chatId: string, options?: { idleTimeoutInSeconds?: number }): Promise {
+ async preload(
+ chatId: string,
+ options?: { idleTimeoutInSeconds?: number; metadata?: Record }
+ ): Promise {
// Don't preload if session already exists
if (this.sessions.get(chatId)?.runId) return;
+ const mergedMetadata =
+ this.defaultMetadata || options?.metadata
+ ? { ...(this.defaultMetadata ?? {}), ...(options?.metadata ?? {}) }
+ : undefined;
+
const payload = {
messages: [] as never[],
chatId,
trigger: "preload" as const,
- metadata: this.defaultMetadata,
+ metadata: mergedMetadata,
...(options?.idleTimeoutInSeconds !== undefined
? { idleTimeoutInSeconds: options.idleTimeoutInSeconds }
: {}),
diff --git a/packages/trigger-sdk/src/v3/shared.ts b/packages/trigger-sdk/src/v3/shared.ts
index c1d3139b7..70d18bd61 100644
--- a/packages/trigger-sdk/src/v3/shared.ts
+++ b/packages/trigger-sdk/src/v3/shared.ts
@@ -1,4 +1,6 @@
import { SpanKind } from "@opentelemetry/api";
+import { trail } from "agentcrumbs"; // @crumbs
+const _sdkCrumb = trail("sdk"); // @crumbs
import { SerializableJson } from "@trigger.dev/core";
import {
accessoryAttributes,
@@ -250,12 +252,25 @@ export function createTask<
registerTaskLifecycleHooks(params.id, params);
+ // #region @crumbs
+ _sdkCrumb("createTask registerTaskMetadata", {
+ taskId: params.id,
+ triggerSource: params.triggerSource,
+ agentConfig: params.agentConfig,
+ hasTriggerSource: "triggerSource" in params,
+ hasAgentConfig: "agentConfig" in params,
+ paramKeys: Object.keys(params).filter((k: string) => k.includes("trigger") || k.includes("agent") || k.includes("config")),
+ });
+ // #endregion @crumbs
+
resourceCatalog.registerTaskMetadata({
id: params.id,
description: params.description,
queue: params.queue,
retry: params.retry ? { ...defaultRetryOptions, ...params.retry } : undefined,
machine: typeof params.machine === "string" ? { preset: params.machine } : params.machine,
+ triggerSource: params.triggerSource,
+ agentConfig: params.agentConfig,
maxDuration: params.maxDuration,
ttl: params.ttl,
payloadSchema: params.jsonSchema,
@@ -408,6 +423,8 @@ export function createSchemaTask<
queue: params.queue,
retry: params.retry ? { ...defaultRetryOptions, ...params.retry } : undefined,
machine: typeof params.machine === "string" ? { preset: params.machine } : params.machine,
+ triggerSource: params.triggerSource,
+ agentConfig: params.agentConfig,
maxDuration: params.maxDuration,
ttl: params.ttl,
fns: {
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 19c7eb1d8..94f18d3a7 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -242,6 +242,9 @@ importers:
'@ai-sdk/openai':
specifier: ^1.3.23
version: 1.3.23(zod@3.25.76)
+ '@ai-sdk/react':
+ specifier: ^3.0.0
+ version: 3.0.170(react@18.2.0)(zod@3.25.76)
'@ariakit/react':
specifier: ^0.4.6
version: 0.4.6(react-dom@18.2.0(react@18.2.0))(react@18.2.0)
@@ -21402,6 +21405,16 @@ snapshots:
optionalDependencies:
zod: 3.25.76
+ '@ai-sdk/react@3.0.170(react@18.2.0)(zod@3.25.76)':
+ dependencies:
+ '@ai-sdk/provider-utils': 4.0.23(zod@3.25.76)
+ ai: 6.0.168(zod@3.25.76)
+ react: 18.2.0
+ swr: 2.2.5(react@18.2.0)
+ throttleit: 2.1.0
+ transitivePeerDependencies:
+ - zod
+
'@ai-sdk/react@3.0.170(react@19.1.0)(zod@3.25.76)':
dependencies:
'@ai-sdk/provider-utils': 4.0.23(zod@3.25.76)
@@ -24881,9 +24894,9 @@ snapshots:
dependencies:
react: 18.2.0
- '@hono/node-server@1.12.2(hono@4.5.11)':
+ '@hono/node-server@1.12.2(hono@4.12.15)':
dependencies:
- hono: 4.5.11
+ hono: 4.12.15
'@hono/node-server@1.19.11(hono@4.12.15)':
dependencies:
@@ -24899,7 +24912,7 @@ snapshots:
'@hono/node-ws@1.0.4(@hono/node-server@1.12.2(hono@4.5.11))(bufferutil@4.0.9)':
dependencies:
- '@hono/node-server': 1.12.2(hono@4.5.11)
+ '@hono/node-server': 1.12.2(hono@4.12.15)
ws: 8.18.3(bufferutil@4.0.9)
transitivePeerDependencies:
- bufferutil
diff --git a/references/ai-chat/src/components/chat-sidebar.tsx b/references/ai-chat/src/components/chat-sidebar.tsx
index 198d9afde..d1157a066 100644
--- a/references/ai-chat/src/components/chat-sidebar.tsx
+++ b/references/ai-chat/src/components/chat-sidebar.tsx
@@ -119,7 +119,7 @@ export function ChatSidebar({
onChange={(e) => onTaskModeChange(e.target.value)}
className="flex-1 rounded border border-gray-300 px-1.5 py-0.5 text-xs text-gray-600 outline-none focus:border-blue-500"
>
-
+
diff --git a/references/ai-chat/src/lib/chat-tools.ts b/references/ai-chat/src/lib/chat-tools.ts
index 9009d2bb3..078b55ecb 100644
--- a/references/ai-chat/src/lib/chat-tools.ts
+++ b/references/ai-chat/src/lib/chat-tools.ts
@@ -308,7 +308,7 @@ export const executeJs = tool({
},
});
-/** Tool set passed to `streamText` for the main `chat.task` run (includes PostHog). */
+/** Tool set passed to `streamText` for the main `chat.agent` run (includes PostHog). */
export const chatTools = {
inspectEnvironment,
webFetch,
diff --git a/references/ai-chat/src/lib/code-sandbox.ts b/references/ai-chat/src/lib/code-sandbox.ts
index 2bac3445c..5a3e48cd6 100644
--- a/references/ai-chat/src/lib/code-sandbox.ts
+++ b/references/ai-chat/src/lib/code-sandbox.ts
@@ -1,11 +1,11 @@
/**
* E2B sandboxes keyed by Trigger run id.
*
- * - Warmed from `chat.task` `onTurnStart` (non-blocking) so the first `executeCode` tool call is faster.
- * - Disposed in task `onWait` when `wait.type === "token"` (input-stream suspend, same path as `wait.for` tokens).
- * - `onComplete` disposes any leftover sandbox if the run ends without hitting another token wait.
+ * - Warmed from `chat.agent` `onTurnStart` (non-blocking) so the first `executeCode` tool call is faster.
+ * - Disposed in `onChatSuspend` before the run suspends waiting for the next message.
+ * - `onComplete` disposes any leftover sandbox if the run ends without hitting another suspend.
*
- * No extra `chat.task` SDK hook is required for the suspend boundary — platform `onWait` is sufficient.
+ * No extra SDK hook is required beyond `onChatSuspend` and `onComplete`.
*/
import { chat } from "@trigger.dev/sdk/ai";
import { Sandbox } from "@e2b/code-interpreter";
diff --git a/references/ai-chat/src/trigger/chat.ts b/references/ai-chat/src/trigger/chat.ts
index e541df06a..ff70e84cd 100644
--- a/references/ai-chat/src/trigger/chat.ts
+++ b/references/ai-chat/src/trigger/chat.ts
@@ -146,7 +146,7 @@ const userContext = chat.local<{
// #endregion
// ============================================================================
-// chat.task — the main chat agent
+// chat.agent — the main chat agent
// ============================================================================
export const aiChat = chat
@@ -172,7 +172,7 @@ export const aiChat = chat
.onChatResume(async ({ phase, ctx }) => {
logger.debug("Chat resumed", { phase, runId: ctx.run.id });
})
- .task({
+ .agent({
id: "ai-chat",
idleTimeoutInSeconds: 60,
chatAccessTokenTTL: "1m",
@@ -442,7 +442,7 @@ export const aiChat = chat
},
// #endregion
- // #region run — just return streamText(), chat.task handles everything else
+ // #region run — just return streamText(), chat.agent handles everything else
run: async ({ messages, clientData, stopSignal }) => {
userContext.messageCount++;
if (clientData?.model) {
diff --git a/references/ai-chat/trigger.config.ts b/references/ai-chat/trigger.config.ts
index 94584b5e1..830af3880 100644
--- a/references/ai-chat/trigger.config.ts
+++ b/references/ai-chat/trigger.config.ts
@@ -1,17 +1,76 @@
import { defineConfig } from "@trigger.dev/sdk";
import { prismaExtension } from "@trigger.dev/build/extensions/prisma";
-import { secureExec } from "@trigger.dev/build/extensions/secureExec";
+import { esbuildPlugin } from "@trigger.dev/build/extensions";
+import { createRequire } from "node:module";
+import fs from "node:fs";
+import path from "node:path";
export default defineConfig({
project: process.env.TRIGGER_PROJECT_REF!,
dirs: ["./src/trigger"],
maxDuration: 3600,
+ runtime: "node-22",
build: {
extensions: [
prismaExtension({
mode: "modern",
}),
- secureExec(),
+ // Trigger's ESM shim anchors require.resolve() to the chunk path, so
+ // node-stdlib-browser's runtime require.resolve("./mock/empty.js") breaks.
+ // Fix: load the real node-stdlib-browser at build time (where require.resolve
+ // works), capture the resolved path map, and inline it as a static export.
+ esbuildPlugin({
+ name: "node-stdlib-browser-stub",
+ setup(build) {
+ build.onResolve({ filter: /^node-stdlib-browser$/ }, () => ({
+ path: "node-stdlib-browser",
+ namespace: "nsb-resolved",
+ }));
+ build.onLoad({ filter: /.*/, namespace: "nsb-resolved" }, () => {
+ const buildRequire = createRequire(import.meta.url);
+ const resolved = buildRequire("node-stdlib-browser");
+ return {
+ contents: `export default ${JSON.stringify(resolved)};`,
+ loader: "js",
+ };
+ });
+ },
+ }),
+ // @secure-exec/node's bridge-loader.js runs require.resolve("@secure-exec/core")
+ // at module scope to locate dist/bridge.js on disk. This fails in Trigger's
+ // Docker container where the code is bundled into chunks and the package
+ // isn't on disk. Fix: inline bridge.js content at build time so no runtime
+ // filesystem access or package resolution is needed.
+ esbuildPlugin({
+ name: "inline-secure-exec-bridge",
+ setup(build) {
+ build.onLoad(
+ { filter: /[\\/]@secure-exec[\\/]node[\\/]dist[\\/]bridge-loader\.js$/ },
+ (args) => {
+ const buildRequire = createRequire(args.path);
+ const coreEntry = buildRequire.resolve("@secure-exec/core");
+ const coreRoot = path.resolve(path.dirname(coreEntry), "..");
+ const bridgeCode = fs.readFileSync(path.join(coreRoot, "dist", "bridge.js"), "utf8");
+ return {
+ contents: [
+ `import { getIsolateRuntimeSource } from "@secure-exec/core";`,
+ `const bridgeCodeCache = ${JSON.stringify(bridgeCode)};`,
+ `export function getRawBridgeCode() { return bridgeCodeCache; }`,
+ `export function getBridgeAttachCode() { return getIsolateRuntimeSource("bridgeAttach"); }`,
+ ].join("\n"),
+ loader: "js",
+ };
+ },
+ );
+ },
+ }),
],
+ external: [
+ // esbuild must not be bundled — it locates its native binary via a
+ // relative path from its JS API entry point. secure-exec uses esbuild
+ // at runtime to bundle polyfills for sandbox code.
+ "esbuild",
+ ],
+ keepNames: false,
},
});
diff --git a/references/hello-world/src/trigger/chatAgent.ts b/references/hello-world/src/trigger/chatAgent.ts
new file mode 100644
index 000000000..da0a2af07
--- /dev/null
+++ b/references/hello-world/src/trigger/chatAgent.ts
@@ -0,0 +1,56 @@
+import { chat } from "@trigger.dev/sdk/ai";
+import { prompts } from "@trigger.dev/sdk";
+import { streamText, createProviderRegistry } from "ai";
+import { openai } from "@ai-sdk/openai";
+import { z } from "zod";
+
+const registry = createProviderRegistry({ openai });
+
+type RegistryModelId = Parameters[0];
+
+const systemPrompt = prompts.define({
+ id: "test-agent-system",
+ model: "openai:gpt-4o-mini" satisfies RegistryModelId,
+ config: { temperature: 0.7 },
+ variables: z.object({ userId: z.string() }),
+ content: `You are a helpful AI assistant in the Trigger.dev playground.
+The current user is {{userId}}.
+
+## Guidelines
+- Be concise and friendly. Prefer short, direct answers.
+- Use markdown formatting for code blocks and lists.
+- If you don't know something, say so.`,
+});
+
+export const testAgent = chat
+ .withClientData({
+ schema: z.object({
+ userId: z.string().optional().default("anonymous"),
+ model: z.string().optional().default("openai:gpt-4o-mini"),
+ }),
+ })
+ .onChatStart(async ({ clientData }) => {
+ const resolved = await systemPrompt.resolve({
+ userId: clientData?.userId ?? "anonymous",
+ });
+ chat.prompt.set(resolved);
+ })
+ .agent({
+ id: "test-agent",
+ run: async ({ messages, clientData, signal }) => {
+ // chat.toStreamTextOptions({ registry }) resolves the prompt's model via
+ // the registry and injects system prompt + telemetry automatically
+ const model = registry.languageModel(clientData?.model ? (clientData.model as RegistryModelId) : "openai:gpt-4o-mini")
+
+ if (!model) {
+ throw new Error("Model not found");
+ }
+
+ return streamText({
+ ...chat.toStreamTextOptions({ registry }),
+ model,
+ messages,
+ abortSignal: signal,
+ });
+ },
+ });