From bf2f7ad8543d3a135a1f0c6b2e0cb9ecd8efb418 Mon Sep 17 00:00:00 2001 From: Matt Aitken Date: Mon, 7 Sep 2026 13:36:23 +0100 Subject: [PATCH 1/8] feat(sdk,core,webapp): chat sessions accept concurrencyKey and trigger-time named limits Chat agents already accept the task-level concurrency option, but the session trigger path had no way to scope runs: SessionTriggerConfig now carries concurrencyKey and up to two named limits, threaded through the session run trigger (initial, continuation, and upgrade re-triggers) and forwarded by all three session starters (createStartSessionAction, the AgentChat client, and handover). Keys are never defaulted from the chat ID; a session without one shares the task's keyless pool. Named limits are validated client-side with the same rules as tasks.trigger. --- .changeset/chat-session-concurrency.md | 12 ++++++ .../realtime/sessionRunManager.server.ts | 2 + docs/ai-chat/client-protocol.mdx | 2 + packages/core/src/v3/schemas/api.ts | 4 ++ packages/trigger-sdk/src/v3/ai.ts | 7 +++- packages/trigger-sdk/src/v3/chat-client.ts | 6 +++ packages/trigger-sdk/src/v3/chat-server.ts | 7 ++++ .../src/v3/createStartSessionAction.test.ts | 41 +++++++++++++++++++ packages/trigger-sdk/src/v3/shared.ts | 2 +- 9 files changed, 81 insertions(+), 2 deletions(-) create mode 100644 .changeset/chat-session-concurrency.md diff --git a/.changeset/chat-session-concurrency.md b/.changeset/chat-session-concurrency.md new file mode 100644 index 00000000000..fcf27046ba6 --- /dev/null +++ b/.changeset/chat-session-concurrency.md @@ -0,0 +1,12 @@ +--- +"@trigger.dev/sdk": patch +"@trigger.dev/core": patch +--- + +Chat agents can now scope concurrency per session. Pass `concurrencyKey` (for example your chat or tenant ID) and trigger-time named limits via `triggerConfig.concurrency` when starting a chat session, from `chat.createStartSessionAction`, the `AgentChat` client, or a handover. Keys are never defaulted, so a session without one shares the task's keyless pool. + +```ts +const start = chat.createStartSessionAction("support-chat", { + triggerConfig: { concurrencyKey: user.id }, +}); +``` diff --git a/apps/webapp/app/services/realtime/sessionRunManager.server.ts b/apps/webapp/app/services/realtime/sessionRunManager.server.ts index 3aa620d3c21..98f37c9aec5 100644 --- a/apps/webapp/app/services/realtime/sessionRunManager.server.ts +++ b/apps/webapp/app/services/realtime/sessionRunManager.server.ts @@ -318,6 +318,8 @@ async function triggerSessionRun(params: { options: { ...(config.machine ? { machine: config.machine as never } : {}), ...(config.queue ? { queue: { name: config.queue } } : {}), + ...(config.concurrency ? { concurrency: config.concurrency } : {}), + ...(config.concurrencyKey !== undefined ? { concurrencyKey: config.concurrencyKey } : {}), ...(config.tags ? { tags: config.tags } : {}), ...(config.maxAttempts !== undefined ? { maxAttempts: config.maxAttempts } : {}), ...(config.maxDuration !== undefined ? { maxDuration: config.maxDuration } : {}), diff --git a/docs/ai-chat/client-protocol.mdx b/docs/ai-chat/client-protocol.mdx index ab8b51cb14e..069da9b426e 100644 --- a/docs/ai-chat/client-protocol.mdx +++ b/docs/ai-chat/client-protocol.mdx @@ -206,6 +206,8 @@ Pick `"preload"` when the UI has rendered but the user hasn't typed (warms the a | `expiresAt` | `string` (ISO date) | Retention cap. | | `triggerConfig.machine` | `string` | Machine preset (`micro`, `small-1x`, …) for every run. | | `triggerConfig.queue` | `string` | Queue name. | +| `triggerConfig.concurrency` | `string[]` | Up to two [named concurrency limits](/concurrency#sharing-a-limit-between-tasks) every run holds, replacing the task's declared named limits. | +| `triggerConfig.concurrencyKey` | `string` | Scopes every run of this session to its own pool under each `perKey` bound it holds. Never defaulted — pass one (e.g. your chat or tenant ID) to isolate sessions from each other. | | `triggerConfig.tags` | `string[]` | Tags applied to every run (in addition to session-level `tags`). | | `triggerConfig.maxAttempts` | `number` | Per-run retry cap (1–10). | | `triggerConfig.maxDuration` | `number` | Per-run wall-clock cap, seconds. | diff --git a/packages/core/src/v3/schemas/api.ts b/packages/core/src/v3/schemas/api.ts index b96356b62c3..275a5c11724 100644 --- a/packages/core/src/v3/schemas/api.ts +++ b/packages/core/src/v3/schemas/api.ts @@ -1890,6 +1890,10 @@ export const SessionTriggerConfig = z.object({ basePayload: z.record(z.unknown()), machine: MachinePresetName.optional(), queue: z.string().max(128).optional(), + /** Named concurrency limits every run holds, replacing the task's declared named limits. */ + concurrency: z.string().min(1).max(128).array().max(2).optional(), + /** Scopes every run to its own pool under each `perKey` bound it holds. Never defaulted — a session without one shares the keyless pool. */ + concurrencyKey: ConcurrencyKeySchema.optional(), tags: z.array(z.string().max(128)).max(10).optional(), maxAttempts: z.number().int().positive().max(10).optional(), /** Per-run wall-clock cap (seconds). Forwarded to `TaskRunOptions.maxDuration`. */ diff --git a/packages/trigger-sdk/src/v3/ai.ts b/packages/trigger-sdk/src/v3/ai.ts index 1ba546f60b2..b74bbe99c24 100644 --- a/packages/trigger-sdk/src/v3/ai.ts +++ b/packages/trigger-sdk/src/v3/ai.ts @@ -118,7 +118,7 @@ import { sessions, type SessionSubscribeOptions, } from "./sessions.js"; -import { createTask } from "./shared.js"; +import { createTask, triggerConcurrencyBody } from "./shared.js"; import { markChatAgentRunForStreamsWarning } from "./streams.js"; import { tracer } from "./tracer.js"; @@ -11800,6 +11800,9 @@ function createChatStartSessionAction( params.clientData !== undefined ? { metadata: params.clientData } : {}; const maxAttempts = params.triggerConfig?.maxAttempts ?? options?.triggerConfig?.maxAttempts; const maxDuration = params.triggerConfig?.maxDuration ?? options?.triggerConfig?.maxDuration; + const concurrency = params.triggerConfig?.concurrency ?? options?.triggerConfig?.concurrency; + const concurrencyKey = + params.triggerConfig?.concurrencyKey ?? options?.triggerConfig?.concurrencyKey; const idleTimeoutInSeconds = params.triggerConfig?.idleTimeoutInSeconds ?? options?.triggerConfig?.idleTimeoutInSeconds; @@ -11818,6 +11821,8 @@ function createChatStartSessionAction( ...(options?.triggerConfig?.queue || params.triggerConfig?.queue ? { queue: params.triggerConfig?.queue ?? options?.triggerConfig?.queue } : {}), + ...(concurrency ? triggerConcurrencyBody(concurrency) : {}), + ...(concurrencyKey !== undefined ? { concurrencyKey } : {}), tags, ...(maxAttempts !== undefined ? { maxAttempts } : {}), ...(maxDuration !== undefined ? { maxDuration } : {}), diff --git a/packages/trigger-sdk/src/v3/chat-client.ts b/packages/trigger-sdk/src/v3/chat-client.ts index 955c0abb1d1..a8dad619974 100644 --- a/packages/trigger-sdk/src/v3/chat-client.ts +++ b/packages/trigger-sdk/src/v3/chat-client.ts @@ -671,6 +671,12 @@ export class AgentChat { }, ...(this.triggerConfigDefault?.machine ? { machine: this.triggerConfigDefault.machine } : {}), ...(this.triggerConfigDefault?.queue ? { queue: this.triggerConfigDefault.queue } : {}), + ...(this.triggerConfigDefault?.concurrency + ? { concurrency: this.triggerConfigDefault.concurrency } + : {}), + ...(this.triggerConfigDefault?.concurrencyKey !== undefined + ? { concurrencyKey: this.triggerConfigDefault.concurrencyKey } + : {}), tags: chatRunTags(this.chatId, this.triggerConfigDefault?.tags), ...(this.triggerConfigDefault?.maxAttempts !== undefined ? { maxAttempts: this.triggerConfigDefault.maxAttempts } diff --git a/packages/trigger-sdk/src/v3/chat-server.ts b/packages/trigger-sdk/src/v3/chat-server.ts index 5654089c22f..ed2b09d63c6 100644 --- a/packages/trigger-sdk/src/v3/chat-server.ts +++ b/packages/trigger-sdk/src/v3/chat-server.ts @@ -72,6 +72,7 @@ import { import type { FinishReason, ModelMessage, Tool, UIMessage, UIMessageChunk } from "ai"; import type { ChatInputChunk, ChatTaskWirePayload } from "./ai-shared.js"; import { chatRunTags } from "./ai-shared.js"; +import { triggerConcurrencyBody } from "./shared.js"; // `StreamTextResult` is defined locally rather than imported from `ai`: its // generic arity diverged (v6 `StreamTextResult`, v7 @@ -543,6 +544,12 @@ async function openHandoverSession(opts: { }, ...(opts.triggerConfig?.machine ? { machine: opts.triggerConfig.machine } : {}), ...(opts.triggerConfig?.queue ? { queue: opts.triggerConfig.queue } : {}), + ...(opts.triggerConfig?.concurrency + ? triggerConcurrencyBody(opts.triggerConfig.concurrency) + : {}), + ...(opts.triggerConfig?.concurrencyKey !== undefined + ? { concurrencyKey: opts.triggerConfig.concurrencyKey } + : {}), tags, ...(opts.triggerConfig?.maxAttempts !== undefined ? { maxAttempts: opts.triggerConfig.maxAttempts } diff --git a/packages/trigger-sdk/src/v3/createStartSessionAction.test.ts b/packages/trigger-sdk/src/v3/createStartSessionAction.test.ts index 79131965541..6cdaddb4362 100644 --- a/packages/trigger-sdk/src/v3/createStartSessionAction.test.ts +++ b/packages/trigger-sdk/src/v3/createStartSessionAction.test.ts @@ -150,6 +150,47 @@ describe("chat.createStartSessionAction — runtime", () => { expect(lastStartBody?.triggerConfig.lockToVersion).toBe("20260101.1"); }); + it("forwards concurrency and concurrencyKey from triggerConfig, with per-call precedence", async () => { + installStartFixture(); + + const start = chat.createStartSessionAction("fake-chat", { + triggerConfig: { + concurrency: ["chats"], + concurrencyKey: "tenant-default", + }, + }); + await start({ + chatId: "chat-conc", + triggerConfig: { concurrencyKey: "tenant-42" }, + }); + + expect(lastStartBody?.triggerConfig.concurrency).toEqual(["chats"]); + expect(lastStartBody?.triggerConfig.concurrencyKey).toBe("tenant-42"); + }); + + it("never defaults concurrencyKey from the chatId", async () => { + installStartFixture(); + + const start = chat.createStartSessionAction("fake-chat"); + await start({ chatId: "chat-no-key" }); + + expect(lastStartBody?.triggerConfig.concurrencyKey).toBeUndefined(); + expect(lastStartBody?.triggerConfig.concurrency).toBeUndefined(); + }); + + it("rejects invalid trigger-time limit names before any network call", async () => { + installStartFixture(); + + const start = chat.createStartSessionAction("fake-chat", { + triggerConfig: { concurrency: ["not a valid name!"] }, + }); + + await expect(start({ chatId: "chat-bad-limit" })).rejects.toThrow( + /letters, numbers, underscores and hyphens/ + ); + expect(lastStartBody).toBeUndefined(); + }); + it("server-mints override tokens for additional API keys", async () => { const requests: Array<{ url: string; body: unknown }> = []; const start = chat.createStartSessionAction("fake-chat", { diff --git a/packages/trigger-sdk/src/v3/shared.ts b/packages/trigger-sdk/src/v3/shared.ts index 1ee4a2e33c1..adde24939bd 100644 --- a/packages/trigger-sdk/src/v3/shared.ts +++ b/packages/trigger-sdk/src/v3/shared.ts @@ -217,7 +217,7 @@ function triggerQueueBody( * Trigger-time named limits: strings only, like `queue`. They replace the task's * declared named limits for this run; the server resolves names to the run's gates. */ -function triggerConcurrencyBody(concurrency: string | string[] | undefined): { +export function triggerConcurrencyBody(concurrency: string | string[] | undefined): { concurrency?: string[]; } { if (!concurrency) { From 17c7f688b022b2cc9527f9225346fc4d4b8d7b95 Mon Sep 17 00:00:00 2001 From: Matt Aitken Date: Mon, 7 Sep 2026 13:42:59 +0100 Subject: [PATCH 2/8] fix(sdk): concurrency validation helpers live outside the task runtime module triggerConcurrencyBody and validateConcurrencyLimitName move to a dependency-free module so the chat-server route-handler entrypoint stays lean instead of pulling the task runtime's import graph into customer bundles. --- packages/trigger-sdk/src/v3/ai.ts | 3 +- packages/trigger-sdk/src/v3/chat-server.ts | 2 +- .../trigger-sdk/src/v3/concurrency-shared.ts | 36 +++++++++++++++++++ packages/trigger-sdk/src/v3/shared.ts | 32 +---------------- 4 files changed, 40 insertions(+), 33 deletions(-) create mode 100644 packages/trigger-sdk/src/v3/concurrency-shared.ts diff --git a/packages/trigger-sdk/src/v3/ai.ts b/packages/trigger-sdk/src/v3/ai.ts index b74bbe99c24..5023bb245a2 100644 --- a/packages/trigger-sdk/src/v3/ai.ts +++ b/packages/trigger-sdk/src/v3/ai.ts @@ -118,7 +118,8 @@ import { sessions, type SessionSubscribeOptions, } from "./sessions.js"; -import { createTask, triggerConcurrencyBody } from "./shared.js"; +import { createTask } from "./shared.js"; +import { triggerConcurrencyBody } from "./concurrency-shared.js"; import { markChatAgentRunForStreamsWarning } from "./streams.js"; import { tracer } from "./tracer.js"; diff --git a/packages/trigger-sdk/src/v3/chat-server.ts b/packages/trigger-sdk/src/v3/chat-server.ts index ed2b09d63c6..9f84b33ddf2 100644 --- a/packages/trigger-sdk/src/v3/chat-server.ts +++ b/packages/trigger-sdk/src/v3/chat-server.ts @@ -72,7 +72,7 @@ import { import type { FinishReason, ModelMessage, Tool, UIMessage, UIMessageChunk } from "ai"; import type { ChatInputChunk, ChatTaskWirePayload } from "./ai-shared.js"; import { chatRunTags } from "./ai-shared.js"; -import { triggerConcurrencyBody } from "./shared.js"; +import { triggerConcurrencyBody } from "./concurrency-shared.js"; // `StreamTextResult` is defined locally rather than imported from `ai`: its // generic arity diverged (v6 `StreamTextResult`, v7 diff --git a/packages/trigger-sdk/src/v3/concurrency-shared.ts b/packages/trigger-sdk/src/v3/concurrency-shared.ts new file mode 100644 index 00000000000..d197d8b47d4 --- /dev/null +++ b/packages/trigger-sdk/src/v3/concurrency-shared.ts @@ -0,0 +1,36 @@ +/** + * Concurrency helpers with no runtime dependencies, importable from the lean + * browser and route-handler entrypoints (chat-client, chat-server) without + * pulling the task runtime's module graph into those bundles. + */ + +/** + * Trigger-time named limits: strings only, like `queue`. They replace the task's + * declared named limits for this run; the server resolves names to the run's gates. + */ +export function triggerConcurrencyBody(concurrency: string | string[] | undefined): { + concurrency?: string[]; +} { + if (!concurrency) { + return {}; + } + const limits = Array.isArray(concurrency) ? concurrency : [concurrency]; + if (limits.length > 2) { + throw new Error("The concurrency option accepts at most two named limits."); + } + if (limits.some((name) => typeof name !== "string" || name.length === 0)) { + throw new Error("The concurrency option takes limit names: non-empty strings."); + } + for (const name of limits) { + validateConcurrencyLimitName(name); + } + return { concurrency: limits }; +} + +export function validateConcurrencyLimitName(name: string): void { + if (!/^[a-zA-Z0-9_-]{1,122}$/.test(name)) { + throw new Error( + `Concurrency limit "${name}": names are 1-122 characters using only letters, numbers, underscores and hyphens.` + ); + } +} diff --git a/packages/trigger-sdk/src/v3/shared.ts b/packages/trigger-sdk/src/v3/shared.ts index adde24939bd..6f7d8ce2a17 100644 --- a/packages/trigger-sdk/src/v3/shared.ts +++ b/packages/trigger-sdk/src/v3/shared.ts @@ -95,6 +95,7 @@ import { type ConcurrencyLimit, } from "@trigger.dev/core/v3"; import { tracer } from "./tracer.js"; +import { triggerConcurrencyBody, validateConcurrencyLimitName } from "./concurrency-shared.js"; export type { AnyRunHandle, @@ -213,29 +214,6 @@ function triggerQueueBody( return { queue: name ? { name } : undefined }; } -/** - * Trigger-time named limits: strings only, like `queue`. They replace the task's - * declared named limits for this run; the server resolves names to the run's gates. - */ -export function triggerConcurrencyBody(concurrency: string | string[] | undefined): { - concurrency?: string[]; -} { - if (!concurrency) { - return {}; - } - const limits = Array.isArray(concurrency) ? concurrency : [concurrency]; - if (limits.length > 2) { - throw new Error("The concurrency option accepts at most two named limits."); - } - if (limits.some((name) => typeof name !== "string" || name.length === 0)) { - throw new Error("The concurrency option takes limit names: non-empty strings."); - } - for (const name of limits) { - validateConcurrencyLimitName(name); - } - return { concurrency: limits }; -} - export function queue(options: QueueOptions): Queue { resourceCatalog.registerQueueMetadata(options); @@ -261,14 +239,6 @@ export function queue(options: QueueOptions): Queue { * }); * ``` */ -function validateConcurrencyLimitName(name: string): void { - if (!/^[a-zA-Z0-9_-]{1,122}$/.test(name)) { - throw new Error( - `Concurrency limit "${name}": names are 1-122 characters using only letters, numbers, underscores and hyphens.` - ); - } -} - export function concurrencyLimit(options: ConcurrencyLimitOptions): ConcurrencyLimit { validateConcurrencyLimitName(options.name); From 5d16aca125dcc95df2a51ea49449a9b3e31226a1 Mon Sep 17 00:00:00 2001 From: Matt Aitken Date: Mon, 7 Sep 2026 13:53:07 +0100 Subject: [PATCH 3/8] fix(core,sdk): bad limit names are rejected before the session row persists The session trigger config's concurrency names now carry the charset rule in the schema itself, so an invalid name fails session creation instead of poisoning a persisted, run-less session that fails every retry. The AgentChat client validates through the same dependency-free helper as the other starters, tests pin per-call concurrency precedence and the empty-array clear, and the chat-server import allowlist names the new module. --- packages/core/src/v3/schemas/api.ts | 14 +++++++++++-- packages/trigger-sdk/src/v3/chat-client.ts | 3 ++- packages/trigger-sdk/src/v3/chat-server.ts | 1 + .../src/v3/createStartSessionAction.test.ts | 20 +++++++++++++++++++ 4 files changed, 35 insertions(+), 3 deletions(-) diff --git a/packages/core/src/v3/schemas/api.ts b/packages/core/src/v3/schemas/api.ts index 275a5c11724..e4f07d05c16 100644 --- a/packages/core/src/v3/schemas/api.ts +++ b/packages/core/src/v3/schemas/api.ts @@ -1890,8 +1890,18 @@ export const SessionTriggerConfig = z.object({ basePayload: z.record(z.unknown()), machine: MachinePresetName.optional(), queue: z.string().max(128).optional(), - /** Named concurrency limits every run holds, replacing the task's declared named limits. */ - concurrency: z.string().min(1).max(128).array().max(2).optional(), + /** Named concurrency limits every run holds, replacing the task's declared named limits. + * The charset rule is enforced here so a bad name is rejected before the session row + * persists, instead of surfacing from the trigger after the session already exists. */ + concurrency: z + .string() + .regex(/^[a-zA-Z0-9_-]{1,122}$/, { + message: + "Concurrency limit names are 1-122 characters using only letters, numbers, underscores and hyphens", + }) + .array() + .max(2) + .optional(), /** Scopes every run to its own pool under each `perKey` bound it holds. Never defaulted — a session without one shares the keyless pool. */ concurrencyKey: ConcurrencyKeySchema.optional(), tags: z.array(z.string().max(128)).max(10).optional(), diff --git a/packages/trigger-sdk/src/v3/chat-client.ts b/packages/trigger-sdk/src/v3/chat-client.ts index a8dad619974..d88a0d44970 100644 --- a/packages/trigger-sdk/src/v3/chat-client.ts +++ b/packages/trigger-sdk/src/v3/chat-client.ts @@ -29,6 +29,7 @@ import { } from "@trigger.dev/core/v3"; import type { ChatInputChunk, ChatTaskWirePayload } from "./ai-shared.js"; import { chatRunTags, slimSubmitMessageForWire } from "./ai-shared.js"; +import { triggerConcurrencyBody } from "./concurrency-shared.js"; import { sessions } from "./sessions.js"; // ─── Type inference ──────────────────────────────────────────────── @@ -672,7 +673,7 @@ export class AgentChat { ...(this.triggerConfigDefault?.machine ? { machine: this.triggerConfigDefault.machine } : {}), ...(this.triggerConfigDefault?.queue ? { queue: this.triggerConfigDefault.queue } : {}), ...(this.triggerConfigDefault?.concurrency - ? { concurrency: this.triggerConfigDefault.concurrency } + ? triggerConcurrencyBody(this.triggerConfigDefault.concurrency) : {}), ...(this.triggerConfigDefault?.concurrencyKey !== undefined ? { concurrencyKey: this.triggerConfigDefault.concurrencyKey } diff --git a/packages/trigger-sdk/src/v3/chat-server.ts b/packages/trigger-sdk/src/v3/chat-server.ts index 9f84b33ddf2..de1fe99e6f6 100644 --- a/packages/trigger-sdk/src/v3/chat-server.ts +++ b/packages/trigger-sdk/src/v3/chat-server.ts @@ -50,6 +50,7 @@ * `ai.ts` statically imports `agentSkillsRuntime` (which uses `node:` * builtins unfit for some serverless runtimes) and the heavy task * runtime. Allowed imports: `./ai-shared.js`, `./chat-client.js`, + * `./concurrency-shared.js` (dependency-free validation helpers), * `@trigger.dev/core/v3` (api client), `ai` (types + lightweight * helpers like `stepCountIs` / `convertToModelMessages`). */ diff --git a/packages/trigger-sdk/src/v3/createStartSessionAction.test.ts b/packages/trigger-sdk/src/v3/createStartSessionAction.test.ts index 6cdaddb4362..9b49cf0e4c7 100644 --- a/packages/trigger-sdk/src/v3/createStartSessionAction.test.ts +++ b/packages/trigger-sdk/src/v3/createStartSessionAction.test.ts @@ -168,6 +168,26 @@ describe("chat.createStartSessionAction — runtime", () => { expect(lastStartBody?.triggerConfig.concurrencyKey).toBe("tenant-42"); }); + it("per-call concurrency wins over the action default, and an empty array clears it", async () => { + installStartFixture(); + + const start = chat.createStartSessionAction("fake-chat", { + triggerConfig: { concurrency: ["chats"] }, + }); + + await start({ + chatId: "chat-conc-override", + triggerConfig: { concurrency: ["priority"] }, + }); + expect(lastStartBody?.triggerConfig.concurrency).toEqual(["priority"]); + + await start({ + chatId: "chat-conc-clear", + triggerConfig: { concurrency: [] }, + }); + expect(lastStartBody?.triggerConfig.concurrency).toEqual([]); + }); + it("never defaults concurrencyKey from the chatId", async () => { installStartFixture(); From d8ca1efea378c61c8f9f55af534045cc15eeff52 Mon Sep 17 00:00:00 2001 From: Matt Aitken Date: Mon, 7 Sep 2026 14:14:03 +0100 Subject: [PATCH 4/8] fix(core,webapp): every session config writer validates before persisting Webhook deliveries parse the assembled trigger config before creating the session, failing terminally on a bad routing-target template instead of persisting a session that every continuation re-parse would strand. The trigger and batch bodies share the session config's limit-name rule (1-122 chars, letters/numbers/underscores/hyphens), so a bad name is a uniform up-front 400 on every path rather than a late validation error after earlier batch items already triggered. --- apps/webapp/app/v3/webhookEngine.server.ts | 18 ++++++++++++-- packages/core/src/v3/schemas/api.ts | 28 +++++++++++++--------- 2 files changed, 33 insertions(+), 13 deletions(-) diff --git a/apps/webapp/app/v3/webhookEngine.server.ts b/apps/webapp/app/v3/webhookEngine.server.ts index b06f2f0024b..685eb5420e4 100644 --- a/apps/webapp/app/v3/webhookEngine.server.ts +++ b/apps/webapp/app/v3/webhookEngine.server.ts @@ -7,6 +7,7 @@ import { env } from "~/env.server"; import { findEnvironmentById } from "~/models/runtimeEnvironment.server"; import { logger } from "~/services/logger.server"; import { S2RealtimeStreams } from "~/services/realtime/s2realtimeStreams.server"; +import { SessionTriggerConfig as SessionTriggerConfigSchema } from "@trigger.dev/core/v3"; import { ensureRunForSession, type SessionTriggerConfig, @@ -155,7 +156,11 @@ function createWebhookEngine() { } const template = (triggerConfigTemplate ?? {}) as Partial; - const triggerConfig: SessionTriggerConfig = { + /** The template arrives unvalidated (`z.record(z.unknown())` on the routing + * target), and continuations re-parse the stored row with a throwing parse — + * so anything this path persists must parse, or the session strands forever. + * A bad template fails the delivery terminally instead. */ + const parsedTriggerConfig = SessionTriggerConfigSchema.safeParse({ ...template, basePayload: { messages: [], @@ -163,7 +168,16 @@ function createWebhookEngine() { chatId: externalId, ...(template.basePayload ?? {}), }, - }; + }); + if (!parsedTriggerConfig.success) { + return { + success: false, + error: `Invalid triggerConfigTemplate on the webhook routing target: ${parsedTriggerConfig.error.issues + .map((issue) => `${issue.path.join(".")}: ${issue.message}`) + .join("; ")}`, + }; + } + const triggerConfig: SessionTriggerConfig = parsedTriggerConfig.data; // Resume an existing session; otherwise only START one when the event is a session-start // (startOn). Resume-only with no session yet -> ignore (no session, no run, no egress). diff --git a/packages/core/src/v3/schemas/api.ts b/packages/core/src/v3/schemas/api.ts index e4f07d05c16..4b14df4a485 100644 --- a/packages/core/src/v3/schemas/api.ts +++ b/packages/core/src/v3/schemas/api.ts @@ -271,6 +271,20 @@ export type IdempotencyKeyOptionsSchema = z.infer String(value)); +/** + * Trigger-time named concurrency limits. The charset rule matches what the queue + * concern enforces, so a bad name is a uniform up-front 400 on every path instead + * of a late validation error after the request (or earlier batch items) succeeded. + */ +const TriggerConcurrencyLimitsSchema = z + .string() + .regex(/^[a-zA-Z0-9_-]{1,122}$/, { + message: + "Concurrency limit names are 1-122 characters using only letters, numbers, underscores and hyphens", + }) + .array() + .max(2); + const ExternalDeploymentId = z.preprocess((value) => { if (typeof value !== "string") { return value; @@ -336,7 +350,7 @@ export const TriggerTaskRequestBody = z ) .max(3) .optional(), - concurrency: z.string().min(1).max(128).array().max(2).optional(), + concurrency: TriggerConcurrencyLimitsSchema.optional(), concurrencyKey: ConcurrencyKeySchema.optional(), delay: z.string().or(z.coerce.date()).optional(), idempotencyKey: z @@ -452,7 +466,7 @@ export const BatchTriggerTaskItem = z.object({ ) .max(3) .optional(), - concurrency: z.string().min(1).max(128).array().max(2).optional(), + concurrency: TriggerConcurrencyLimitsSchema.optional(), tags: RunTags.optional(), test: z.boolean().optional(), ttl: z.string().or(z.number().nonnegative().int()).optional(), @@ -1893,15 +1907,7 @@ export const SessionTriggerConfig = z.object({ /** Named concurrency limits every run holds, replacing the task's declared named limits. * The charset rule is enforced here so a bad name is rejected before the session row * persists, instead of surfacing from the trigger after the session already exists. */ - concurrency: z - .string() - .regex(/^[a-zA-Z0-9_-]{1,122}$/, { - message: - "Concurrency limit names are 1-122 characters using only letters, numbers, underscores and hyphens", - }) - .array() - .max(2) - .optional(), + concurrency: TriggerConcurrencyLimitsSchema.optional(), /** Scopes every run to its own pool under each `perKey` bound it holds. Never defaulted — a session without one shares the keyless pool. */ concurrencyKey: ConcurrencyKeySchema.optional(), tags: z.array(z.string().max(128)).max(10).optional(), From 05e2cd2a7eec139b3a1e8e9b3b4e1dc5eb59f085 Mon Sep 17 00:00:00 2001 From: Matt Aitken Date: Mon, 7 Sep 2026 14:27:26 +0100 Subject: [PATCH 5/8] fix(webapp): a broken webhook template only fails session creation, never resumes Template validation moves inside the create branch: resume deliveries to existing sessions never touch the template, so editing a routing target to an invalid template can't terminally fail events to healthy live sessions. A non-object basePayload is rejected instead of being spread into index-keyed garbage, and unknown template keys persist as before with only the known fields normalized by the parse. --- apps/webapp/app/v3/webhookEngine.server.ts | 91 ++++++++++++++-------- 1 file changed, 58 insertions(+), 33 deletions(-) diff --git a/apps/webapp/app/v3/webhookEngine.server.ts b/apps/webapp/app/v3/webhookEngine.server.ts index 685eb5420e4..f45e15f8735 100644 --- a/apps/webapp/app/v3/webhookEngine.server.ts +++ b/apps/webapp/app/v3/webhookEngine.server.ts @@ -155,30 +155,6 @@ function createWebhookEngine() { return { success: false, errorType: "NOT_FOUND", error: "Environment not found" }; } - const template = (triggerConfigTemplate ?? {}) as Partial; - /** The template arrives unvalidated (`z.record(z.unknown())` on the routing - * target), and continuations re-parse the stored row with a throwing parse — - * so anything this path persists must parse, or the session strands forever. - * A bad template fails the delivery terminally instead. */ - const parsedTriggerConfig = SessionTriggerConfigSchema.safeParse({ - ...template, - basePayload: { - messages: [], - trigger: "preload", - chatId: externalId, - ...(template.basePayload ?? {}), - }, - }); - if (!parsedTriggerConfig.success) { - return { - success: false, - error: `Invalid triggerConfigTemplate on the webhook routing target: ${parsedTriggerConfig.error.issues - .map((issue) => `${issue.path.join(".")}: ${issue.message}`) - .join("; ")}`, - }; - } - const triggerConfig: SessionTriggerConfig = parsedTriggerConfig.data; - // Resume an existing session; otherwise only START one when the event is a session-start // (startOn). Resume-only with no session yet -> ignore (no session, no run, no egress). const existing = await findSessionByExternalId(environment, externalId); @@ -189,15 +165,64 @@ function createWebhookEngine() { skippedReason: "startOn: not a session-start event", }; } - const { session, isCached } = existing - ? { session: existing, isCached: true } - : await findOrCreateSession({ - environment, - externalId, - type: "chat.agent", - taskIdentifier, - triggerConfig, - }); + + let session; + let isCached; + if (existing) { + session = existing; + isCached = true; + } else { + /** The template arrives unvalidated (`z.record(z.unknown())` on the routing + * target), and continuations re-parse the stored row with a throwing parse — + * so anything this path persists must parse, or the session strands forever. + * A bad template fails the CREATE delivery terminally; resumes above never + * touch the template, so a broken template can't stop existing sessions. + * Known fields persist normalized (the parse output) while unknown template + * keys are kept as the pre-validation path stored them; a non-object + * `basePayload` is rejected rather than spread into index-keyed garbage. */ + const template = (triggerConfigTemplate ?? {}) as Partial; + if ( + template.basePayload !== undefined && + (typeof template.basePayload !== "object" || + template.basePayload === null || + Array.isArray(template.basePayload)) + ) { + return { + success: false, + error: + "Invalid triggerConfigTemplate on the webhook routing target: basePayload must be an object", + }; + } + const assembled = { + ...template, + basePayload: { + messages: [], + trigger: "preload", + chatId: externalId, + ...(template.basePayload ?? {}), + }, + }; + const parsedTriggerConfig = SessionTriggerConfigSchema.safeParse(assembled); + if (!parsedTriggerConfig.success) { + return { + success: false, + error: `Invalid triggerConfigTemplate on the webhook routing target: ${parsedTriggerConfig.error.issues + .map((issue) => `${issue.path.join(".")}: ${issue.message}`) + .join("; ")}`, + }; + } + const triggerConfig: SessionTriggerConfig = { + ...assembled, + ...parsedTriggerConfig.data, + }; + ({ session, isCached } = await findOrCreateSession({ + environment, + externalId, + type: "chat.agent", + taskIdentifier, + triggerConfig, + })); + } if (session.closedAt || (session.expiresAt && session.expiresAt.getTime() < Date.now())) { return { success: false, error: "Session is closed or expired" }; From 123b60adc57b0c7abe0718f9528c1cbfc65a54d8 Mon Sep 17 00:00:00 2001 From: Matt Aitken Date: Thu, 17 Sep 2026 12:56:15 +0100 Subject: [PATCH 6/8] fix(sdk): an empty or null concurrency value is rejected, not silently dropped Truthiness guards at the trigger call sites swallowed falsey concurrency values before validation, so a JavaScript caller passing an empty string or null started the session without the intended limits. Only undefined counts as absent now; everything else flows into validation and throws. --- packages/trigger-sdk/src/v3/ai.ts | 2 +- packages/trigger-sdk/src/v3/chat-client.ts | 2 +- packages/trigger-sdk/src/v3/chat-server.ts | 2 +- .../trigger-sdk/src/v3/concurrency-shared.ts | 2 +- .../src/v3/createStartSessionAction.test.ts | 21 +++++++++++++++++++ 5 files changed, 25 insertions(+), 4 deletions(-) diff --git a/packages/trigger-sdk/src/v3/ai.ts b/packages/trigger-sdk/src/v3/ai.ts index 5023bb245a2..1ecf6c01587 100644 --- a/packages/trigger-sdk/src/v3/ai.ts +++ b/packages/trigger-sdk/src/v3/ai.ts @@ -11822,7 +11822,7 @@ function createChatStartSessionAction( ...(options?.triggerConfig?.queue || params.triggerConfig?.queue ? { queue: params.triggerConfig?.queue ?? options?.triggerConfig?.queue } : {}), - ...(concurrency ? triggerConcurrencyBody(concurrency) : {}), + ...(concurrency !== undefined ? triggerConcurrencyBody(concurrency) : {}), ...(concurrencyKey !== undefined ? { concurrencyKey } : {}), tags, ...(maxAttempts !== undefined ? { maxAttempts } : {}), diff --git a/packages/trigger-sdk/src/v3/chat-client.ts b/packages/trigger-sdk/src/v3/chat-client.ts index d88a0d44970..b11f68050e6 100644 --- a/packages/trigger-sdk/src/v3/chat-client.ts +++ b/packages/trigger-sdk/src/v3/chat-client.ts @@ -672,7 +672,7 @@ export class AgentChat { }, ...(this.triggerConfigDefault?.machine ? { machine: this.triggerConfigDefault.machine } : {}), ...(this.triggerConfigDefault?.queue ? { queue: this.triggerConfigDefault.queue } : {}), - ...(this.triggerConfigDefault?.concurrency + ...(this.triggerConfigDefault?.concurrency !== undefined ? triggerConcurrencyBody(this.triggerConfigDefault.concurrency) : {}), ...(this.triggerConfigDefault?.concurrencyKey !== undefined diff --git a/packages/trigger-sdk/src/v3/chat-server.ts b/packages/trigger-sdk/src/v3/chat-server.ts index de1fe99e6f6..867cdbf3cf1 100644 --- a/packages/trigger-sdk/src/v3/chat-server.ts +++ b/packages/trigger-sdk/src/v3/chat-server.ts @@ -545,7 +545,7 @@ async function openHandoverSession(opts: { }, ...(opts.triggerConfig?.machine ? { machine: opts.triggerConfig.machine } : {}), ...(opts.triggerConfig?.queue ? { queue: opts.triggerConfig.queue } : {}), - ...(opts.triggerConfig?.concurrency + ...(opts.triggerConfig?.concurrency !== undefined ? triggerConcurrencyBody(opts.triggerConfig.concurrency) : {}), ...(opts.triggerConfig?.concurrencyKey !== undefined diff --git a/packages/trigger-sdk/src/v3/concurrency-shared.ts b/packages/trigger-sdk/src/v3/concurrency-shared.ts index d197d8b47d4..c147cdcee12 100644 --- a/packages/trigger-sdk/src/v3/concurrency-shared.ts +++ b/packages/trigger-sdk/src/v3/concurrency-shared.ts @@ -11,7 +11,7 @@ export function triggerConcurrencyBody(concurrency: string | string[] | undefined): { concurrency?: string[]; } { - if (!concurrency) { + if (concurrency === undefined) { return {}; } const limits = Array.isArray(concurrency) ? concurrency : [concurrency]; diff --git a/packages/trigger-sdk/src/v3/createStartSessionAction.test.ts b/packages/trigger-sdk/src/v3/createStartSessionAction.test.ts index 9b49cf0e4c7..a06413ac0a5 100644 --- a/packages/trigger-sdk/src/v3/createStartSessionAction.test.ts +++ b/packages/trigger-sdk/src/v3/createStartSessionAction.test.ts @@ -211,6 +211,27 @@ describe("chat.createStartSessionAction — runtime", () => { expect(lastStartBody).toBeUndefined(); }); + it("rejects an empty-string concurrency instead of silently dropping it", async () => { + installStartFixture(); + + const emptyDefault = chat.createStartSessionAction("fake-chat", { + triggerConfig: { concurrency: "" as unknown as string[] }, + }); + await expect(emptyDefault({ chatId: "chat-empty-limit" })).rejects.toThrow( + /non-empty strings/ + ); + + const emptyPerCall = chat.createStartSessionAction("fake-chat"); + await expect( + emptyPerCall({ + chatId: "chat-empty-limit-2", + triggerConfig: { concurrency: "" as unknown as string[] }, + }) + ).rejects.toThrow(/non-empty strings/); + + expect(lastStartBody).toBeUndefined(); + }); + it("server-mints override tokens for additional API keys", async () => { const requests: Array<{ url: string; body: unknown }> = []; const start = chat.createStartSessionAction("fake-chat", { From 27b01b29aa3865adaf54aecc515a43eebb1e3d2b Mon Sep 17 00:00:00 2001 From: Matt Aitken Date: Thu, 17 Sep 2026 13:32:32 +0100 Subject: [PATCH 7/8] fix(sdk): a per-call null concurrency is rejected instead of inheriting the action default Nullish coalescing treated a per-call null as unspecified, silently selecting the action default (or skipping validation entirely when no default exists). Per-call selection now treats only undefined as absent, matching the other chat trigger surfaces. --- packages/trigger-sdk/src/v3/ai.ts | 5 ++++- .../src/v3/createStartSessionAction.test.ts | 10 ++++++++++ 2 files changed, 14 insertions(+), 1 deletion(-) diff --git a/packages/trigger-sdk/src/v3/ai.ts b/packages/trigger-sdk/src/v3/ai.ts index 1ecf6c01587..325872cb3ef 100644 --- a/packages/trigger-sdk/src/v3/ai.ts +++ b/packages/trigger-sdk/src/v3/ai.ts @@ -11801,7 +11801,10 @@ function createChatStartSessionAction( params.clientData !== undefined ? { metadata: params.clientData } : {}; const maxAttempts = params.triggerConfig?.maxAttempts ?? options?.triggerConfig?.maxAttempts; const maxDuration = params.triggerConfig?.maxDuration ?? options?.triggerConfig?.maxDuration; - const concurrency = params.triggerConfig?.concurrency ?? options?.triggerConfig?.concurrency; + const concurrency = + params.triggerConfig?.concurrency !== undefined + ? params.triggerConfig.concurrency + : options?.triggerConfig?.concurrency; const concurrencyKey = params.triggerConfig?.concurrencyKey ?? options?.triggerConfig?.concurrencyKey; const idleTimeoutInSeconds = diff --git a/packages/trigger-sdk/src/v3/createStartSessionAction.test.ts b/packages/trigger-sdk/src/v3/createStartSessionAction.test.ts index a06413ac0a5..9007ba3bca1 100644 --- a/packages/trigger-sdk/src/v3/createStartSessionAction.test.ts +++ b/packages/trigger-sdk/src/v3/createStartSessionAction.test.ts @@ -229,6 +229,16 @@ describe("chat.createStartSessionAction — runtime", () => { }) ).rejects.toThrow(/non-empty strings/); + const nullPerCall = chat.createStartSessionAction("fake-chat", { + triggerConfig: { concurrency: ["chats"] }, + }); + await expect( + nullPerCall({ + chatId: "chat-null-limit", + triggerConfig: { concurrency: null as unknown as string[] }, + }) + ).rejects.toThrow(/non-empty strings/); + expect(lastStartBody).toBeUndefined(); }); From d511299e68b7a0389f7a09751bbd8686dde83edc Mon Sep 17 00:00:00 2001 From: Matt Aitken Date: Thu, 17 Sep 2026 14:21:21 +0100 Subject: [PATCH 8/8] docs(sdk): the chat concurrency changeset names both example key types --- .changeset/chat-session-concurrency.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.changeset/chat-session-concurrency.md b/.changeset/chat-session-concurrency.md index fcf27046ba6..69bee125e2b 100644 --- a/.changeset/chat-session-concurrency.md +++ b/.changeset/chat-session-concurrency.md @@ -3,7 +3,7 @@ "@trigger.dev/core": patch --- -Chat agents can now scope concurrency per session. Pass `concurrencyKey` (for example your chat or tenant ID) and trigger-time named limits via `triggerConfig.concurrency` when starting a chat session, from `chat.createStartSessionAction`, the `AgentChat` client, or a handover. Keys are never defaulted, so a session without one shares the task's keyless pool. +Chat agents can now scope concurrency per session. Pass `concurrencyKey` (for example, your chat ID or tenant ID) and trigger-time named limits via `triggerConfig.concurrency` when starting a chat session, from `chat.createStartSessionAction`, the `AgentChat` client, or a handover. Keys are never defaulted, so a session without one shares the task's keyless pool. ```ts const start = chat.createStartSessionAction("support-chat", {