Skip to content

Commit 266d394

Browse files
committed
feat(sdk): surface a parked agent run to Head Start callers
A Head Start turn splits across two processes: step 1 runs in the warm server, step 2 in the agent run. When the run is pinned to a deployment that has not landed yet it parks, so step 1 streams normally and step 2 arrives late, with nothing saying why. `pendingVersion` now reaches all three Head Start shapes: - the transport emits `run-pending-version` with a new `head-start` source, read from an `X-Trigger-Chat-Pending-Version` header the handler sets only when parked - `chat.startHeadStart` returns it, for the detached flow that has no open browser connection to emit into - the `chat.handover` session handle exposes it, for callers that build their own response Nothing about the wait changes: the handover signal is durable, the agent's idle timeout starts when the run boots rather than when it was triggered, and a fresh run reads `session.in` from the beginning, so a parked turn resumes at step 2 once the deployment lands.
1 parent fa56d28 commit 266d394

8 files changed

Lines changed: 138 additions & 6 deletions

File tree

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
---
2+
"@trigger.dev/sdk": patch
3+
---
4+
5+
Head Start now tells you when the agent run it handed over to is waiting on a deployment that is still building. Step 1 always streams from your warm process, so the wait only affects step 2, and it used to be invisible: the transport now emits `run-pending-version` with `source: "head-start"`, `chat.startHeadStart` returns `pendingVersion`, and the `chat.handover` session handle exposes it too.
6+
7+
```tsx
8+
onEvent: (event) => {
9+
if (event.type === "run-pending-version") setDeploying(true);
10+
if (event.type === "first-chunk") setDeploying(false);
11+
},
12+
```

docs/ai-chat/fast-starts.mdx

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -735,11 +735,13 @@ chat.startHeadStart<TTools>({
735735
triggerConfig?: Partial<SessionTriggerConfig>, // tags, queue, machine, …
736736
apiClient?: ApiClientConfiguration, // when the agent lives in another project/env
737737
metadata?: Record<string, unknown>, // merged into the run payload; never sent to the browser
738-
}): Promise<{ chatId: string; completion: Promise<void> }>
738+
}): Promise<{ chatId: string; pendingVersion: boolean; completion: Promise<void> }>
739739
```
740740

741741
`completion` resolves once the head start finishes; `await` it or hand it to `waitUntil`. It rejects if the warm step or the dispatch fails.
742742

743+
`pendingVersion` is `true` when the agent run is parked waiting for the deployment carrying the session's [external deployment id](/deployment/version-skew-protection#chat-sessions). Step 1 still runs in your process and still reaches the browser, so pass the flag to the destination page if you want it to say a deploy is in progress rather than appear to stall on step 2.
744+
743745
### Limitations
744746

745747
- **First turn only.** Step 2+ and turn 2+ run on the trigger side. There's no per-turn "head start every turn" mode — the win comes from amortizing agent boot across the LLM call once.

docs/ai-chat/reference.mdx

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -492,7 +492,7 @@ Options for [`chat.headStart()`](/ai-chat/fast-starts#head-start), the warm-serv
492492
| `idleTimeoutInSeconds` | `number` | `60` | How long the agent waits for the handover signal |
493493
| `triggerConfig` | `Partial<SessionTriggerConfig>` | `undefined` | Run options (tags, queue, machine, maxAttempts, maxDuration, region, lockToVersion, externalDeploymentId) for the auto-triggered handover-prepare run. The `chat:{chatId}` tag is prepended automatically |
494494

495-
`chat.headStart(options)` returns the handler `(req: Request) => Promise<Response>`. The `run` callback receives `HeadStartRunArgs`: `{ messages: UIMessage[], signal: AbortSignal, chat: HeadStartChatHelper }`, where the helper exposes `chat.toStreamTextOptions({ tools })` and a `chat.session` escape hatch. See [Head Start](/ai-chat/fast-starts#head-start) for the full guide.
495+
`chat.headStart(options)` returns the handler `(req: Request) => Promise<Response>`. The `run` callback receives `HeadStartRunArgs`: `{ messages: UIMessage[], signal: AbortSignal, chat: HeadStartChatHelper }`, where the helper exposes `chat.toStreamTextOptions({ tools })` and a `chat.session` escape hatch (whose `pendingVersion` says whether the agent run is parked waiting for its deployment). See [Head Start](/ai-chat/fast-starts#head-start) for the full guide.
496496

497497
## chat namespace
498498

@@ -643,6 +643,7 @@ The `onEvent` callback receives a `ChatTransportEvent` (exported from `@trigger.
643643
| --- | --- | --- |
644644
| `message-sent` | `messageId?`, `source`, `durationMs`, `partId?`, `bodyBytes?` | A send was durably acknowledged — a 2xx from the session input stream append (or the `headStart` POST), after any internal token-refresh retries. This means the message is durably written to the stream the agent consumes from, not merely "request accepted". `partId` is the append's idempotency key, also stored on the server-side record. |
645645
| `message-send-failed` | `messageId?`, `source`, `error`, `status?`, `durationMs`, `partId?`, `bodyBytes?` | A send definitively failed after internal retries. Fires in addition to `useChat`'s `onError`. |
646+
| `run-pending-version` | `source` | The chat's run is parked waiting for the deployment carrying its external deployment id ([version skew protection](/deployment/version-skew-protection#chat-sessions)). Everything already sent is durable and answered once the deployment lands. `source` is `"start"` (learned while starting the session), `"send"` (from a message append, re-emitted on every send while parked) or `"head-start"` (from the `headStart` POST, where step 1 still streams from your server and only step 2 waits). |
646647
| `stream-connected` | `resumed`, `lastEventId?`, `messageId?` | The SSE subscription to the session's output stream started delivering. `resumed: true` when reconnecting from a stored cursor (page reload) rather than following a fresh send. `lastEventId` is the cursor it connected from. |
647648
| `first-chunk` | `chunkType?`, `lastEventId?`, `messageId?`, `sinceSendMs?` | The first response chunk of a turn arrived. `sinceSendMs` is the delta from the last turn-producing send — time to first token without any bookkeeping. |
648649
| `turn-completed` | `lastEventId?`, `sessionInEventId?`, `messageId?`, `sinceSendMs?` | The agent's turn-complete control record arrived — the "finished answering" signal. `sinceSendMs` is the full turn latency; `sessionInEventId` is the agent's committed input-stream cursor. |

docs/deployment/version-skew-protection.mdx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -314,6 +314,8 @@ const transport = useTriggerChatTransport({
314314
315315
The event repeats on every message sent while the chat is parked, so a notice driven off it stays accurate.
316316
317+
[Head Start](/ai-chat/fast-starts#head-start) softens this considerably: turn 1 runs in your own warm process, so a parked deployment costs nothing until step 2. The handover signal is durable, so the agent picks the turn up where it left off once the deployment lands. The transport emits `run-pending-version` with `source: "head-start"` for that case, and `chat.startHeadStart` returns `pendingVersion` for the detached flow.
318+
317319
### Opting a chat out
318320
319321
Pass `null` and that chat is never pinned, whatever the environment says:

packages/trigger-sdk/src/v3/chat-server.test.ts

Lines changed: 55 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -89,7 +89,7 @@ function makeRequest(body: unknown): Request {
8989

9090
const SESSION_PAT = "tr_session_pat_for_handover";
9191

92-
function createSessionResponse(externalId: string): Response {
92+
function createSessionResponse(externalId: string, opts?: { pendingVersion?: boolean }): Response {
9393
return new Response(
9494
JSON.stringify({
9595
id: "session_test",
@@ -111,6 +111,7 @@ function createSessionResponse(externalId: string): Response {
111111
createdAt: new Date(0).toISOString(),
112112
updatedAt: new Date(0).toISOString(),
113113
isCached: false,
114+
...(opts?.pendingVersion ? { pendingVersion: true } : {}),
114115
}),
115116
{
116117
status: 200,
@@ -160,6 +161,57 @@ describe("chat.headStart (route handler)", () => {
160161
vi.restoreAllMocks();
161162
});
162163

164+
it("reports a parked agent run in the response headers", async () => {
165+
global.fetch = vi.fn().mockImplementation(async (url: string | URL) => {
166+
const urlStr = typeof url === "string" ? url : url.toString();
167+
if (urlStr.endsWith("/api/v1/sessions") || urlStr.endsWith("/api/v1/sessions/")) {
168+
return createSessionResponse("chat-parked", { pendingVersion: true });
169+
}
170+
if (urlStr.includes("/realtime/v1/sessions/") && urlStr.endsWith("/in/append")) {
171+
return appendOkResponse();
172+
}
173+
// Stitched response subscribes to `.out` after handover.
174+
if (/\/realtime\/v1\/sessions\/[^/]+\/out$/.test(urlStr)) {
175+
return new Response(
176+
new ReadableStream({
177+
start(c) {
178+
c.close();
179+
},
180+
}),
181+
{ status: 200, headers: { "content-type": "text/event-stream" } }
182+
);
183+
}
184+
throw new Error(`Unexpected URL: ${urlStr}`);
185+
});
186+
187+
const handler = chat.headStart({
188+
agentId: "test-agent",
189+
run: async ({ chat: chatHelper }) =>
190+
streamText({
191+
...chatHelper.toStreamTextOptions(),
192+
model: new MockLanguageModelV3({
193+
doStream: async () => ({ stream: textStream("step 1 while parked") }),
194+
}),
195+
}),
196+
});
197+
198+
const res = await withApiContext(() =>
199+
handler(
200+
makeRequest({
201+
chatId: "chat-parked",
202+
trigger: "submit-message",
203+
headStartMessages: [{ id: "m1", role: "user", parts: [{ type: "text", text: "hi" }] }],
204+
})
205+
)
206+
);
207+
208+
// Step 1 still streams from this process even though nothing can answer step 2 yet.
209+
expect(res.status).toBe(200);
210+
expect(res.headers.get("X-Trigger-Chat-Pending-Version")).toBe("1");
211+
const chunks = await readSSEBodyToChunks(res);
212+
expect(chunks.length).toBeGreaterThan(0);
213+
});
214+
163215
it("creates the session with handover-prepare in basePayload and returns the session PAT in headers", async () => {
164216
const requests: CapturedRequest[] = [];
165217
global.fetch = vi.fn().mockImplementation(async (url: string | URL, init?: RequestInit) => {
@@ -200,6 +252,8 @@ describe("chat.headStart (route handler)", () => {
200252
expect(res.headers.get("X-Trigger-Chat-Id")).toBe("chat-1");
201253
expect(res.headers.get("X-Trigger-Chat-Access-Token")).toBe(SESSION_PAT);
202254
expect(res.headers.get("Content-Type")).toMatch(/text\/event-stream/);
255+
// Not parked, so the header is absent rather than "0".
256+
expect(res.headers.get("X-Trigger-Chat-Pending-Version")).toBeNull();
203257

204258
const sessionCreate = requests.find(
205259
(r) => r.url.endsWith("/api/v1/sessions") || r.url.endsWith("/api/v1/sessions/")

packages/trigger-sdk/src/v3/chat-server.ts

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -141,6 +141,11 @@ export type HeadStartChatHelper<TTools extends Record<string, Tool>> = {
141141

142142
export type HeadStartSession = {
143143
readonly chatId: string;
144+
/**
145+
* The agent run is parked waiting for a deployment carrying the session's external deployment
146+
* id. Step 1 still streams from this process; step 2 lands once the deployment does.
147+
*/
148+
readonly pendingVersion: boolean;
144149
/**
145150
* Tees a UIMessage stream into `session.out` for durability/resume,
146151
* fire-and-forget. Returns a passthrough that the caller can use as
@@ -235,6 +240,8 @@ export type StartHeadStartOptions<TTools extends Record<string, Tool>> = {
235240
export type StartHeadStartResult = {
236241
/** The chat id you passed in — echoed for convenience. */
237242
chatId: string;
243+
/** See {@link HeadStartSession.pendingVersion}. */
244+
pendingVersion: boolean;
238245
/**
239246
* Resolves once step 1 has drained to `session.out` and the handover is
240247
* dispatched. Hand to `waitUntil` / `after` on serverless; ignore it on a
@@ -389,7 +396,7 @@ export const chat = {
389396
// returned promise still surfaces the error.
390397
completion.catch(() => {});
391398

392-
return { chatId: opts.chatId, completion };
399+
return { chatId: opts.chatId, pendingVersion: session.handle.pendingVersion, completion };
393400
},
394401

395402
/**
@@ -584,6 +591,7 @@ async function openHandoverSession(opts: {
584591
})
585592
);
586593
const sessionPublicAccessToken = created.publicAccessToken;
594+
const pendingVersion = created.pendingVersion === true;
587595

588596
// Combined abort signal: request lifecycle OR an internal timeout
589597
// mirroring the agent's idle wait so a hung handler doesn't sit
@@ -967,12 +975,15 @@ async function openHandoverSession(opts: {
967975
// without going back through the handler.
968976
"X-Trigger-Chat-Id": chatId,
969977
"X-Trigger-Chat-Access-Token": sessionPublicAccessToken,
978+
// Only sent when parked, so an unpinned chat's headers are unchanged.
979+
...(pendingVersion ? { "X-Trigger-Chat-Pending-Version": "1" } : {}),
970980
},
971981
});
972982
};
973983

974984
const handle: HeadStartSession = {
975985
chatId,
986+
pendingVersion,
976987
tee,
977988
handoverWhenDone,
978989
handoverResponse,

packages/trigger-sdk/src/v3/chat.test.ts

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1871,17 +1871,56 @@ describe("TriggerChatTransport", () => {
18711871
chatId: string;
18721872
accessToken: string;
18731873
chunks: UIMessageChunk[];
1874+
pendingVersion?: boolean;
18741875
}): Response {
18751876
return new Response(handoverSseBody(args.chunks), {
18761877
status: 200,
18771878
headers: {
18781879
"content-type": "text/event-stream",
18791880
"X-Trigger-Chat-Id": args.chatId,
18801881
"X-Trigger-Chat-Access-Token": args.accessToken,
1882+
...(args.pendingVersion ? { "X-Trigger-Chat-Pending-Version": "1" } : {}),
18811883
},
18821884
});
18831885
}
18841886

1887+
it("emits run-pending-version when the handover endpoint reports a parked run", async () => {
1888+
global.fetch = vi.fn().mockImplementation(async (url: string | URL) => {
1889+
const urlStr = typeof url === "string" ? url : url.toString();
1890+
if (urlStr === "https://my-app.example/api/chat") {
1891+
return handoverResponse({
1892+
chatId: "chat-handover-parked",
1893+
accessToken: "handover-pat-parked",
1894+
chunks: sampleChunks,
1895+
pendingVersion: true,
1896+
});
1897+
}
1898+
throw new Error(`Unexpected URL: ${urlStr}`);
1899+
});
1900+
1901+
const events: ChatTransportEvent[] = [];
1902+
const transport = new TriggerChatTransport({
1903+
task: "my-chat-task",
1904+
accessToken: () => "pat",
1905+
headStart: "https://my-app.example/api/chat",
1906+
onEvent: (event) => events.push(event),
1907+
});
1908+
1909+
const stream = await transport.sendMessages({
1910+
trigger: "submit-message",
1911+
chatId: "chat-handover-parked",
1912+
messageId: "m1",
1913+
messages: [createUserMessage("hello")],
1914+
abortSignal: undefined,
1915+
});
1916+
// Step 1 still arrives from the warm server.
1917+
expect(await drainChunks(stream)).toEqual(sampleChunks);
1918+
1919+
const parked = events.filter((e) => e.type === "run-pending-version");
1920+
expect(parked).toHaveLength(1);
1921+
expect(parked[0]).toMatchObject({ chatId: "chat-handover-parked", source: "head-start" });
1922+
});
1923+
18851924
it("first-turn POSTs the wire payload to endpoint when no session exists", async () => {
18861925
const requests: Array<{ url: string; init?: RequestInit }> = [];
18871926
global.fetch = vi.fn().mockImplementation(async (url: string | URL, init?: RequestInit) => {

packages/trigger-sdk/src/v3/chat.ts

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -234,8 +234,8 @@ export type ChatTransportEvent =
234234
type: "run-pending-version";
235235
chatId: string;
236236
timestamp: number;
237-
/** Whether we learned this from starting the session or from sending a message. */
238-
source: "start" | "send";
237+
/** Whether we learned this from starting the session, a send, or the `headStart` POST. */
238+
source: "start" | "send" | "head-start";
239239
}
240240
| {
241241
type: "message-sent";
@@ -983,6 +983,17 @@ export class TriggerChatTransport implements ChatTransport<UIMessage> {
983983
this.sessions.set(chatId, state);
984984
this.notifySessionChange(chatId, state);
985985

986+
// Step 1 streams from the warm server either way; this says the agent run that owes step 2
987+
// is parked on an undeployed external deployment id.
988+
if (response.headers.get("X-Trigger-Chat-Pending-Version") === "1") {
989+
this.emitEvent({
990+
type: "run-pending-version",
991+
chatId,
992+
timestamp: Date.now(),
993+
source: "head-start",
994+
});
995+
}
996+
986997
// Filter the parsed UIMessage stream:
987998
// - Drop control chunks (`trigger:turn-complete`,
988999
// `trigger:session-state`) before they reach AI SDK — they

0 commit comments

Comments
 (0)