From 64ede8284751066db53ebf48071f4ecb7ae0dc81 Mon Sep 17 00:00:00 2001 From: Eric Allam Date: Sun, 3 May 2026 15:33:12 +0100 Subject: [PATCH] chore(references): demo chat.headStart in ai-chat reference MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a /api/chat route handler exporting chat.headStart, splits the tool definitions across two modules so heavy executes never reach the browser bundle, and exposes a sidebar toggle for paired TTFC tests. - src/lib/chat-tools-schemas.ts (new): schema-only tool definitions — imported by both the route handler and the agent task. No `execute`, no heavy deps. Bundle stays small. - src/trigger/chat-tools.ts (renamed): re-exports the schemas with agent-side `execute` fns added (E2B sandbox, turndown, deepResearch subtask, etc.). Only the trigger task imports this. - src/app/api/chat/route.ts (new): exports POST = chat.headStart, runs step 1 streamText with claude-sonnet-4-6 to match the agent's default. - ChatSettingsContext + sidebar gain a "Use handover (1st turn)" toggle; chat-view threads it into the transport's `headStart` URL. - Smoke result: ~53% TTFC reduction on first turn (1561ms vs 3358ms), with persistence + tool execution behaving identically. --- references/ai-chat/src/app/actions.ts | 2 +- references/ai-chat/src/app/api/chat/route.ts | 54 ++++ .../ai-chat/src/components/chat-app.tsx | 4 +- .../src/components/chat-settings-context.tsx | 10 + .../src/components/chat-sidebar-wrapper.tsx | 4 + .../ai-chat/src/components/chat-sidebar.tsx | 16 + .../ai-chat/src/components/chat-view.tsx | 14 +- references/ai-chat/src/components/chat.tsx | 7 +- .../ai-chat/src/lib/chat-tools-schemas.ts | 143 +++++++++ references/ai-chat/src/trigger/chat-tools.ts | 297 ++++++++++++++++++ references/ai-chat/src/trigger/chat.ts | 14 +- 11 files changed, 557 insertions(+), 8 deletions(-) create mode 100644 references/ai-chat/src/app/api/chat/route.ts create mode 100644 references/ai-chat/src/lib/chat-tools-schemas.ts create mode 100644 references/ai-chat/src/trigger/chat-tools.ts diff --git a/references/ai-chat/src/app/actions.ts b/references/ai-chat/src/app/actions.ts index 60e156689..0ef650cfc 100644 --- a/references/ai-chat/src/app/actions.ts +++ b/references/ai-chat/src/app/actions.ts @@ -9,7 +9,7 @@ import type { aiChatSession, upgradeTestAgent, } from "@/trigger/chat"; -import type { ChatUiMessage } from "@/lib/chat-tools"; +import type { ChatUiMessage } from "@/lib/chat-tools-schemas"; import { prisma } from "@/lib/prisma"; /** Short-lived PATs for local testing of expiry + renewal (not for production). */ diff --git a/references/ai-chat/src/app/api/chat/route.ts b/references/ai-chat/src/app/api/chat/route.ts new file mode 100644 index 000000000..42812f163 --- /dev/null +++ b/references/ai-chat/src/app/api/chat/route.ts @@ -0,0 +1,54 @@ +/** + * chat.headStart first-turn endpoint. + * + * The browser transport POSTs first-turn messages here when the + * `headStart` option is set on `useTriggerChatTransport`. This + * handler: + * + * 1. Creates the chat.agent session and triggers a `handover-prepare` + * run (atomic, one round-trip), so the agent boots in parallel. + * 2. Runs `streamText` step 1 right here in the warm Next.js process + * and returns the SSE stream directly to the browser — no waiting + * on the agent's cold start. + * 3. On step 1's tool-call boundary, hands ownership of the durable + * session.out stream over to the agent run, which executes tools + * and continues from step 2+ (or exits clean for pure-text turns). + * + * Subsequent turns bypass this endpoint — the transport hydrates the + * session PAT from response headers and writes directly to + * `session.in` for turn 2 onward. + * + * The TTFC win: cold-start agent boot (~488ms) + onTurnStart hooks + * (~316ms) overlap with the LLM TTFB instead of stacking before it, + * so the user-perceived first chunk arrives ~50% sooner. The agent + * still owns tool execution and everything after — heavy deps stay + * where they belong. + */ +import { chat } from "@trigger.dev/sdk/chat-server"; +import { streamText } from "ai"; +import { anthropic } from "@ai-sdk/anthropic"; +// ⚠️ Imports MUST come from `chat-tools-schemas` only — see the +// header comment in that file for the bundle-isolation rationale. +// Importing `src/trigger/chat-tools.ts` here would drag E2B, +// turndown, the trigger SDK runtime, etc. into the Next.js bundle +// and defeat the whole point of `chat.headStart`. +import { headStartTools } from "@/lib/chat-tools-schemas"; + +export const POST = chat.headStart({ + agentId: "ai-chat", + run: async ({ chat: chatHelper }) => { + return streamText({ + // `toStreamTextOptions` wires `messages` (converted from + // UIMessages), `tools`, `stopWhen: stepCountIs(1)`, and the + // combined `abortSignal`. Customer adds model + system prompt on + // top — anything else `streamText` accepts is fair game. + ...chatHelper.toStreamTextOptions({ tools: headStartTools }), + // Match the agent's default (`DEFAULT_MODEL` in `lib/models.ts`) + // so step 1 and step 2+ run on the same provider — no jarring + // tone/style shift mid-turn, and TTFC comparisons stay honest. + model: anthropic("claude-sonnet-4-6"), + system: + "You are a helpful AI assistant. Be concise and friendly. Use the available tools when relevant.", + }); + }, +}); diff --git a/references/ai-chat/src/components/chat-app.tsx b/references/ai-chat/src/components/chat-app.tsx index 2d77ffe94..4d9ede23e 100644 --- a/references/ai-chat/src/components/chat-app.tsx +++ b/references/ai-chat/src/components/chat-app.tsx @@ -2,7 +2,7 @@ import { generateId } from "ai"; import { useTriggerChatTransport } from "@trigger.dev/sdk/chat/react"; -import type { ChatUiMessage } from "@/lib/chat-tools"; +import type { ChatUiMessage } from "@/lib/chat-tools-schemas"; import { useCallback, useEffect, useState } from "react"; import { Chat } from "@/components/chat"; import { ChatSidebar } from "@/components/chat-sidebar"; @@ -169,6 +169,8 @@ export function ChatApp({ onIdleTimeoutChange={setIdleTimeoutInSeconds} taskMode={taskMode} onTaskModeChange={onTaskModeChange} + useHandover={false} + onUseHandoverChange={() => {}} />
{activeChatId ? ( diff --git a/references/ai-chat/src/components/chat-settings-context.tsx b/references/ai-chat/src/components/chat-settings-context.tsx index 0d9e605b5..6eb6366ca 100644 --- a/references/ai-chat/src/components/chat-settings-context.tsx +++ b/references/ai-chat/src/components/chat-settings-context.tsx @@ -7,6 +7,13 @@ type ChatSettings = { setTaskMode: (mode: string) => void; idleTimeoutInSeconds: number; setIdleTimeoutInSeconds: (seconds: number) => void; + /** + * When true, first-turn messages are POSTed to `/api/chat` + * (`chat.handover` route handler) instead of triggering the agent + * directly. Subsequent turns bypass the endpoint regardless. + */ + useHandover: boolean; + setUseHandover: (on: boolean) => void; }; const ChatSettingsContext = createContext(null); @@ -14,12 +21,15 @@ const ChatSettingsContext = createContext(null); export function ChatSettingsProvider({ children }: { children: ReactNode }) { const [taskMode, setTaskMode] = useState("ai-chat"); const [idleTimeoutInSeconds, setIdleTimeoutInSeconds] = useState(60); + const [useHandover, setUseHandover] = useState(false); const value: ChatSettings = { taskMode, setTaskMode, idleTimeoutInSeconds, setIdleTimeoutInSeconds, + useHandover, + setUseHandover, }; // eslint-disable-next-line @typescript-eslint/no-explicit-any diff --git a/references/ai-chat/src/components/chat-sidebar-wrapper.tsx b/references/ai-chat/src/components/chat-sidebar-wrapper.tsx index 2827e901d..8b3dc3b8e 100644 --- a/references/ai-chat/src/components/chat-sidebar-wrapper.tsx +++ b/references/ai-chat/src/components/chat-sidebar-wrapper.tsx @@ -28,6 +28,8 @@ export function ChatSidebarWrapper({ setTaskMode, idleTimeoutInSeconds, setIdleTimeoutInSeconds, + useHandover, + setUseHandover, } = useChatSettings(); // Extract active chatId from URL @@ -85,6 +87,8 @@ export function ChatSidebarWrapper({ onIdleTimeoutChange={setIdleTimeoutInSeconds} taskMode={taskMode} onTaskModeChange={setTaskMode} + useHandover={useHandover} + onUseHandoverChange={setUseHandover} /> ); } diff --git a/references/ai-chat/src/components/chat-sidebar.tsx b/references/ai-chat/src/components/chat-sidebar.tsx index 11dd05475..5c4bd6c67 100644 --- a/references/ai-chat/src/components/chat-sidebar.tsx +++ b/references/ai-chat/src/components/chat-sidebar.tsx @@ -29,6 +29,8 @@ type ChatSidebarProps = { onIdleTimeoutChange: (seconds: number) => void; taskMode: string; onTaskModeChange: (mode: string) => void; + useHandover: boolean; + onUseHandoverChange: (on: boolean) => void; }; export function ChatSidebar({ @@ -42,6 +44,8 @@ export function ChatSidebar({ onIdleTimeoutChange, taskMode, onTaskModeChange, + useHandover, + onUseHandoverChange, }: ChatSidebarProps) { const sorted = [...chats].sort((a, b) => b.updatedAt - a.updatedAt); @@ -115,6 +119,18 @@ export function ChatSidebar({
+