Skip to content

Commit 17482c0

Browse files
ericallamsaasjesus
andauthored
feat(sdk): chat.headStart handover for customAgent and createSession (#3963)
## Summary `chat.headStart` (the warm step-1 fast path) previously handed its response over only to `chat.agent`. This extends handover to the other two backends: `chat.customAgent` consumes it with `conversation.consumeHandover({ payload })` on turn 0, and `chat.createSession` surfaces it as `turn.handover` (call `turn.complete()` with no source to finalize a pure-text handover). The low-level `chat.waitForHandover()` and `accumulator.applyHandover()` are exported for hand-rolled loops. It also adds `triggerConfig` to `chat.headStart()` and `chat.openSession()`, so the auto-triggered handover-prepare run inherits tags, queue, machine, and the other session run options the same way `chat.createStartSessionAction()` does. The `chat:{chatId}` tag is prepended automatically. Because the session is created once on the first head-start turn (idempotent on the chat id), this is the only place those options can be set for a head-start chat's lifetime. ## Fix: tool-call resume When the warm step-1 hands over a pending tool call (rather than pure text), the agent loop resumes that tool round. For it to merge cleanly the pipe threads the spliced partial as `originalMessages`, so the resumed tool-output chunk attaches to the handed-over tool-call instead of throwing `No tool invocation found`. `MessageAccumulator.addResponse` now also dedups by id (replace-in-place), so the persisted history doesn't carry a duplicate assistant message when the resumed response reuses the partial's id. Incorporates the `triggerConfig` work from [#3933](#3933) by @saasjesus, with `createStartSessionAction` extended to also forward `maxDuration`, `region`, and `lockToVersion` so the two session entry points stay consistent. Verified end-to-end against a local environment: handover (pure-text and tool-call) on both new backends, a `chat.agent` regression pass, and `triggerConfig` tags and queue landing on the run. --------- Co-authored-by: saasjesus <armin@chatarmin.com>
1 parent 002b845 commit 17482c0

7 files changed

Lines changed: 838 additions & 34 deletions

File tree

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
---
2+
"@trigger.dev/sdk": patch
3+
---
4+
5+
`chat.headStart` now works with the `chat.customAgent` and `chat.createSession` backends, not only `chat.agent`. The warm step-1 response hands over to your loop the same way it does for a managed agent.
6+
7+
In a `chat.customAgent` loop, consume the handover on turn 0:
8+
9+
```ts
10+
const conversation = new chat.MessageAccumulator();
11+
const { isFinal, skipped } = await conversation.consumeHandover({ payload });
12+
if (skipped) return; // warm handler aborted, so exit without a turn
13+
if (isFinal) {
14+
await chat.writeTurnComplete(); // step 1 is the response, no streamText
15+
} else {
16+
const result = streamText({ model, messages: conversation.modelMessages, tools });
17+
// Pass originalMessages so the handed-over tool round merges into the
18+
// step-1 assistant instead of starting a new message.
19+
const response = await chat.pipeAndCapture(result, {
20+
originalMessages: conversation.uiMessages,
21+
});
22+
if (response) await conversation.addResponse(response);
23+
}
24+
```
25+
26+
With `chat.createSession`, the iterator surfaces it as `turn.handover`; call `turn.complete()` with no argument on a final handover. The lower-level `chat.waitForHandover()` and `accumulator.applyHandover()` are also exported for hand-rolled loops.
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
---
2+
"@trigger.dev/sdk": patch
3+
---
4+
5+
Add `triggerConfig` support to `chat.headStart()` and `chat.openSession()`, so the auto-triggered handover-prepare run inherits tags, queue, machine, and other session trigger options the same way `chat.createStartSessionAction()` does. The `chat:{chatId}` tag is prepended automatically.
6+
7+
```ts
8+
export const POST = chat.headStart({
9+
agentId: "my-agent",
10+
triggerConfig: { tags: ["org:acme"], queue: "chat" },
11+
run: async ({ chat }) => streamText({ ...chat.toStreamTextOptions(), model }),
12+
});
13+
```
14+
15+
Because the session is created once on the first head-start turn and is idempotent on the chat id, this is the only place to set those options for a head-start chat's lifetime. `chat.createStartSessionAction()` now also forwards `maxDuration`, `region`, and `lockToVersion` so both session entry points stay consistent.

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

Lines changed: 242 additions & 24 deletions
Large diffs are not rendered by default.

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

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -216,6 +216,67 @@ describe("chat.headStart (route handler)", () => {
216216
expect(body.triggerConfig.basePayload.idleTimeoutInSeconds).toBe(60);
217217
});
218218

219+
it("merges triggerConfig tags and queue into createSession", async () => {
220+
const requests: CapturedRequest[] = [];
221+
global.fetch = vi.fn().mockImplementation(async (url: string | URL, init?: RequestInit) => {
222+
const urlStr = typeof url === "string" ? url : url.toString();
223+
requests.push({ url: urlStr, init });
224+
if (urlStr.endsWith("/api/v1/sessions") || urlStr.endsWith("/api/v1/sessions/")) {
225+
return createSessionResponse("chat-1");
226+
}
227+
if (urlStr.includes("/realtime/v1/sessions/") && urlStr.endsWith("/in/append")) {
228+
return appendOkResponse();
229+
}
230+
if (/\/realtime\/v1\/sessions\/[^/]+\/out$/.test(urlStr)) {
231+
return new Response(new ReadableStream({ start(c) { c.close(); } }), {
232+
status: 200,
233+
headers: { "content-type": "text/event-stream" },
234+
});
235+
}
236+
throw new Error(`Unexpected URL: ${urlStr}`);
237+
});
238+
239+
const handler = chat.headStart({
240+
agentId: "test-agent",
241+
triggerConfig: {
242+
tags: ["org:acme", "agentic-run:xyz"],
243+
queue: "my-queue",
244+
},
245+
run: async ({ chat: chatHelper }) => {
246+
return streamText({
247+
...chatHelper.toStreamTextOptions(),
248+
model: new MockLanguageModelV3({
249+
doStream: async () => ({ stream: textStream("hi back") }),
250+
}),
251+
});
252+
},
253+
});
254+
255+
await withApiContext(() =>
256+
handler(
257+
makeRequest({
258+
chatId: "chat-1",
259+
trigger: "submit-message",
260+
headStartMessages: [{ id: "m1", role: "user", parts: [{ type: "text", text: "hi" }] }],
261+
})
262+
)
263+
);
264+
265+
const sessionCreate = requests.find((r) =>
266+
r.url.endsWith("/api/v1/sessions") || r.url.endsWith("/api/v1/sessions/")
267+
);
268+
expect(sessionCreate).toBeDefined();
269+
const body = JSON.parse(sessionCreate!.init!.body as string);
270+
expect(body.triggerConfig.tags).toEqual([
271+
"chat:chat-1",
272+
"org:acme",
273+
"agentic-run:xyz",
274+
]);
275+
expect(body.triggerConfig.queue).toBe("my-queue");
276+
expect(body.triggerConfig.basePayload.trigger).toBe("handover-prepare");
277+
expect(body.triggerConfig.basePayload.chatId).toBe("chat-1");
278+
});
279+
219280
it("dispatches handover with isFinal=true on pure-text finishReason", async () => {
220281
const requests: CapturedRequest[] = [];
221282
global.fetch = vi.fn().mockImplementation(async (url: string | URL, init?: RequestInit) => {

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

Lines changed: 44 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,7 @@ import {
5959
SessionStreamInstance,
6060
TRIGGER_CONTROL_SUBTYPE,
6161
apiClientManager,
62+
type SessionTriggerConfig,
6263
} from "@trigger.dev/core/v3";
6364
// Runtime VALUES via the ESM/CJS shim so the CJS build can `require` ESM-only
6465
// `ai@7` (see ../imports/ai-runtime.ts).
@@ -195,6 +196,12 @@ export type HeadStartHandlerOptions<TTools extends Record<string, Tool>> = {
195196
* exiting. Defaults to 60.
196197
*/
197198
idleTimeoutInSeconds?: number;
199+
/**
200+
* Run options for the auto-triggered `handover-prepare` session run —
201+
* tags, queue, machine, etc. Mirrors `chat.createStartSessionAction`.
202+
* The `chat:{chatId}` tag is prepended automatically.
203+
*/
204+
triggerConfig?: Partial<SessionTriggerConfig>;
198205
};
199206

200207
// ---------------------------------------------------------------------------
@@ -220,6 +227,7 @@ export const chat = {
220227
req,
221228
agentId: opts.agentId,
222229
idleTimeoutInSeconds: opts.idleTimeoutInSeconds,
230+
triggerConfig: opts.triggerConfig,
223231
});
224232

225233
const helper: HeadStartChatHelper<TTools> = {
@@ -249,6 +257,7 @@ export const chat = {
249257
req: Request;
250258
agentId: string;
251259
idleTimeoutInSeconds?: number;
260+
triggerConfig?: Partial<SessionTriggerConfig>;
252261
}): Promise<HeadStartSession> {
253262
return openHandoverSession(opts).then((s) => s.handle);
254263
},
@@ -304,6 +313,7 @@ async function openHandoverSession(opts: {
304313
req: Request;
305314
agentId: string;
306315
idleTimeoutInSeconds?: number;
316+
triggerConfig?: Partial<SessionTriggerConfig>;
307317
}): Promise<InternalSession> {
308318
const wirePayload = (await opts.req.json()) as ChatTaskWirePayload;
309319
const chatId = wirePayload.chatId;
@@ -323,7 +333,39 @@ async function openHandoverSession(opts: {
323333
const modelMessages = await convertToModelMessages(uiMessages);
324334

325335
const apiClient = resolveApiClient();
326-
const idleTimeoutInSeconds = opts.idleTimeoutInSeconds ?? 60;
336+
// Top-level `idleTimeoutInSeconds` wins over the one in `triggerConfig`.
337+
const idleTimeoutInSeconds =
338+
opts.idleTimeoutInSeconds ?? opts.triggerConfig?.idleTimeoutInSeconds ?? 60;
339+
340+
// Merge the customer's trigger options. `handover-prepare` and `chatId` in
341+
// `basePayload` are ours and can't be overridden; the `chat:{chatId}` tag is
342+
// prepended (SessionTriggerConfig.tags caps at 5).
343+
const userTags = opts.triggerConfig?.tags ?? [];
344+
const tags = [`chat:${chatId}`, ...userTags].slice(0, 5);
345+
346+
const triggerConfig: SessionTriggerConfig = {
347+
basePayload: {
348+
...(opts.triggerConfig?.basePayload ?? {}),
349+
...wirePayload,
350+
chatId,
351+
trigger: "handover-prepare",
352+
idleTimeoutInSeconds,
353+
},
354+
...(opts.triggerConfig?.machine ? { machine: opts.triggerConfig.machine } : {}),
355+
...(opts.triggerConfig?.queue ? { queue: opts.triggerConfig.queue } : {}),
356+
tags,
357+
...(opts.triggerConfig?.maxAttempts !== undefined
358+
? { maxAttempts: opts.triggerConfig.maxAttempts }
359+
: {}),
360+
...(opts.triggerConfig?.maxDuration !== undefined
361+
? { maxDuration: opts.triggerConfig.maxDuration }
362+
: {}),
363+
...(opts.triggerConfig?.region ? { region: opts.triggerConfig.region } : {}),
364+
...(opts.triggerConfig?.lockToVersion
365+
? { lockToVersion: opts.triggerConfig.lockToVersion }
366+
: {}),
367+
idleTimeoutInSeconds,
368+
};
327369

328370
// Create the session and trigger the chat.agent's `handover-prepare`
329371
// run atomically. `createSession` is idempotent on `(env, externalId
@@ -342,15 +384,7 @@ async function openHandoverSession(opts: {
342384
type: "chat.agent",
343385
externalId: chatId,
344386
taskIdentifier: opts.agentId,
345-
triggerConfig: {
346-
basePayload: {
347-
...wirePayload,
348-
chatId,
349-
trigger: "handover-prepare",
350-
idleTimeoutInSeconds,
351-
},
352-
idleTimeoutInSeconds,
353-
},
387+
triggerConfig,
354388
});
355389
const sessionPublicAccessToken = created.publicAccessToken;
356390

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

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -96,6 +96,42 @@ describe("chat.createStartSessionAction — runtime", () => {
9696
expect(lastStartBody?.triggerConfig.basePayload).not.toHaveProperty("metadata");
9797
});
9898

99+
it("prepends chat:{chatId} to triggerConfig.tags and caps at 5", async () => {
100+
installStartFixture();
101+
102+
const start = chat.createStartSessionAction("fake-chat", {
103+
triggerConfig: {
104+
tags: ["org:acme", "a", "b", "c", "d", "e"],
105+
},
106+
});
107+
await start({ chatId: "chat-tags" });
108+
109+
expect(lastStartBody?.triggerConfig.tags).toEqual([
110+
"chat:chat-tags",
111+
"org:acme",
112+
"a",
113+
"b",
114+
"c",
115+
]);
116+
});
117+
118+
it("forwards maxDuration, region, and lockToVersion from triggerConfig", async () => {
119+
installStartFixture();
120+
121+
const start = chat.createStartSessionAction("fake-chat", {
122+
triggerConfig: {
123+
maxDuration: 120,
124+
region: "us-east-1",
125+
lockToVersion: "20260101.1",
126+
},
127+
});
128+
await start({ chatId: "chat-parity" });
129+
130+
expect(lastStartBody?.triggerConfig.maxDuration).toBe(120);
131+
expect(lastStartBody?.triggerConfig.region).toBe("us-east-1");
132+
expect(lastStartBody?.triggerConfig.lockToVersion).toBe("20260101.1");
133+
});
134+
99135
it("keeps session-level metadata distinct from per-turn clientData", async () => {
100136
installStartFixture();
101137

0 commit comments

Comments
 (0)