import { json } from "@remix-run/server-runtime"; import { type ActionFunctionArgs } from "@remix-run/server-runtime"; import { z } from "zod"; import type { Prisma } from "@trigger.dev/database"; import { SessionId } from "@trigger.dev/core/v3/isomorphic"; import { prisma } from "~/db.server"; import { findProjectBySlug } from "~/models/project.server"; import { findEnvironmentBySlug } from "~/models/runtimeEnvironment.server"; import { requireUserId } from "~/services/session.server"; import { EnvironmentParamSchema } from "~/utils/pathBuilder"; import { mintSessionToken } from "~/services/realtime/mintSessionToken.server"; import { ensureRunForSession } from "~/services/realtime/sessionRunManager.server"; import { runStore } from "~/v3/runStore.server"; const PlaygroundAction = z.object({ intent: z.enum(["create", "start", "save", "delete"]), agentSlug: z.string(), // For create conversationId: z.string().optional(), // For start (replaces "trigger" — atomically creates the Session and // triggers its first run, returns a session-scoped PAT) chatId: z.string().optional(), payload: z.string().optional(), clientData: z.string().optional(), tags: z.string().optional(), machine: z.string().optional(), maxAttempts: z.string().optional(), maxDuration: z.string().optional(), version: z.string().optional(), region: z.string().optional(), // For save messages: z.string().optional(), lastEventId: z.string().optional(), // For delete deleteConversationId: z.string().optional(), }); export const action = async ({ request, params }: ActionFunctionArgs) => { 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 "start": { const { agentSlug, chatId, payload: payloadStr, clientData, tags: tagsStr, machine, maxAttempts, maxDuration, version, region, } = parsed.data; if (!chatId) { return json({ error: "chatId is required" }, { status: 400 }); } // Parse the optional initial payload — used as the basePayload // for the first run trigger. After session create, the agent // reads subsequent messages from `.in/append` so the payload // here is just the bootstrap. let payload: Record = {}; try { payload = payloadStr ? (JSON.parse(payloadStr) as Record) : {}; } catch { return json({ error: "Invalid payload JSON" }, { status: 400 }); } let parsedClientData: unknown; try { parsedClientData = clientData ? JSON.parse(clientData) : undefined; } catch { /* invalid JSON — fall through with undefined */ } const tags = [ `chat:${chatId}`, "playground:true", ...(tagsStr ? tagsStr .split(",") .map((t) => t.trim()) .filter(Boolean) : []), ].slice(0, 5); const triggerConfig = { basePayload: { // The first run boots before the user's first message lands on // `.in/append`, so it sees `messages: []` and `trigger: "preload"`. // Mirrors the defaults in `chat.createStartSessionAction` — // chat.agent's runtime reads `payload.messages.length` so the // field must be an array, not undefined. messages: [], trigger: "preload", ...payload, chatId, ...(parsedClientData ? { metadata: parsedClientData } : {}), }, ...(machine ? { machine } : {}), tags, ...(maxAttempts ? { maxAttempts: parseInt(maxAttempts, 10) } : {}), ...(maxDuration ? { maxDuration: parseInt(maxDuration, 10) } : {}), ...(version ? { lockToVersion: version } : {}), ...(region ? { region } : {}), }; // Atomic: upsert the Session, then trigger the first run via // the optimistic-claim path. The transport's `accessToken` // callback hits this endpoint on initial start AND on 401 — the // upsert + ensureRunForSession combo is idempotent so repeat // calls converge to the same session and (if alive) reuse the // existing run. const { id: sessionId, friendlyId } = SessionId.generate(); const session = await prisma.session.upsert({ where: { runtimeEnvironmentId_externalId: { runtimeEnvironmentId: environment.id, externalId: chatId, }, }, create: { id: sessionId, friendlyId, externalId: chatId, type: "chat.agent", taskIdentifier: agentSlug, triggerConfig: triggerConfig as unknown as Prisma.InputJsonValue, // Mark as a Test session — surfaced via the Test column in the // Sessions table. Session tags stay empty; the triggered run still // carries "playground:true" via triggerConfig.tags. isTest: true, projectId: project.id, runtimeEnvironmentId: environment.id, environmentType: environment.type, organizationId: project.organizationId, // Stamp the org's S2 basin so realtime reads on this // session's `.in/.out` channels resolve without joining // Organization. Null until per-org basins are provisioned. streamBasinName: environment.organization.streamBasinName, }, update: { // Refresh trigger config in case agent version / params changed triggerConfig: triggerConfig as unknown as Prisma.InputJsonValue, }, }); const ensureResult = await ensureRunForSession({ session, environment, reason: "initial", }); const run = await runStore.findRun( { id: ensureResult.runId }, { select: { friendlyId: true } }, prisma ); if (!run) { return json({ error: "Triggered run not found" }, { status: 500 }); } // Title: prefer the user message text on first start, else a // generic placeholder. The conversation row is the playground's // own surface — separate from the Session row that drives the // trigger. 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: ensureResult.runId, clientData: parsedClientData as any, projectId: project.id, runtimeEnvironmentId: environment.id, userId, }, update: { runId: ensureResult.runId, clientData: parsedClientData as any, title, }, }); const publicAccessToken = await mintSessionToken(environment, chatId); return json({ runId: run.friendlyId, publicAccessToken, conversationId: conversation.id, }); } case "save": { const { chatId, messages: messagesStr, lastEventId } = parsed.data; if (!chatId) { return json({ error: "chatId is required" }, { status: 400 }); } let messagesData: unknown; try { messagesData = messagesStr ? JSON.parse(messagesStr) : undefined; } catch { return json({ error: "Invalid messages JSON" }, { status: 400 }); } // Extract title from the first user message if the conversation still has the default title. // This handles the case where a preloaded conversation gets its first real message // via the input stream (bypassing the trigger action that normally sets the title). let titleUpdate: { title: string } | undefined; if (messagesData && Array.isArray(messagesData)) { const existing = await prisma.playgroundConversation.findFirst({ where: { chatId, runtimeEnvironmentId: environment.id, userId }, select: { title: true }, }); if (existing?.title === "New conversation") { const firstUserMsg = messagesData.find((m: any) => m.role === "user") as | Record | undefined; const firstText = firstUserMsg?.parts?.find((p: any) => p.type === "text")?.text ?? firstUserMsg?.content; if (firstText && typeof firstText === "string") { titleUpdate = { title: firstText.length > 60 ? firstText.slice(0, 60) + "..." : firstText, }; } } } await prisma.playgroundConversation.updateMany({ where: { chatId, runtimeEnvironmentId: environment.id, userId, }, data: { ...(messagesData ? { messages: messagesData as any } : {}), ...(lastEventId ? { lastEventId } : {}), ...titleUpdate, }, }); 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 }); } } };