Skip to content

Commit 6797da9

Browse files
committed
feat(sdk): chat.headStart for fast first-turn TTFC
Adds an opt-in fast path that runs step 1 streamText in the warm customer process (Next.js, Hono, Workers, Express, etc.) while the trigger agent run boots in parallel. Pure-text turns finish on the handler side; tool-call turns hand ownership to the agent at the tool-call boundary via a `kind: "handover"` chunk on session.in. - New @trigger.dev/sdk/chat-server subpath with chat.headStart, chat.openSession (escape hatch), and chat.toNodeListener (Express / Fastify / Koa bridge from Web Fetch handler to (req, res)). - Wire-format: ChatInputChunk gains kind: "handover" with isFinal flag and partialAssistantMessage; trigger payload kind: "handover-prepare" for the boot-and-wait variant. - Run-loop: handover-prepare branch waits on session.in, then either skips userRun (isFinal: true → pure-text) or seeds accumulators and resumes step 2+ from tool-output-available (isFinal: false). - Browser: TriggerChatTransport gains an optional `headStart` URL. First-turn POSTs go there; turn 2+ bypasses and writes session.in. - Tests: chat-server.test.ts (handover dispatch, isFinal routing) and chatHandover.test.ts (run-loop branching, hook ordering, idle-timeout exit, schema-only-on-handler / executes-on-agent tool round).
1 parent 6ecf1d7 commit 6797da9

11 files changed

Lines changed: 2867 additions & 21 deletions

File tree

.changeset/chat-head-start.md

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
---
2+
"@trigger.dev/sdk": minor
3+
---
4+
5+
Add `chat.headStart` — an opt-in fast-path that runs the first turn's `streamText` step in your warm Next.js / Hono / Workers / Express handler while the trigger agent run boots in parallel. Cold-start TTFC drops by ~50% on the first message; the agent owns step 2+ (tool execution, persistence, hooks) so heavy deps stay where they belong.
6+
7+
```ts
8+
// app/api/chat/route.ts (Next.js / any Web Fetch framework)
9+
import { chat } from "@trigger.dev/sdk/chat-server";
10+
import { streamText } from "ai";
11+
import { openai } from "@ai-sdk/openai";
12+
import { headStartTools } from "@/lib/chat-tools-schemas"; // schema-only
13+
14+
export const POST = chat.headStart({
15+
agentId: "ai-chat",
16+
run: async ({ chat: chatHelper }) =>
17+
streamText({
18+
...chatHelper.toStreamTextOptions({ tools: headStartTools }),
19+
model: openai("gpt-4o-mini"),
20+
system: "You are a helpful AI assistant.",
21+
}),
22+
});
23+
```
24+
25+
```tsx
26+
// browser — opt in by pointing the transport at your handler
27+
const transport = useTriggerChatTransport({
28+
task: "ai-chat",
29+
accessToken,
30+
headStart: "/api/chat", // first-turn-only; turn 2+ bypasses the endpoint
31+
});
32+
```
33+
34+
For Node-only frameworks (Express, Fastify, Koa, raw `node:http`) use `chat.toNodeListener(handler)` to bridge the Web Fetch handler to `(req, res)`. Adds a new `@trigger.dev/sdk/chat-server` subpath; bundle stays Web Fetch–only with no `node:*` imports.

packages/trigger-sdk/package.json

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,8 @@
2828
"./ai/skills-runtime": "./src/v3/agentSkillsRuntime.ts",
2929
"./ai/test": "./src/v3/test/index.ts",
3030
"./chat": "./src/v3/chat.ts",
31-
"./chat/react": "./src/v3/chat-react.ts"
31+
"./chat/react": "./src/v3/chat-react.ts",
32+
"./chat-server": "./src/v3/chat-server.ts"
3233
},
3334
"sourceDialects": [
3435
"@triggerdotdev/source"
@@ -53,6 +54,9 @@
5354
],
5455
"chat/react": [
5556
"dist/commonjs/v3/chat-react.d.ts"
57+
],
58+
"chat-server": [
59+
"dist/commonjs/v3/chat-server.d.ts"
5660
]
5761
}
5862
},
@@ -187,6 +191,17 @@
187191
"types": "./dist/commonjs/v3/chat-react.d.ts",
188192
"default": "./dist/commonjs/v3/chat-react.js"
189193
}
194+
},
195+
"./chat-server": {
196+
"import": {
197+
"@triggerdotdev/source": "./src/v3/chat-server.ts",
198+
"types": "./dist/esm/v3/chat-server.d.ts",
199+
"default": "./dist/esm/v3/chat-server.js"
200+
},
201+
"require": {
202+
"types": "./dist/commonjs/v3/chat-server.d.ts",
203+
"default": "./dist/commonjs/v3/chat-server.js"
204+
}
190205
}
191206
},
192207
"main": "./dist/commonjs/v3/index.js",

packages/trigger-sdk/src/v3/ai-shared.ts

Lines changed: 66 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@
1616
*/
1717

1818
import type { Task, AnyTask } from "@trigger.dev/core/v3";
19-
import type { UIMessage } from "ai";
19+
import type { ModelMessage, UIMessage } from "ai";
2020

2121
/**
2222
* Message-part `type` value for the pending-message data part the agent
@@ -31,7 +31,21 @@ export const PENDING_MESSAGE_INJECTED_TYPE = "data-pending-message-injected" as
3131
export type ChatTaskWirePayload<TMessage extends UIMessage = UIMessage, TMetadata = unknown> = {
3232
messages: TMessage[];
3333
chatId: string;
34-
trigger: "submit-message" | "regenerate-message" | "preload" | "close" | "action";
34+
trigger:
35+
| "submit-message"
36+
| "regenerate-message"
37+
| "preload"
38+
| "close"
39+
| "action"
40+
/**
41+
* The customer's `chat.handover` route handler kicked us off in
42+
* parallel with the first-turn `streamText` running in the warm
43+
* Next.js process. The run sits idle on `session.in` waiting for
44+
* a `kind: "handover"` (continue from tool execution) or
45+
* `kind: "handover-skip"` (handler finished pure-text, exit
46+
* cleanly). See `chat.handover` in `@trigger.dev/sdk/chat-server`.
47+
*/
48+
| "handover-prepare";
3549
messageId?: string;
3650
metadata?: TMetadata;
3751
/** Custom action payload when `trigger` is `"action"`. Validated against `actionSchema` on the backend. */
@@ -83,6 +97,56 @@ export type ChatInputChunk<TMessage extends UIMessage = UIMessage, TMetadata = u
8397
kind: "stop";
8498
/** Optional human-readable reason. Maps to the legacy `chat-stop` record. */
8599
message?: string;
100+
}
101+
| {
102+
/**
103+
* Sent by `chat.headStart` when the customer's first-turn
104+
* `streamText` finishes. The agent run (currently parked in
105+
* `handover-prepare`) wakes, seeds its accumulators with
106+
* `partialAssistantMessage`, and runs the normal turn loop
107+
* (`onChatStart` → `onTurnStart` → … → `onTurnComplete`).
108+
*
109+
* What happens after that depends on `isFinal`:
110+
*
111+
* - `isFinal: false` — step 1 ended with `finishReason:
112+
* "tool-calls"`. The partial carries the assistant's
113+
* tool-call(s) wrapped in AI SDK's tool-approval round. The
114+
* agent's `streamText` runs the approved tools and continues
115+
* from step 2.
116+
* - `isFinal: true` — step 1 ended pure-text (no tool calls).
117+
* The partial carries the final assistant text. The agent
118+
* skips the LLM call entirely (the response is already
119+
* complete on the customer side) and runs `onTurnComplete`
120+
* with the partial as `responseMessage` so persistence and
121+
* any post-turn work fire normally.
122+
*/
123+
kind: "handover";
124+
/** Customer's step-1 response messages (ModelMessage form). */
125+
partialAssistantMessage: ModelMessage[];
126+
/**
127+
* The UI messageId the customer's handler used for its step-1
128+
* assistant message. The agent reuses this so any post-handover
129+
* chunks (tool-output-available, step-2 text, data-* parts
130+
* written by hooks) merge into the SAME assistant message on
131+
* the browser side instead of starting a new one.
132+
*/
133+
messageId?: string;
134+
/**
135+
* Whether the customer's step 1 is the final response. See
136+
* `kind` description above for the two branches.
137+
*/
138+
isFinal: boolean;
139+
}
140+
| {
141+
/**
142+
* Sent by `chat.headStart` only when the customer's handler
143+
* ABORTS before producing a finishReason (e.g., dispatch error,
144+
* stream cancelled before any tokens). The agent run exits
145+
* cleanly without firing turn hooks. Normal pure-text and
146+
* tool-call finishes go through `kind: "handover"` with the
147+
* appropriate `isFinal` flag.
148+
*/
149+
kind: "handover-skip";
86150
};
87151

88152
/**

0 commit comments

Comments
 (0)