Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 26 additions & 0 deletions .changeset/chat-headstart-custom-backends.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
---
"@trigger.dev/sdk": patch
---

`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.

In a `chat.customAgent` loop, consume the handover on turn 0:

```ts
const conversation = new chat.MessageAccumulator();
const { isFinal, skipped } = await conversation.consumeHandover({ payload });
if (skipped) return; // warm handler aborted, so exit without a turn
if (isFinal) {
await chat.writeTurnComplete(); // step 1 is the response, no streamText
} else {
const result = streamText({ model, messages: conversation.modelMessages, tools });
// Pass originalMessages so the handed-over tool round merges into the
// step-1 assistant instead of starting a new message.
const response = await chat.pipeAndCapture(result, {
originalMessages: conversation.uiMessages,
});
if (response) await conversation.addResponse(response);
}
```

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.
15 changes: 15 additions & 0 deletions .changeset/chat-headstart-trigger-config.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
---
"@trigger.dev/sdk": patch
---

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.

```ts
export const POST = chat.headStart({
agentId: "my-agent",
triggerConfig: { tags: ["org:acme"], queue: "chat" },
run: async ({ chat }) => streamText({ ...chat.toStreamTextOptions(), model }),
});
```

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.
266 changes: 242 additions & 24 deletions packages/trigger-sdk/src/v3/ai.ts
Comment thread
ericallam marked this conversation as resolved.

Large diffs are not rendered by default.

61 changes: 61 additions & 0 deletions packages/trigger-sdk/src/v3/chat-server.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -216,6 +216,67 @@ describe("chat.headStart (route handler)", () => {
expect(body.triggerConfig.basePayload.idleTimeoutInSeconds).toBe(60);
});

it("merges triggerConfig tags and queue into createSession", async () => {
const requests: CapturedRequest[] = [];
global.fetch = vi.fn().mockImplementation(async (url: string | URL, init?: RequestInit) => {
const urlStr = typeof url === "string" ? url : url.toString();
requests.push({ url: urlStr, init });
if (urlStr.endsWith("/api/v1/sessions") || urlStr.endsWith("/api/v1/sessions/")) {
return createSessionResponse("chat-1");
}
if (urlStr.includes("/realtime/v1/sessions/") && urlStr.endsWith("/in/append")) {
return appendOkResponse();
}
if (/\/realtime\/v1\/sessions\/[^/]+\/out$/.test(urlStr)) {
return new Response(new ReadableStream({ start(c) { c.close(); } }), {
status: 200,
headers: { "content-type": "text/event-stream" },
});
}
throw new Error(`Unexpected URL: ${urlStr}`);
});

const handler = chat.headStart({
agentId: "test-agent",
triggerConfig: {
tags: ["org:acme", "agentic-run:xyz"],
queue: "my-queue",
},
run: async ({ chat: chatHelper }) => {
return streamText({
...chatHelper.toStreamTextOptions(),
model: new MockLanguageModelV3({
doStream: async () => ({ stream: textStream("hi back") }),
}),
});
},
});

await withApiContext(() =>
handler(
makeRequest({
chatId: "chat-1",
trigger: "submit-message",
headStartMessages: [{ id: "m1", role: "user", parts: [{ type: "text", text: "hi" }] }],
})
)
);

const sessionCreate = requests.find((r) =>
r.url.endsWith("/api/v1/sessions") || r.url.endsWith("/api/v1/sessions/")
);
expect(sessionCreate).toBeDefined();
const body = JSON.parse(sessionCreate!.init!.body as string);
expect(body.triggerConfig.tags).toEqual([
"chat:chat-1",
"org:acme",
"agentic-run:xyz",
]);
expect(body.triggerConfig.queue).toBe("my-queue");
expect(body.triggerConfig.basePayload.trigger).toBe("handover-prepare");
expect(body.triggerConfig.basePayload.chatId).toBe("chat-1");
});

it("dispatches handover with isFinal=true on pure-text finishReason", async () => {
const requests: CapturedRequest[] = [];
global.fetch = vi.fn().mockImplementation(async (url: string | URL, init?: RequestInit) => {
Expand Down
54 changes: 44 additions & 10 deletions packages/trigger-sdk/src/v3/chat-server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,7 @@ import {
SessionStreamInstance,
TRIGGER_CONTROL_SUBTYPE,
apiClientManager,
type SessionTriggerConfig,
} from "@trigger.dev/core/v3";
// Runtime VALUES via the ESM/CJS shim so the CJS build can `require` ESM-only
// `ai@7` (see ../imports/ai-runtime.ts).
Expand Down Expand Up @@ -195,6 +196,12 @@ export type HeadStartHandlerOptions<TTools extends Record<string, Tool>> = {
* exiting. Defaults to 60.
*/
idleTimeoutInSeconds?: number;
/**
* Run options for the auto-triggered `handover-prepare` session run —
* tags, queue, machine, etc. Mirrors `chat.createStartSessionAction`.
* The `chat:{chatId}` tag is prepended automatically.
*/
triggerConfig?: Partial<SessionTriggerConfig>;
};

// ---------------------------------------------------------------------------
Expand All @@ -220,6 +227,7 @@ export const chat = {
req,
agentId: opts.agentId,
idleTimeoutInSeconds: opts.idleTimeoutInSeconds,
triggerConfig: opts.triggerConfig,
});

const helper: HeadStartChatHelper<TTools> = {
Expand Down Expand Up @@ -249,6 +257,7 @@ export const chat = {
req: Request;
agentId: string;
idleTimeoutInSeconds?: number;
triggerConfig?: Partial<SessionTriggerConfig>;
}): Promise<HeadStartSession> {
return openHandoverSession(opts).then((s) => s.handle);
},
Expand Down Expand Up @@ -304,6 +313,7 @@ async function openHandoverSession(opts: {
req: Request;
agentId: string;
idleTimeoutInSeconds?: number;
triggerConfig?: Partial<SessionTriggerConfig>;
}): Promise<InternalSession> {
const wirePayload = (await opts.req.json()) as ChatTaskWirePayload;
const chatId = wirePayload.chatId;
Expand All @@ -323,7 +333,39 @@ async function openHandoverSession(opts: {
const modelMessages = await convertToModelMessages(uiMessages);

const apiClient = resolveApiClient();
const idleTimeoutInSeconds = opts.idleTimeoutInSeconds ?? 60;
// Top-level `idleTimeoutInSeconds` wins over the one in `triggerConfig`.
const idleTimeoutInSeconds =
opts.idleTimeoutInSeconds ?? opts.triggerConfig?.idleTimeoutInSeconds ?? 60;

// Merge the customer's trigger options. `handover-prepare` and `chatId` in
// `basePayload` are ours and can't be overridden; the `chat:{chatId}` tag is
// prepended (SessionTriggerConfig.tags caps at 5).
const userTags = opts.triggerConfig?.tags ?? [];
const tags = [`chat:${chatId}`, ...userTags].slice(0, 5);

const triggerConfig: SessionTriggerConfig = {
basePayload: {
...(opts.triggerConfig?.basePayload ?? {}),
...wirePayload,
chatId,
trigger: "handover-prepare",
idleTimeoutInSeconds,
},
...(opts.triggerConfig?.machine ? { machine: opts.triggerConfig.machine } : {}),
...(opts.triggerConfig?.queue ? { queue: opts.triggerConfig.queue } : {}),
tags,
...(opts.triggerConfig?.maxAttempts !== undefined
? { maxAttempts: opts.triggerConfig.maxAttempts }
: {}),
...(opts.triggerConfig?.maxDuration !== undefined
? { maxDuration: opts.triggerConfig.maxDuration }
: {}),
...(opts.triggerConfig?.region ? { region: opts.triggerConfig.region } : {}),
...(opts.triggerConfig?.lockToVersion
? { lockToVersion: opts.triggerConfig.lockToVersion }
: {}),
idleTimeoutInSeconds,
};

// Create the session and trigger the chat.agent's `handover-prepare`
// run atomically. `createSession` is idempotent on `(env, externalId
Expand All @@ -342,15 +384,7 @@ async function openHandoverSession(opts: {
type: "chat.agent",
externalId: chatId,
taskIdentifier: opts.agentId,
triggerConfig: {
basePayload: {
...wirePayload,
chatId,
trigger: "handover-prepare",
idleTimeoutInSeconds,
},
idleTimeoutInSeconds,
},
triggerConfig,
});
const sessionPublicAccessToken = created.publicAccessToken;

Expand Down
36 changes: 36 additions & 0 deletions packages/trigger-sdk/src/v3/createStartSessionAction.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,42 @@ describe("chat.createStartSessionAction — runtime", () => {
expect(lastStartBody?.triggerConfig.basePayload).not.toHaveProperty("metadata");
});

it("prepends chat:{chatId} to triggerConfig.tags and caps at 5", async () => {
installStartFixture();

const start = chat.createStartSessionAction("fake-chat", {
triggerConfig: {
tags: ["org:acme", "a", "b", "c", "d", "e"],
},
});
await start({ chatId: "chat-tags" });

expect(lastStartBody?.triggerConfig.tags).toEqual([
"chat:chat-tags",
"org:acme",
"a",
"b",
"c",
]);
});

it("forwards maxDuration, region, and lockToVersion from triggerConfig", async () => {
installStartFixture();

const start = chat.createStartSessionAction("fake-chat", {
triggerConfig: {
maxDuration: 120,
region: "us-east-1",
lockToVersion: "20260101.1",
},
});
await start({ chatId: "chat-parity" });

expect(lastStartBody?.triggerConfig.maxDuration).toBe(120);
expect(lastStartBody?.triggerConfig.region).toBe("us-east-1");
expect(lastStartBody?.triggerConfig.lockToVersion).toBe("20260101.1");
});

it("keeps session-level metadata distinct from per-turn clientData", async () => {
installStartFixture();

Expand Down
Loading
Loading