feat(sdk,webapp,ai-chat): end-to-end browser UI smoke on sessions

Fixes the last set of issues that were blocking TriggerChatTransport
from running end-to-end against the ai-chat reference. Smoke now
passes: new chat → send → streamed assistant reply in ~4s → second
turn reuses the same session + run, lastEventId advances 10 → 21.

SDK (@trigger.dev/sdk)

- RenewRunAccessTokenParams carries the durable sessionId alongside
  chatId + runId. Server-side renew handlers MUST mint the renewed
  PAT with read:sessions:{sessionId} + write:sessions:{sessionId}
  scopes (in addition to the existing run scopes) — without them,
  the first append after expiry 401s on session.in/append and sends
  the transport into a renew loop. transport.renewRunPatForSession
  looks up the cached sessionId off `this.sessions` so existing
  renew callers just need to spread the new field through.
- transport.preload(chatId) on the triggerTask callback path no
  longer calls apiClient.createSession from the browser. Matches
  sendMessages: when triggerTaskFn is configured the server action
  (chat.createTriggerAction) creates the Session with its secret
  key and returns sessionId alongside the run PAT. Browser
  deployments using the callback flow therefore never need
  write:sessions on any browser-facing token.
- chat.test.ts renew-spy assertions updated to match the new
  {chatId, runId, sessionId} shape — 86/86 tests still green.

Webapp

- POST /api/v1/sessions gets allowJWT: true + corsStrategy: "all".
  Pre-fix, the route rejected any CORS-preflighted browser call,
  which broke the transport's direct accessToken fallback path
  (sessions.create from the browser).
- POST /realtime/v1/sessions/:session/:io/append now exports both
  { action, loader }. The route builder installs the OPTIONS
  preflight handler on the loader; without a loader export, the
  preflight returned 400 ("No loader for route") and Chrome
  surfaced the follow-up POST as net::ERR_FAILED. Same pattern
  already in use on /api/v1/tasks/:id/trigger.

references/ai-chat

- Switch both chat-app.tsx and chat-view.tsx from
  accessToken: getChatToken to triggerTask: triggerChat. This path
  has the server action create the Session server-side with the
  secret key, so the browser never hits POST /api/v1/sessions and
  the returned PAT already carries the session scopes needed for
  session.in/out.
- renewRunAccessTokenForChat(chatId, runId, sessionId?) now mints
  tokens that include read:sessions:{sessionId} +
  write:sessions:{sessionId} alongside the run scopes. Both call
  sites thread the sessionId from the SDK's renew callback params.
- Drop executeJs / runInSecureSandbox / runInPRReviewSandbox to
  decouple ai-chat trigger dev from the isolated-vm native binary
  (its darwin-arm64 prebuild is broken against node 20.20.0 on
  the current toolchain). Deletes src/lib/secure-sandbox.ts and
  src/lib/pr-review-sandbox.ts, removes the executeJs tool from
  chatTools, the secure-exec-bridge esbuild plugin from
  trigger.config.ts (and its companion node-stdlib-browser-stub),
  and the `secure-exec` dependency from package.json. E2B-backed
  executeCode stays. If a future session needs the in-process V8
  sandbox back, reintroduce through a different module (or pin a
  prebuilt binary) to avoid this failure mode.

Smoke drove via the window.__chat bridge from Chrome DevTools MCP —
no click-based interaction needed.
This commit is contained in:
Eric Allam
2026-04-23 17:53:58 +01:00
parent a332e1d3f4
commit 23bf81efdb
16 changed files with 1313 additions and 1227 deletions
@@ -0,0 +1,8 @@
---
"@trigger.dev/sdk": patch
---
`TriggerChatTransport` fixes for session-scoped auth and end-to-end UI smoke parity:
- `RenewRunAccessTokenParams` now includes the durable `sessionId` alongside `chatId` + `runId`. Server-side renew handlers should mint the renewed PAT with `read:sessions:{sessionId}` + `write:sessions:{sessionId}` scopes (in addition to the existing run scopes) so it keeps authenticating against the session `.in` append + `.out` subscribe endpoints. Renewing without session scopes sends the transport into a 401 loop on the first append after expiry.
- `transport.preload(chatId)` on the `triggerTask` callback path no longer calls `apiClient.createSession` from the browser. The server action (e.g. `chat.createTriggerAction`) creates the session with its secret key and returns the `sessionId` in its result, matching how `sendMessages` already worked. Browser deployments that use the `triggerTask` callback path therefore no longer need `write:sessions` on any browser-side token.
@@ -0,0 +1,558 @@
# Sessions as run manager
Plan for the next chat.agent / Sessions branch. Builds on the row-agnostic
addressing branch (`chat-agent-sessions.md`).
## Context
The previous branch made `chatId` (the externalId) the universal addressing
string and made the `.in/.out/wait` routes row-agnostic. It works, but the
transport still owns run lifecycle: it triggers the first run, threads
`runId` through state, has to detect "run died" so the next user message
re-triggers, and re-triggers explicitly on `trigger:upgrade-required`.
Two real gaps fall out of that:
1. **Run-death blindness.** `.in/append` is run-independent — it appends to
S2 successfully whether or not the run is alive. The transport's
"non-auth error → re-trigger" fallback (`chat.ts:647-654`) is dead code
under row-agnostic addressing because the endpoint always 200s. If a run
is cancelled or crashes mid-turn before emitting `turn-complete`, the
user's next message sits in S2 with no listener and the transport has
no signal to recover.
2. **Transport carries upgrade plumbing.** ~50 lines around
`subscribeToSessionStream`'s `upgradeRetry`, threaded `payload`+
`messages` for re-trigger, and the `triggerNewRun` call on a
client-issued retry — all so the transport can react to a chunk the
agent emits. Server is in a better position to do this work.
The fix: **make Session the run manager.** Sessions know their task,
their config, and their current run. Server triggers/re-triggers as
needed. Browser holds a session-scoped PAT and never sees runs.
Nothing has shipped yet — no back-compat needed. We're free to break
public surface (`chat.createTriggerAction`, `onSessionChange` shape,
`ChatSession.runId`).
## Design
### Invariants
- Session is the durable identity of a chat. One session, many runs over
its lifetime.
- Session always knows its task (`taskIdentifier`) and how to trigger it
(`triggerConfig`). Sessions without those fields don't exist anymore —
Sessions are task-bound by design.
- At most one live run per session at a time. Tracked as
`Session.currentRunId` (non-FK, can lag reality).
- `Session.currentRunVersion` (monotonic int) drives optimistic locking on
any state transition that swaps the run.
- Browser only ever holds session-scoped tokens. Run identifiers are a
server-side implementation detail.
- The append-time probe is the source of truth. Hooks from run-engine into
Session are optional eager-clears for dashboard freshness, never for
correctness.
### State machine
```
┌─────────────────┐
│ Session created │
│ first run fired │
└────────┬────────┘
┌──────────────────┐ user msg / .in append
│ currentRun alive │ ◀────────────────────────┐
└────────┬─────────┘ │
│ run terminates │
│ (idle, cancel, crash, end-cont.) │
▼ │
┌──────────────────┐ .in/append probes │
│ currentRun stale │ ─── ensureRunForSession ─┘
└──────────────────┘
│ session.close()
┌──────────────────┐
│ closed (terminal)│
└──────────────────┘
```
### Three trigger paths
1. **Session create.** `POST /api/v1/sessions` creates the row and triggers
the first run synchronously, returns `{ id, runId, publicAccessToken }`.
2. **`.in/append` probe.** Server checks `currentRunId`'s snapshot status;
if terminal, calls `ensureRunForSession` before processing the append.
3. **`end-and-continue`.** Agent calls `POST /api/v1/sessions/:id/end-and-continue`
to request a clean handoff to a fresh run on the latest version. Server
triggers v2, swaps `currentRunId`, returns the new runId. v1 emits its
final `.out` chunks (e.g. `trigger:upgrade-required` for transport
telemetry) and exits.
## Schema
### Prisma changes
```prisma
model Session {
// existing fields stay...
// Now required (today nullable). Sessions are task-bound.
taskIdentifier String
// New: trigger payload + options for re-runs.
// { basePayload, machine, queue, tags, maxAttempts, idleTimeoutInSeconds }
triggerConfig Json
// New: current run pointer. Non-FK so run deletion doesn't cascade.
currentRunId String?
// New: monotonic counter for optimistic locking on currentRunId swaps.
currentRunVersion Int @default(0)
@@index([currentRunId]) // only useful for "find session by run" reverse lookups
}
```
### Optional historical join (defer to v1.1)
```prisma
model SessionRun {
sessionId String
runId String @unique
reason String // "initial" | "continuation" | "upgrade" | "manual"
triggeredAt DateTime @default(now())
@@index([sessionId])
}
```
Not strictly needed for v1 — debugging/audit can use TaskRun's existing
metadata + `Session.currentRunId` history via `git`-style logs in
ClickHouse if desired. Add only if a concrete dashboard surface needs it.
### Migration
Two-step:
1. Add the new columns + populate `taskIdentifier` from existing data
(chat.agent sessions all have it implicit via tags or metadata).
2. Set `triggerConfig = '{}'` for any existing sessions and either close
them or leave them as zombies. Since the old transport still works
pre-merge, this branch is the cutover.
For the dev DB: I'll write a backfill that closes existing dev sessions
rather than try to compute valid triggerConfigs for them. They were all
test data anyway.
## API surface
### `POST /api/v1/sessions` — modified
Two auth modes:
| Mode | Caller | Required scope | Notes |
| ----------- | ----------------------- | ----------------------------- | -------------------------------------------------- |
| Secret key | Customer's server | env-wide | `chat.createStartSessionAction` server action |
| One-time JWT| Browser | `trigger:tasks:{taskId}` | Mints via `auth.createTriggerPublicToken(taskId)` |
Body (Zod-validated):
```ts
{
type: string, // existing
externalId?: string, // chatId for chat.agent
taskIdentifier: string, // required; must match scope if JWT
triggerConfig: {
basePayload: Record<string, unknown>,
machine?: MachinePresetName,
queue?: string,
tags?: string[], // ≤5
maxAttempts?: number,
idleTimeoutInSeconds?: number,
},
tags?: string[], // existing — session-level tags
metadata?: Record<string, unknown>, // existing
}
```
Response:
```ts
{
id: string, // session_*
runId: string, // first run, freshly triggered
publicAccessToken: string, // session-scoped, long TTL
externalId: string | null,
type: string,
// ... rest of SessionItem fields
}
```
Behavior:
- Idempotent on `(env, externalId)`. Repeat calls return the existing
session, ensure-running its run if terminal, return a fresh PAT.
- Token consumption: if JWT mode, the one-time token is consumed on first
successful call (existing replay-protection infra).
- PAT scopes returned: `read:sessions:{externalId} + write:sessions:{externalId}`.
No run-scoped permissions — the transport doesn't need them.
### `POST /api/v1/sessions/:id/in/append` — modified
Add the probe + ensure-run step before the existing S2 append. Pseudocode:
```ts
const sess = await readSession(id);
if (sess.closedAt) return 400;
if (sess.expiresAt && sess.expiresAt < now) return 400;
if (!sess.currentRunId || isTerminal(await getSnapshotStatus(sess.currentRunId))) {
await ensureRunForSession(sess); // see below
}
return appendToS2(addressingKey, body); // unchanged
```
The probe is one Redis snapshot read (`getSnapshotStatus` is cheap,
already used by the run-engine). Net hot-path overhead: ~1ms.
### `POST /api/v1/sessions/:id/end-and-continue` — new
Called by the run itself (uses internal run auth, scoped to the
calling run's id + the session id). Triggers a fresh run for the same
session, atomically swaps `currentRunId`, returns the new runId.
Body:
```ts
{
reason: "upgrade" | "explicit-handoff" | string,
// optional metadata for SessionRun.reason if/when we add the join table
}
```
Response:
```ts
{ runId: string }
```
The calling run is expected to exit shortly after receiving the response —
it has done whatever wrap-up it wanted and is delegating the conversation
to the new run. The transport sees this as "more chunks arrive on `.out`,
some from v1 then some from v2" — it's the same S2 stream keyed on chatId.
### Other routes — unchanged
`GET /api/v1/sessions/:id`, `PATCH /api/v1/sessions/:id` (close, update),
`PUT /realtime/v1/sessions/:id/:io`, `GET /realtime/v1/sessions/:id/:io`
(SSE subscribe, including the row-agnostic addressing from the previous
branch) — all stay the same.
## Server internals
### `ensureRunForSession` — atomic re-run via optimistic locking
Lives in a new service: `apps/webapp/app/services/realtime/sessionRunManager.server.ts`.
```ts
async function ensureRunForSession(
sess: SessionRow,
reason: "initial" | "continuation" | "upgrade" | "manual"
): Promise<{ runId: string }> {
// 1. Trigger the run upfront. Cheap to cancel if we lose the race.
const newRun = await triggerTaskInternal(sess.taskIdentifier, sess.triggerConfig);
// 2. Try to claim the slot.
const claimed = await prisma.session.updateMany({
where: {
id: sess.id,
currentRunVersion: sess.currentRunVersion,
},
data: {
currentRunId: newRun.id,
currentRunVersion: { increment: 1 },
},
});
if (claimed.count === 1) {
// Optionally record SessionRun history here.
return { runId: newRun.id };
}
// 3. Lost the race. Cancel ours, reuse whoever won.
cancelTaskRun(newRun.id).catch(() => {/* fire-and-forget */});
const fresh = await readSession(sess.id);
if (fresh.currentRunId && !isTerminal(await getSnapshotStatus(fresh.currentRunId))) {
return { runId: fresh.currentRunId };
}
// 4. Pathological: winner's run died between win and our re-read. Recurse.
return ensureRunForSession(fresh, reason);
}
```
Key properties:
- No DB lock held across the trigger network call.
- Wasted-trigger window is small and bounded (multi-tab race on dead run,
ms apart). Cancel cost is negligible.
- Recursion only on pathological double-failure; bounded by run-engine's
own progress.
### Run-engine eager-clear (optional, defer)
A run-engine post-termination hook that nulls `Session.currentRunId` when
the terminal run matches. Purely a dashboard freshness concern. Skip in
v1 — append-time probe is the source of truth.
## SDK changes
### Transport (`packages/trigger-sdk/src/v3/chat.ts`)
State collapses to:
```ts
type ChatSessionState = {
publicAccessToken: string; // session-scoped, long TTL
lastEventId?: string; // for SSE resume
isStreaming?: boolean; // for reconnect-on-reload UX
skipToTurnComplete?: boolean; // for stop+resume UX
};
```
Note: no `runId`, no `sessionId`. The chat is the chatId; the token is
session-scoped.
Removed:
- `triggerTaskFn` callback option (constructor branch on it)
- `triggerNewRun()` method
- `renewRunPatForSession()`
- `renewRunAccessToken` callback option (token is session-scoped, doesn't
expire on run boundaries)
- `ensureSession()` (already removed in previous branch)
- The `trigger:upgrade-required` re-trigger handler in
`subscribeToSessionStream` (~50 lines)
- The `upgradeRetry: { payload, messages }` parameter threaded through
`sendMessages`, `preload`, `subscribeToSessionStream`
- The non-auth-error fallback in `sendMessages` (dead code, removed)
Renamed/replaced:
- `chat.createTriggerAction``chat.createStartSessionAction`
- Calls `sessions.create({ taskIdentifier, externalId, triggerConfig })`
server-side with secret key
- Returns `{ publicAccessToken }` (no runId — invisible to browser)
New methods:
- `transport.start(chatId, opts)` — for the browser-mediated path:
- Customer provides a `getStartToken(taskId)` callback that mints the
one-time JWT
- Transport calls `POST /sessions` with that token
- Receives session PAT, stores as state.publicAccessToken
- `transport.preload(chatId)` — same shape as `start` but with empty
basePayload override
Method behavior changes:
- `sendMessages` — no trigger logic. Always `.in/append`. Server triggers
if needed. On 401/403, error out (token expired — customer's token
callback should provide fresh).
- `subscribeToSessionStream(chatId)` — pure passthrough on `.out`. Filters
`trigger:upgrade-required` for cleanliness (server handles the re-run
swap). Filters `trigger:turn-complete` as today.
- `stopGeneration``.in/append` with `{ kind: "stop" }`. Unchanged.
- `getSession(chatId)` — returns `{ publicAccessToken, lastEventId, isStreaming }`.
No id fields.
### `chat-client.ts` (server-side AgentChat)
Mirror the transport: state without `runId`/`sessionId`, no `triggerNewRun`,
constructor takes `{ chatId, publicAccessToken }` (or mints via secret
key). All `.in/append` and `.out` URLs use `chatId`.
### `chat.agent` runtime (`packages/trigger-sdk/src/v3/ai.ts`)
- Drop the fire-and-forget `sessions.create({ externalId: chatId })` at
bind. Session already exists by the time the agent boots — server
triggers via `ensureRunForSession` after creating the row.
- Keep `sessions.open(payload.chatId)` for helper resolution. No change.
- `chat.requestUpgrade()` plumbing: calls `POST /sessions/:id/end-and-continue`
with the run's internal auth. On success, emits `trigger:upgrade-required`
on `.out` for telemetry, exits cleanly.
### Reference projects (`references/ai-chat`)
- `actions.ts`: replace `chat.createTriggerAction` callsite with
`chat.createStartSessionAction`
- `chat-app.tsx`: pass the new `start` mode to `useTriggerChatTransport`
- `chat.tsx`: drop `runId` references
- `trigger/chat.ts`: no changes (chat.agent contract unchanged from
agent-author POV)
## Auth model summary
| Token | Scopes | Where minted | Lifetime |
| ----------------------------- | ------------------------------------------------------ | ----------------------------------------- | ----------- |
| Trigger-task one-shot | `trigger:tasks:{taskId}` | `auth.createTriggerPublicToken(taskId)` | One use |
| Session PAT | `read:sessions:{ext} + write:sessions:{ext}` | Issued by `POST /sessions` | 1h24h |
| Run-internal PAT (chat.agent) | `read:runs:{run} + read:sessions:{ext} + …` | Server-side, never crosses to browser | Run-bounded |
Browser holds at most a one-shot token (briefly) and a session PAT
(steady state). Never holds a run-scoped token.
## Edge cases
- **Concurrent multi-tab on dead run** — optimistic locking handles it,
loser cancels its triggered run.
- **Page refresh mid-stream** — `.out` SSE resumes via Last-Event-ID
(existing); session PAT survives because it's not run-scoped.
- **Run cancelled by user (dashboard)** — append-time probe sees terminal,
triggers new run on next message.
- **Idle exit** — same path; user comes back later, sends message, fresh
run boots.
- **Crash mid-turn (no `turn-complete` emitted)** — same path; persisted
store is pre-turn, fresh run reads `.in` from tail position, picks up
unanswered message.
- **Upgrade during user message** — optimistic locking in
`end-and-continue` ensures one wins. If user message wins,
`end-and-continue` returns conflict, agent v1 keeps running, processes
message, retries upgrade later. If upgrade wins, user message's append
probes fresh `currentRunId` (v2), uses it.
- **Session expiry mid-conversation** — `.in/append` and `end-and-continue`
reject after `expiresAt`. Existing run keeps running until idle, then
exits. Frontend sees a 400.
- **Concurrent `POST /sessions`** — unique constraint on
`(env, externalId)`, idempotent upsert returns existing row + ensure-runs.
## Tests
### Unit
- `ensureRunForSession`:
- Happy path (no contention)
- Concurrent contention (two callers, one wins, loser reuses winner's
run)
- Pathological recursion (winner's run dies before loser re-reads)
- Trigger failure (caller's responsibility to surface)
- `POST /sessions` route:
- Idempotent upsert (same externalId → same row, fresh PAT)
- Auth: secret key path, JWT path with valid scope, JWT path with wrong
task scope (403), JWT replay (consumed token rejected)
- First run triggered, runId in response
- `POST /sessions/:id/in/append`:
- Probe path: alive run, terminal run, null currentRunId
- Probe + trigger: ensure new run before append
- Closed session 400
- Expired session 400
- `POST /sessions/:id/end-and-continue`:
- Auth: only callable from the current run
- Optimistic locking: stale currentRunId loses gracefully
### Integration
- chat.test.ts rewrite around the new transport surface (no `runId`,
no `triggerNewRun`)
- mock-chat-agent harness updates: install `__setSessionCreateImplForTests`
to also stub the first-run trigger (the create + trigger is now atomic
on the server, so the test harness needs to surface a fake runId)
### Smoke (manual via Chrome DevTools)
Same checklist as the previous branch's smoke test, plus:
- Cancel run via dashboard → next user message triggers fresh run
automatically (no longer a gap)
- Deploy a new agent version mid-conversation → existing run requests
upgrade, exits, new run continues seamlessly (transport sees no
interruption beyond a possible extra TTFB)
## Verification plan
Per-package:
```
pnpm run typecheck --filter webapp # apps + internal pkgs
pnpm run typecheck --filter @internal/run-engine
pnpm run build --filter @trigger.dev/sdk # public package
pnpm run build --filter @trigger.dev/core # public package
pnpm run test --filter webapp -- sessionRunManager
pnpm run test --filter @trigger.dev/sdk -- chat
```
End-to-end via the playground:
1. ai-chat (chat.agent) — basic send + reply
2. ai-chat-session (custom agent) — basic send + reply
3. ai-chat-raw — basic send + reply
4. ai-chat-hydrated — basic send + reply
5. Mid-stream reload — SSE reconnect
6. Stop + follow-up — same run handles next turn
7. Cancel run + send message → new run triggered automatically (the gap
from previous branch's S4 — must pass cleanly here)
8. Deploy new version + send message → in-flight conversation upgrades
transparently
9. Cross-form addressing curl matrix — unchanged from previous branch
## Rollout
- Single feature branch off `main` (or off the previous chat-agent-sessions
branch once that lands).
- No flag, no shim. Hard cutover. Pre-release SDK version.
- Reference projects updated in the same PR so the smoke test path works.
## Open questions
1. **Should `end-and-continue` accept a custom `triggerConfig` override?**
Use case: agent wants to swap to a different task identifier (rare).
Probably defer — keep it strictly "trigger another run with the same
config" for v1.
2. **Should `triggerConfig` pin the deploy version?** If a customer
redeploys with a chat.agent contract change, in-flight sessions might
have payloads incompatible with the new version. Probably defer —
chat.agent contract is stable; signature-breaking changes are rare and
warrant explicit handling.
3. **`SessionRun` join table**: yes/no/defer? Defer to v1.1 unless a
concrete dashboard surface needs it.
4. **`getSnapshotStatus` cost on hot path** — measure before optimizing.
Redis snapshot read should be sub-ms; if it isn't, cache for 1-2s
per session.
## Out of scope
- Session-level retry policies (separate feature)
- Multi-run-per-session (parallel agents on one chat) — explicit
non-goal; one currentRunId by design
- Cross-environment sessions (a session in dev, run in prod) — not
considered
- Public `Session.requestRun()` for callers other than the running
agent itself — defer until a use case appears
- Webhook notifications on run swap — defer
## Effort estimate
- Schema + migration: 0.5 day
- `ensureRunForSession` service + tests: 1.5 days
- `POST /sessions` auth modes + idempotent upsert + first-run trigger: 1 day
- `.in/append` probe: 0.5 day
- `end-and-continue` route + agent runtime wiring: 1 day
- Transport rewrite + tests: 2.5 days
- chat-client rewrite + tests: 1 day
- chat.agent runtime cleanup: 0.5 day
- `chat.createStartSessionAction` + browser path: 1 day
- Reference project migration: 0.5 day
- Smoke test + bug-fix buffer: 1.5 days
**~11 days** focused work. Plus design doc review and any architectural
back-and-forth — call it 2 weeks calendar.
## Implementation order
1. Schema + migration (gives the new columns; everything else builds on this)
2. `ensureRunForSession` service + unit tests (the load-bearing primitive)
3. `POST /sessions` route changes (creates a session that actually has a run)
4. `.in/append` probe path (so the server can self-heal between runs)
5. `end-and-continue` route + chat.agent runtime call (upgrade flow)
6. Transport rewrite (depends on all the server pieces)
7. chat-client rewrite (mirrors transport; cheap once that's done)
8. `chat.createStartSessionAction` + reference project migration
9. Smoke test + final bug fixes
@@ -0,0 +1,366 @@
# Docs update plan: Sessions-as-run-manager
Companion to commits `7a48c1e6` (ai-chat) and `427541c2` (sessions server). Captures every doc page that needs to change, what's getting removed, and an upgrade guide for prerelease users.
## Architectural summary (the diff readers should internalize)
Pre-migration mental model: Sessions and chat.agent were two separate primitives. Sessions had its own create/list/close API; chat.agent rolled its own run-scoped streams. The two coexisted but didn't share machinery — chat.agent's wire path (run streams) was distinct from Sessions' wire path (`.in` / `.out` channels).
Post-migration mental model: **Sessions is the run manager.** A Session row is task-bound (`taskIdentifier` + `triggerConfig` are required), it owns its current run via `currentRunId` (optimistic-claim), and it tracks every run it ever triggered in a `SessionRun` audit table. chat.agent is now just a particular kind of task you bind a Session to. The standalone "create a Session, then trigger something against it" path is gone — `sessions.start({...})` atomically creates the row and triggers the first run.
Wire-level, the transport now talks to one set of routes (`/realtime/v1/sessions/:s/...` and `/api/v1/sessions/:s/...`); the per-run-stream code path is dead for chat.
## Standalone Sessions docs: REMOVE
`docs/sessions/` was written for the standalone-Session model. With sessions now task-bound, every page in that directory is incorrect:
- `sessions/overview.mdx` — describes a generic session-as-bidirectional-channel primitive. Standalone create/list/close as the entry point.
- `sessions/quick-start.mdx``sessions.create({type, externalId})` then trigger something. Pattern no longer exists.
- `sessions/channels.mdx``.in` / `.out` documented from the standalone-session perspective.
- `sessions/reference.mdx` — API surface for the standalone primitive.
**Action:**
1. Delete all four files: `docs/sessions/{overview,quick-start,channels,reference}.mdx`.
2. Remove the entire `Sessions` group from `docs/docs.json` under the `AI` group:
```json
{
"group": "Sessions",
"pages": ["sessions/overview", "sessions/quick-start", "sessions/channels", "sessions/reference"]
}
```
3. Don't redirect — the URLs were never widely shared (this was alpha-tier surface). If we add Sessions docs back later, we can decide redirect-vs-fresh-slug then.
We'll re-introduce Sessions docs once the primitive is stable and we have a non-chat.agent customer flow to document.
## ai-chat docs: UPDATE
Pages listed in the order they appear in `docs.json`. Each entry calls out the specific stale claims and what to replace.
### `ai-chat/overview.mdx`
- Replace any line that says chat.agent runs on per-run streams or that the transport mints run-scoped tokens.
- Add one paragraph on the underlying primitive: chat.agent is bound to a Session that owns its runs. Customer-facing surface unchanged.
- If there's a "how it works" diagram, update arrows: browser → server action → `chat.createStartSessionAction` → Session row + first run + session PAT → browser → `.in/append` + `.out` SSE.
### `ai-chat/changelog.mdx`
- Add an entry for the migration: "Sessions-as-run-manager — chat.agent now runs on top of a durable Session row that owns its runs. Public surface unchanged. See upgrade guide."
### `ai-chat/quick-start.mdx`
- The transport snippet is the highest-value example in the docs. It must show the new shape:
```ts
const transport = useTriggerChatTransport<typeof myAgent>({
task: "my-agent",
accessToken: ({ chatId }) => mintAccessToken(chatId),
startSession: ({ chatId, taskId, clientData }) =>
startChatSession({ chatId, taskId, clientData }),
});
```
- Server actions page should show `chat.createStartSessionAction("my-agent")` and `auth.createPublicToken({scopes: {sessions: chatId}})`.
- Drop any mention of `getStartToken` and `auth.createTriggerPublicToken` for the chat path.
### `ai-chat/backend.mdx`
- The `chat.agent({...})` shape itself is unchanged — leave the `run`, `onPreload`, `onTurnStart`, `onTurnComplete` callbacks alone.
- Add a section on `chat.createStartSessionAction(taskId, options?)`. This is the canonical server-side entry point now. Show:
- Default `triggerConfig.basePayload`: `{messages: [], trigger: "preload"}` baked in. Customer overrides via `options.triggerConfig`.
- Idempotent on `(env, externalId)`. Concurrent calls for the same chatId converge.
- Returns `{sessionId, runId, publicAccessToken}`.
- Update `chat.requestUpgrade()` description: it now calls `endAndContinueSession` server-side, which atomically swaps `Session.currentRunId` to a new run. Browser keeps streaming across the swap.
### `ai-chat/frontend.mdx`
- This is where most of the transport API lives. Rewrite around the two callbacks:
- `accessToken: ({chatId}) => string` — pure refresh, called on 401/403.
- `startSession?: ({chatId, taskId, clientData}) => {publicAccessToken}` — wraps the customer's server action, called on `transport.preload(chatId)` and lazy first `sendMessage`.
- Show the typed `clientData` flow: `useTriggerChatTransport<typeof myAgent>` infers `clientData` from `withClientData`, threads it into `startSession`'s params, and merges into per-turn `metadata`.
- Drop `getStartToken` documentation entirely.
- `transport.preload(chatId)` no longer takes per-call options. If the customer needs dynamic per-call config they capture it in their server action via closure (typically over a ref for live values like the playground's `clientDataJsonRef`).
- Persistable `ChatSession`: `{publicAccessToken, lastEventId?}`. `runId` is gone.
### `ai-chat/server-chat.mdx`
- `AgentChat` (server-side chat client) — same shape, but the `session` prop now takes `{lastEventId?}` only.
- `onTriggered({runId, chatId})` callback is still useful for telemetry / dashboard linking — the `runId` is the *current* run, not the only run. Note that across turns the runId may change (continuation runs after idle, upgrade runs, etc.).
### `ai-chat/types.mdx`
- `ChatSession` — drop `runId`, drop `sessionId`. Just `{publicAccessToken, lastEventId?}`.
- `StartSessionParams<TClientData>`, `StartSessionResult` — new public types.
- `AccessTokenParams` — narrowed to `{chatId}` only (no metadata threading).
- Remove `GetStartTokenParams` from the type table.
### `ai-chat/features.mdx`
- Audit for any mention of run-scoped streams, `CHAT_STREAM_KEY`, `CHAT_MESSAGES_STREAM_ID`, `CHAT_STOP_STREAM_ID`. All gone.
- Add: cross-form addressing on the wire (a session-scoped JWT minted for either `externalId` or `friendlyId` form authorizes either URL form).
- Add: SessionRun audit log — every run a chat session has triggered is recorded, queryable via the dashboard.
### `ai-chat/compaction.mdx`
- Should be untouched (compaction lives inside `chat.agent`'s turn loop, doesn't depend on the wire model).
### `ai-chat/pending-messages.mdx`
- Should be untouched (steering messages flow through `.in.append` regardless).
### `ai-chat/background-injection.mdx`
- Same — injection happens inside the run, the run's wire path swap doesn't affect it.
### `ai-chat/error-handling.mdx`
- Add: errors from `startSession` callback. The customer's server action can fail (auth check, DB write). Surface via `onSessionChange(chatId, null)` or via the customer's own try/catch in their callback.
- Replace any 401/403 retry logic that mentions `getStartToken` — it's `accessToken` now.
### `ai-chat/mcp.mdx`
- Audit for `getStartToken` mentions in MCP tool examples.
### `ai-chat/testing.mdx`
- The `mock-chat-agent` test harness moved to `setupSessionStartImplForTests` / similar — verify and update examples.
- Show how to mock `startSession` in unit tests (it's a fetch-mock or vi.fn returning `{publicAccessToken}`).
### `ai-chat/client-protocol.mdx`
- The wire-level protocol page. Replace any `/realtime/v1/streams/{runId}/chat` URLs with `/realtime/v1/sessions/{chatId}/{io}`.
- Document the chunk shape on `.in`: tagged union — `{kind: "message", payload}` for user turns, `{kind: "stop"}` for stop signals, `{kind: "action", name, payload}` for typed actions.
- Document `.out` chunks: `UIMessageChunk`s interleaved with `trigger:turn-complete`, `trigger:upgrade-required` control markers.
- Cross-form addressing on session-scoped PATs.
### `ai-chat/reference.mdx`
- Public API surface tables. `TriggerChatTransportOptions` — drop `getStartToken`, `triggerConfig`, `triggerOptions`; add `startSession`.
- `chat.createStartSessionAction(taskId, options?)` — full signature.
- `chat.requestUpgrade()` — keep, but note the new server-orchestrated swap behaviour.
### `ai-chat/patterns/version-upgrades.mdx`
- This page is essentially about `chat.requestUpgrade()`. Update to explain the new mechanism:
- Old: agent emitted `trigger:upgrade-required` chunk, transport consumed it, transport triggered a new run from the browser side.
- New: agent calls `endAndContinueSession` (server-to-server), webapp atomically swaps `Session.currentRunId` to a freshly-triggered run, transport's existing SSE keeps streaming on the same session — no transport-side swap.
- Add: `SessionRun` audit row with `reason: "upgrade"`.
### `ai-chat/patterns/sub-agents.mdx`
- Audit for any session.create / sub-agent-as-session-creator patterns. Sub-agents now get their session via the parent's task trigger (or by calling `sessions.start({ ... })` themselves with a different taskIdentifier).
### `ai-chat/patterns/database-persistence.mdx`
- The reference app's `ChatSession` schema is now simpler: `{id, publicAccessToken, lastEventId?}`. Drop `runId`/`sessionId` columns from any example schemas.
- The persistence pattern itself is unchanged: persist the PAT + lastEventId, hydrate on page load via `sessions: { [chatId]: ... }` on the transport.
### `ai-chat/patterns/branching-conversations.mdx`
- Should be mostly unchanged. Branching is a customer-side concern (multiple chatIds, each one its own session).
### `ai-chat/patterns/code-sandbox.mdx`
- Audit for stale references. Probably fine.
### `ai-chat/patterns/human-in-the-loop.mdx`
- Should be unchanged.
### `ai-chat/patterns/skills.mdx`
- Should be unchanged.
## NEW page: upgrade guide for chat.agent prerelease users
Filename: `docs/ai-chat/upgrade-guide.mdx` (or `migration-from-prerelease.mdx` — pick whichever fits the docs style). Add to `docs.json` near the top of the AI Chat group, between `overview` and `quick-start`.
Contents:
```mdx
---
title: "Upgrade guide: prerelease → Sessions-as-run-manager"
description: "Migrating chat.agent code from the prerelease API to the Sessions-as-run-manager release."
---
# Upgrade guide
This guide is for customers who tried `chat.agent` during the prerelease period
(any `@trigger.dev/sdk` build before vX.Y.Z). The public surface is largely
unchanged — `chat.agent({...})`, `useTriggerChatTransport`, `chat.store` /
`chat.defer` / `chat.history`, `AgentChat` — but the transport callbacks and a
few server-side helpers were renamed.
## TL;DR
- **`getStartToken` is gone.** Replace with `startSession`, a server-action
callback that returns `{publicAccessToken}`.
- **`chat.createStartSessionAction(taskId, options?)` is the canonical
server-side entry point.** Replaces ad-hoc `auth.createTriggerPublicToken` +
manual session create.
- **`ChatSession` persistable shape changed.** Drop the `runId` field;
store only `{publicAccessToken, lastEventId?}`.
- **`transport.preload(chatId)` no longer takes per-call options.**
Trigger config (machine, idleTimeoutInSeconds, tags) lives server-side in
`chat.createStartSessionAction(taskId, options)`.
- **Wire URLs changed.** Anything that hit
`/realtime/v1/streams/{runId}/chat` directly should use
`/realtime/v1/sessions/{chatId}/out` (subscribe) or
`/realtime/v1/sessions/{chatId}/in/append` (send).
## Transport: replace `getStartToken` with `startSession`
### Before
```ts
const transport = useTriggerChatTransport({
task: "my-agent",
accessToken: async ({ chatId }) => mintToken(chatId),
getStartToken: async ({ taskId }) => mintTriggerToken(taskId),
triggerConfig: { basePayload: { /* ... */ } },
triggerOptions: { tags: [...], machine: "small-1x" },
});
```
The browser called `auth.createTriggerPublicToken(taskId)` server-side to get
a one-shot trigger JWT, then `POST /api/v1/sessions` from the browser.
### After
```ts
const transport = useTriggerChatTransport<typeof myAgent>({
task: "my-agent",
accessToken: ({ chatId }) => mintAccessToken(chatId),
startSession: ({ chatId, taskId, clientData }) =>
startChatSession({ chatId, taskId, clientData }),
});
```
Where `startChatSession` is a server action wrapping
`chat.createStartSessionAction`:
```ts
"use server";
import { chat } from "@trigger.dev/sdk/ai";
export const startChatSession = chat.createStartSessionAction("my-agent", {
triggerConfig: {
machine: "small-1x",
tags: ["my-tag"],
},
});
```
The browser never holds a `trigger:tasks:{taskId}` JWT now. All session
creation goes through the customer's server, where authorization decisions
live alongside the customer's own DB writes.
## Server actions: replace ad-hoc helpers with `chat.createStartSessionAction`
### Before
```ts
"use server";
import { auth, sessions } from "@trigger.dev/sdk";
export async function startChatSession({ chatId, taskId }) {
const session = await sessions.create({
type: "chat.agent",
externalId: chatId,
});
// ... separately trigger the agent task ...
const publicAccessToken = await auth.createPublicToken({
scopes: { read: { sessions: chatId }, write: { sessions: chatId } },
});
return { publicAccessToken };
}
```
### After
```ts
"use server";
import { chat } from "@trigger.dev/sdk/ai";
export const startChatSession = chat.createStartSessionAction("my-agent");
```
The new helper handles session creation + first-run trigger + PAT mint
atomically. It's idempotent on `(env, externalId)` — concurrent calls for the
same `chatId` converge to the same session.
## `ChatSession` shape: drop `runId`
Persistable session state is now just the PAT + last event ID:
```ts
// before
type ChatSession = { runId: string; publicAccessToken: string; lastEventId?: string };
// after
type ChatSession = { publicAccessToken: string; lastEventId?: string };
```
If your DB schema has a `runId` column on a session-state table, drop it (or
keep it for telemetry — but the transport doesn't read it). The current run
ID is server-side state on the Session row; the transport doesn't need to
know it.
## `clientData`: typed and threaded automatically
If your agent uses `chat.agent(...).withClientData({schema})`, the transport
infers the `clientData` type from `useTriggerChatTransport<typeof agent>`
and threads it through `startSession`'s params. Set it once on the
transport:
```ts
useTriggerChatTransport<typeof myAgent>({
// ...
clientData: { userId: currentUser.id, plan: currentUser.plan },
});
```
The same value also merges into per-turn `metadata` on the wire, and your
`startSession` callback receives it as `params.clientData`. Pass through to
`chat.createStartSessionAction` via `triggerConfig.basePayload.metadata` and
the agent's first run sees it in `payload.metadata`.
## `chat.requestUpgrade()`: server-orchestrated now
The behaviour didn't change from the customer's perspective — call
`chat.requestUpgrade()` inside `onTurnStart` / `onValidateMessages` and the
current run will exit so the next message starts on the latest version.
What changed under the hood:
- **Before:** the agent emitted a `trigger:upgrade-required` chunk on
`.out`, the transport consumed it browser-side and triggered a new run.
- **After:** the agent calls `endAndContinueSession` server-to-server, the
webapp triggers a new run and atomically swaps `Session.currentRunId`,
the browser's existing SSE subscription keeps receiving chunks across
the swap. Faster handoff, no browser-side bookkeeping.
The `SessionRun` audit table records every run, including upgrade-driven
ones (with `reason: "upgrade"`).
## Going to URLs directly?
Anyone hitting raw URLs (instead of going through the SDK) should switch:
| Before | After |
|---|---|
| `/realtime/v1/streams/{runId}/chat` (subscribe) | `/realtime/v1/sessions/{chatId}/out` |
| `/realtime/v1/streams/{runId}/{target}/chat-messages/append` | `/realtime/v1/sessions/{chatId}/in/append` (`{kind: "message", payload}` body) |
| `/realtime/v1/streams/{runId}/{target}/chat-stop/append` | `/realtime/v1/sessions/{chatId}/in/append` (`{kind: "stop"}` body) |
The session-scoped PAT (`read:sessions:{chatId} + write:sessions:{chatId}`)
authorizes both the `externalId` form (e.g. `/sessions/my-chat-id/out`)
and the `friendlyId` form (e.g. `/sessions/session_abc.../out`).
## Things that didn't change
- `chat.agent({...})` definition shape and all callbacks.
- `chat.store` / `chat.defer` / `chat.history` APIs.
- `AgentChat` (server-side chat client) — same constructor, same methods.
- `useTriggerChatTransport`'s React semantics (created once, kept in a ref,
callbacks updated via `setOnSessionChange` / `setClientData` under the hood).
- Multi-tab coordination, pending-messages / steering, background injection.
- Per-turn `metadata` flowing through `sendMessage({ text }, { metadata })`.
```
## Other doc surfaces touched
- `docs/ai/prompts.mdx` — only mentions `chat.agent` in passing. Audit but probably no change.
- `docs/realtime/backend/streams.mdx`, `docs/realtime/backend/input-streams.mdx` — these are the older streams API docs. Verify they don't reference `CHAT_STREAM_KEY` or `CHAT_MESSAGES_STREAM_ID` (those constants were removed).
- `docs/mcp-tools.mdx` — likely mentions the chat MCP tools. Audit for `getStartToken`-shaped examples.
- `docs/guides/example-projects/anchor-browser-web-scraper.mdx` — example project. Likely uses `chat.agent`. Audit.
- `docs/tasks/schemaTask.mdx` — only matched on the term "session" probably. Audit.
## Update sequence
Suggested order to minimise stale-state windows for readers:
1. **Add the upgrade guide** (`ai-chat/upgrade-guide.mdx`) and its nav entry. This is the most-needed doc and stands alone from the rest.
2. **Update transport-shape pages** in this order: `quick-start` → `frontend` → `backend` → `server-chat` → `types` → `reference`. They all show the same callback shape; readers cross-reference between them, so they should ship together.
3. **Update peripheral pages**: `overview`, `changelog`, `client-protocol`, `error-handling`, `testing`, `features`, patterns.
4. **Remove `docs/sessions/`** + nav group last. Until step 2 lands the standalone Sessions docs are still less misleading than half-stale chat.agent docs.
## Out of scope for this pass
- Re-adding standalone Sessions docs (deferred until the primitive is stable for non-chat use).
- Diagrams / illustrations — text-first pass; designer can layer visuals after.
- Sample customer projects — the `references/ai-chat` reference repo is the in-source example; if marketing wants a polished standalone sample, that's a separate effort.
@@ -0,0 +1,287 @@
# Review guide — chat.agent on Sessions, row-agnostic addressing
Scope: the 12 uncommitted files. **No new behaviour beyond the public surface
already on this branch** — this is plumbing cleanup that:
1. Eliminates the transport's session-creation step
2. Makes `chatId` the universal addressing string everywhere
3. Makes the server-side stream/append/wait routes row-agnostic
## The two design moves
**Move 1 — agent owns session lifecycle.** `chat.agent` and
`chat.customAgent` upsert the backing `Session` row at bind, fire-and-forget,
keyed on `externalId = payload.chatId`. The transport, server-side
`AgentChat`, and `chat.createTriggerAction` no longer create sessions at all.
Browsers cannot mint sessions either (`POST /api/v1/sessions` is now
secret-key-only). One owner, one path.
**Move 2 — `chatId` is the only address.** The transport, server-side
`AgentChat`, JWT scopes, and S2 stream paths all use `chatId` directly. The
Session's friendlyId is informational. To make this safe, the three stream
routes (`.in/.out` PUT, GET, POST append, plus the run-engine `wait`
endpoint) became "row-optional" and derive a *canonical addressing key*
(`row.externalId ?? row.friendlyId`, fallback to the URL param when the row
hasn't been upserted yet). Same canonical key is used to build the S2 stream
path, the waitpoint cache key, and the JWT resource set — so any caller
addressing by either form converges on the same physical stream.
Together these remove an entire class of "did the row land yet?" races. The
transport can subscribe to `/sessions/{chatId}/out` before the agent boots,
the agent's `void sessions.create({externalId: chatId})` lands a moment
later, and any earlier reads/writes are already on the right S2 key.
---
## Read in this order
### 1. `apps/webapp/app/services/realtime/sessions.server.ts` (+34 lines)
The new primitive. Two helpers:
- `isSessionFriendlyIdForm(value)``value.startsWith("session_")`. Used to
decide whether a missing row is a hard 404 (opaque friendlyId) or a soft
"row will land later" (externalId form).
- `canonicalSessionAddressingKey(row, paramSession)` — `row.externalId ??
row.friendlyId` if the row exists, else `paramSession`. **This is the load-
bearing function.** Read its docstring.
**Question to ask:** can two callers addressing the "same" session ever get
different canonical keys? Only if the row exists for one and not the other,
*and* the URL forms differ — but in that case the row-less caller used the
externalId form (friendlyId-form would have 404'd earlier), and the row-ful
caller computes `row.externalId ?? row.friendlyId`. If the row's externalId
matches the URL, they converge. If it doesn't, there's no row to find by
that string anyway. The interesting edge is "row exists with no externalId",
addressed via friendlyId — both sides read `row.friendlyId`. ✓
### 2. `apps/webapp/app/routes/realtime.v1.sessions.$session.$io.ts` (+47/-12)
PUT initialize + GET subscribe (SSE). Both use the helper. The interesting
part is the loader's `findResource` + `authorization.resource`:
```ts
findResource: async (params, auth) => {
const row = await resolveSessionByIdOrExternalId(...);
if (!row && isSessionFriendlyIdForm(params.session)) return undefined; // 404
return { row, addressingKey: canonicalSessionAddressingKey(row, params.session) };
},
authorization: {
resource: ({ row, addressingKey }) => {
const ids = new Set<string>([addressingKey]);
if (row) {
ids.add(row.friendlyId);
if (row.externalId) ids.add(row.externalId);
}
return { sessions: [...ids] };
},
superScopes: ["read:sessions", "read:all", "admin"],
},
```
**Why three IDs in the resource set?** `checkAuthorization` is "any-match"
across the resource values. We want a JWT scoped to *either* form to
authorize *either* URL form. Smoke test verified the 4-cell matrix passes.
**The PUT path** (action handler) is simpler — it just resolves the row,
builds an addressing key, and hands it to `initializeSessionStream`. Worth
noting the `closedAt` check is now `maybeSession?.closedAt` — no row means
no closedAt to enforce.
### 3. `apps/webapp/app/routes/realtime.v1.sessions.$session.$io.append.ts` (+22/-13)
POST append (browser writes a record to `.in` or server writes to `.out`).
Same row-optional pattern. Both the S2 append and the waitpoint drain use
`addressingKey`.
**Question to ask:** what fires the waitpoint? An agent's
`session.in.wait()` registers a waitpoint keyed on `(addressingKey, io)` via
the wait endpoint (file 4). The append handler drains by the *same* key —
even if the agent registered with externalId form and the transport
appended via friendlyId form, both compute the same canonical key, so they
converge. ✓
### 4. `apps/webapp/app/routes/api.v1.runs.$runFriendlyId.session-streams.wait.ts` (+18/-13)
The agent's `.in.wait()` endpoint. Run-engine creates the waitpoint, then
registers it in Redis under `(addressingKey, io)`. The race-check that runs
right after creation reads from S2 by the same key. Three call sites —
`addSessionStreamWaitpoint`, `readSessionStreamRecords`,
`removeSessionStreamWaitpoint` — all consistent.
### 5. `apps/webapp/app/routes/api.v1.sessions.ts` (+4/-2)
**Security tightening.** Removed `allowJWT: true` and `corsStrategy: "all"`
from the `POST /api/v1/sessions` action — secret-key only now.
**Question to ask:** was the JWT path actually used? Until this branch, the
transport called it via `ensureSession` (now deleted). After this branch,
nobody reaches it from the browser. `chat.createTriggerAction` (server
secret key) is the only browser-adjacent path.
### 6. `packages/trigger-sdk/src/v3/ai.ts` (+62/-39)
Two near-identical edits — one in `chatAgent`, one in `chatCustomAgent`.
Both bind on `payload.chatId` and fire-and-forget the upsert:
```ts
locals.set(chatSessionHandleKey, sessions.open(payload.chatId));
void sessions
.create({ type: "chat.agent", externalId: payload.chatId })
.catch(() => { /* best effort */ });
```
**Question to ask:** why `void`-and-`catch`? Awaiting the upsert would gate
the agent's bind on a network round-trip that doesn't unblock anything
user-visible — `.in/.out` routes are row-agnostic and the waitpoint cache
is keyed on the addressing string, not the row id. If the upsert genuinely
fails, the next bind retries the same idempotent call (`sessions.create`
upserts on `externalId`, so concurrent triggers on one chatId converge to
one row). The row matters for downstream metadata + listing, not for live
addressing.
The PAT scope minting in `chatAgent` (two call sites — preload and
sendMessage) now uses `payload.chatId` for the `sessions:` resource. That
matches what the transport/AgentChat use as the JWT resource and what the
JWT's resource set in the loader includes. Cross-form addressing works
either way (smoke-tested), but using `chatId` keeps the chain tight.
`createChatTriggerAction` is the most visibly trimmed: no pre-create, no
threading `sessionId` into payload, scope mint uses `chatId`. Return type
no longer carries `sessionId` — note `TriggerChatTaskResult.sessionId` was
already declared optional, so this isn't a public-API break.
**Stale docstring to flag:** `chat.ts:59` and `chat.ts:112` still describe
PAT scopes as `read:sessions:{sessionId}` and
`write:sessions:{sessionId}`. Functionally either ID works (row lookup
canonicalises), but the doc text is now out of date — it should say
`{chatId}`. Worth a tidy-up before merge but not blocking.
### 7. `packages/trigger-sdk/src/v3/chat.ts` (+63/-117)
**The biggest mechanical edit.** Net -54 lines from deleting `ensureSession`
and untangling its callers.
What disappeared:
- `private async ensureSession(chatId)` — gone
- The "lazy upsert from the browser if no triggerTask callback" branch in
`sendMessages` and `preload` — gone
- The "throw if neither path surfaced a sessionId" guard — gone
- All `state.sessionId` URL params replaced with `chatId`
- `subscribeToSessionStream`'s `chatId?` (optional) is now `chatId` (required)
What stayed:
- `state.sessionId` in `ChatSessionState` — optional, informational
- The `restore from external storage` branch in the constructor still
hydrates `sessionId` if persisted, just doesn't *require* it
- `notifySessionChange` still surfaces `sessionId` if known
**Question to ask:** does the transport ever still need the friendlyId? The
only place is the `onSessionChange` callback's payload (so consumers
persisting state can save it for later display). The transport itself never
puts it in a URL or a waitpoint key.
The `sendMessages` path is worth re-reading: when state.runId is set, it
appends to `.in/append` and subscribes to `.out`. If the append fails with
a non-auth error, it falls through to triggering a new run (legacy "run is
dead" detection — unchanged from pre-Sessions, doesn't depend on
addressing).
### 8. `packages/trigger-sdk/src/v3/chat-client.ts` (+34/-33)
Server-side `AgentChat`. Mirrors the transport changes — every URL uses
`this.chatId`. `triggerNewRun` no longer pre-creates a session. `ChatSession`
and internal `SessionState` types now have optional `sessionId`.
The shape of the diff is identical to the transport: delete the upsert,
swap addressing identifiers, optionalise the friendlyId. If you've read
`chat.ts` carefully, this one is mostly mechanical confirmation that both
client surfaces (browser transport + server-side AgentChat) speak the same
addressing protocol.
### 9. Test infrastructure — `sessions.ts` (+18) + `mock-chat-agent.ts` (+25)
`__setSessionCreateImplForTests` mirrors the existing
`__setSessionOpenImplForTests`. `mockChatAgent` installs a no-op create stub
returning a synthetic `CreatedSessionResponseBody` so the agent's bind-time
`void sessions.create(...)` doesn't try to hit a real API. Cleanup runs in
the same `.finally` as the open override.
**Question to ask:** is the synthetic response shape correct? It mirrors
`CreatedSessionResponseBody` — `id`, `externalId`, `type`, `tags`,
`metadata`, `closedAt`, `closedReason`, `expiresAt`, `createdAt`,
`updatedAt`, `isCached`. Tests don't currently assert on this object, so
the bar is "doesn't crash + matches the type". Met.
### 10. `packages/trigger-sdk/src/v3/chat.test.ts` (+13/-12)
Three classes of test edits, all consequences:
- Stream URL assertion: `chat-1` (the chatId) instead of
`session_streamurl` (the friendlyId)
- `renewRunAccessToken` callback: `sessionId: undefined` (was
`DEFAULT_SESSION_ID` because the mocked trigger doesn't surface it)
- Token resolve count: `1` (was `2` — second resolve was for `ensureSession`)
- One `onSessionChange` matchObject loses `sessionId`
### 11. `apps/webapp/app/routes/_app.../playground/.../route.tsx` (1 line)
`sessionId: string` → `sessionId?: string` in the playground sidebar prop
to track the transport type change.
---
## Edge cases I checked, so you don't have to
- **Cross-form JWT auth (curl matrix).** JWT scoped to externalId can call
externalId URL ✓ and friendlyId URL ✓. JWT scoped to friendlyId can call
externalId URL ✓ and friendlyId URL ✓. Smoke-tested.
- **Row materialises after subscribe.** Transport opens
`GET /sessions/{chatId}/out` before agent's bind upsert lands → 200 OK,
`addressingKey = chatId` (paramSession fallback). Once the row lands
with `externalId = chatId`, addressingKey resolves to the same value via
`row.externalId`. Same S2 key throughout.
- **Concurrent triggers on one chatId.** Two browser tabs trigger two runs
→ two binds → two `sessions.create({externalId: chatId})` calls. Upsert
semantics: both return the same row.
- **Closed session enforcement.** Still enforced when a row exists.
`maybeSession?.closedAt` is null-safe; no row = no close-state to honour.
- **Agent run cancellation.** Frontend doesn't auto-detect — unchanged from
pre-Sessions; messages sit in S2 until the next trigger (the existing
run-PAT auth-error path is the only reaper). Out of scope for this branch.
- **Idle timeout in dev.** Runs stay `EXECUTING_WITH_WAITPOINTS` past the
configured idle because dev runs don't snapshot/restore; the in-process
idle clock advances locally without touching the row. Expected, not a
regression.
## Things explicitly **not** in this branch
- Run-state subscription on the transport side (the "run died, re-trigger
silently" UX gap)
- Session auto-close on agent exit (still client-driven by design)
- Any change to `Session` schema, `sessions.create` semantics, or
`chatAccessTokenTTL`
- Docstring updates for `read:sessions:{sessionId}` / `write:sessions:{sessionId}`
in `chat.ts:59` and `chat.ts:112` (functional but textually stale —
follow-up nit)
---
## What I'd be ready to answer cold
- Why fire-and-forget upsert (vs. `await`) in the agent's bind step
- Why the route's authorization resource set has three IDs (cross-form JWT
auth)
- Why `POST /api/v1/sessions` lost `allowJWT` (security tightening — no
caller needs it after the transport's `ensureSession` is gone)
- What converges two callers using different URL forms onto the same S2
stream (`canonicalSessionAddressingKey`, identical computation on both
sides for any given row)
- What makes `sessions.create` race-safe under concurrent triggers
(`externalId` upsert)
- Why `state.sessionId` stayed on `ChatSessionState` at all (pure
informational, surfaced via `onSessionChange` for consumer persistence;
zero addressing role)
- Why the chat-client (server-side AgentChat) and chat (transport) edits
look near-identical (they implement the same client protocol against the
same row-agnostic routes)
+3
View File
@@ -808,6 +808,7 @@ describe("TriggerChatTransport", () => {
expect(renewSpy).toHaveBeenCalledWith({
chatId: "chat-renew-sse",
runId: "run_renew_sse",
sessionId: DEFAULT_SESSION_ID,
});
const patStreamCall = (global.fetch as ReturnType<typeof vi.fn>).mock.calls.find(
@@ -893,6 +894,7 @@ describe("TriggerChatTransport", () => {
expect(renewSpy).toHaveBeenCalledWith({
chatId: "chat-fail-renew",
runId: "run_fail_renew",
sessionId: DEFAULT_SESSION_ID,
});
});
@@ -975,6 +977,7 @@ describe("TriggerChatTransport", () => {
expect(renewSpy).toHaveBeenCalledWith({
chatId: "chat-first",
runId: "run_input_renew",
sessionId: DEFAULT_SESSION_ID,
});
expect(inputCalls).toBe(2);
});
+43 -8
View File
@@ -56,6 +56,15 @@ export type RenewRunAccessTokenParams = {
chatId: string;
/** The durable Trigger.dev run backing this chat session. */
runId: string;
/**
* The durable Session friendlyId backing this chat. Present whenever
* the transport has observed a session for the chat (i.e. after the
* first trigger). Servers should mint tokens with both `read:runs:{runId}`
* + `write:inputStreams:{runId}` AND `read:sessions:{sessionId}` +
* `write:sessions:{sessionId}` so the PAT covers the run's live input
* stream AND the Session's `.in` / `.out` channels.
*/
sessionId?: string;
};
/**
@@ -1158,7 +1167,16 @@ export class TriggerChatTransport implements ChatTransport<UIMessage> {
if (pending) return pending;
const doPreload = async () => {
const state = await this.ensureSession(chatId);
// Matches sendMessages: on the `triggerTask` callback path, the
// server action (e.g. `chat.createTriggerAction`) creates the
// Session with its secret key and returns `sessionId` alongside
// the run PAT — the browser never needs `write:sessions` itself.
// On the direct `accessToken` path, do the lazy upsert here so
// `payload.sessionId` is populated before the run starts.
let state: ChatSessionState | undefined = this.sessions.get(chatId);
if (!state?.sessionId && !this.triggerTaskFn) {
state = await this.ensureSession(chatId);
}
const mergedMetadata =
this.defaultMetadata || options?.metadata
@@ -1168,7 +1186,7 @@ export class TriggerChatTransport implements ChatTransport<UIMessage> {
const payload = {
messages: [] as never[],
chatId,
sessionId: state.sessionId,
...(state?.sessionId ? { sessionId: state.sessionId } : {}),
trigger: "preload" as const,
metadata: mergedMetadata,
...(options?.idleTimeoutInSeconds !== undefined
@@ -1176,12 +1194,28 @@ export class TriggerChatTransport implements ChatTransport<UIMessage> {
: {}),
};
const { runId, publicAccessToken } = await this.triggerNewRun(chatId, payload, "preload");
const result = await this.triggerNewRun(chatId, payload, "preload");
state.runId = runId;
state.publicAccessToken = publicAccessToken;
this.sessions.set(chatId, state);
this.notifySessionChange(chatId, state);
// The server action's result carries the `sessionId` it created
// for the triggerTask callback path; adopt it here.
const adoptedSessionId = result.sessionId ?? state?.sessionId;
if (!adoptedSessionId) {
// Neither path surfaced a sessionId — the server action is
// misconfigured. Keep the preload run but don't notify; the
// first sendMessage will recover via its own ensureSession.
return;
}
const nextState: ChatSessionState = {
sessionId: adoptedSessionId,
runId: result.runId,
publicAccessToken: result.publicAccessToken,
lastEventId: state?.lastEventId,
isStreaming: state?.isStreaming,
skipToTurnComplete: state?.skipToTurnComplete,
};
this.sessions.set(chatId, nextState);
this.notifySessionChange(chatId, nextState);
};
const promise = doPreload().finally(() => {
@@ -1279,7 +1313,8 @@ export class TriggerChatTransport implements ChatTransport<UIMessage> {
}
try {
const token = await renew({ chatId, runId });
const sessionId = this.sessions.get(chatId)?.sessionId;
const token = await renew({ chatId, runId, sessionId });
if (typeof token !== "string" || token.length === 0) {
return undefined;
}
+7 -946
View File
File diff suppressed because it is too large Load Diff
-1
View File
@@ -20,7 +20,6 @@
"@prisma/client": "^7.4.2",
"@e2b/code-interpreter": "^2.4.0",
"@trigger.dev/sdk": "workspace:*",
"secure-exec": "0.1.0",
"serialize-error": "^11.0.3",
"ai": "^6.0.0",
"next": "15.3.3",
+21 -7
View File
@@ -49,20 +49,34 @@ export const triggerChat = chat.createTriggerAction("ai-chat", {
});
/**
* Mint a fresh run-scoped PAT for an existing chat run (same scopes as the tasks turn token).
* Used by TriggerChatTransport when the stored PAT expires (401 on realtime / input stream).
* Persists `publicAccessToken` (and `runId`) on `ChatSession` for this `chatId`.
* Requires TRIGGER_SECRET_KEY (or configured secret) in the server environment.
* Mint a fresh PAT for an existing chat (same scopes as the task's turn
* token). Used by TriggerChatTransport when the stored PAT expires (401
* on realtime / input stream). Persists `publicAccessToken` (and
* `runId`) on `ChatSession` for this `chatId`. Requires
* TRIGGER_SECRET_KEY (or configured secret) in the server environment.
*
* Scopes match the initial `chat.createTriggerAction` mint so the PAT
* stays valid against both run-scoped endpoints (run PAT renewal, input
* streams) and session-scoped endpoints (`/realtime/v1/sessions/…/in` +
* `/realtime/v1/sessions/…/out`). Without the session scopes, the
* renewed token 401s on the session append path the transport uses.
*/
export async function renewRunAccessTokenForChat(
chatId: string,
runId: string
runId: string,
sessionId?: string
): Promise<string | undefined> {
try {
const token = await auth.createPublicToken({
scopes: {
read: { runs: runId },
write: { inputStreams: runId },
read: {
runs: runId,
...(sessionId ? { sessions: sessionId } : {}),
},
write: {
inputStreams: runId,
...(sessionId ? { sessions: sessionId } : {}),
},
},
expirationTime: CHAT_EXAMPLE_PAT_TTL,
});
@@ -8,7 +8,7 @@ import { Chat } from "@/components/chat";
import { ChatSidebar } from "@/components/chat-sidebar";
import { DEFAULT_MODEL } from "@/lib/models";
import {
getChatToken,
triggerChat,
getChatList,
getChatMessages,
deleteChat as deleteChatAction,
@@ -73,8 +73,14 @@ export function ChatApp({
const transport = useTriggerChatTransport({
task: taskMode,
accessToken: (params) => getChatToken({ ...params, taskId: taskMode }),
renewRunAccessToken: ({ chatId, runId }) => renewRunAccessTokenForChat(chatId, runId),
// Server-side trigger action creates the backing Session with the
// project's secret key + threads `sessionId` into the run payload.
// Returned PAT already has `read:runs` + `read:sessions` +
// `write:sessions` scopes (from `chat.createTriggerAction` Phase E),
// so the browser never needs to mint write:sessions itself.
triggerTask: triggerChat,
renewRunAccessToken: ({ chatId, runId, sessionId }) =>
renewRunAccessTokenForChat(chatId, runId, sessionId),
baseURL: process.env.NEXT_PUBLIC_TRIGGER_API_URL,
sessions: initialSessions,
onSessionChange: handleSessionChange,
@@ -6,7 +6,7 @@ import { Chat } from "@/components/chat";
import { useChatSettings } from "@/components/chat-settings-context";
import { DEFAULT_MODEL } from "@/lib/models";
import {
getChatToken,
triggerChat,
getChatList,
updateChatTitle,
deleteSessionAction,
@@ -57,8 +57,15 @@ export function ChatView({
const transport = useTriggerChatTransport({
task: taskMode,
accessToken: (params) => getChatToken({ ...params, taskId: taskMode }),
renewRunAccessToken: ({ chatId, runId }) => renewRunAccessTokenForChat(chatId, runId),
// Server-side trigger action creates the backing Session with the
// project's secret key + threads `sessionId` into the run payload.
// Returned PAT already has `read:runs` + `read:sessions` +
// `write:sessions` scopes (from `chat.createTriggerAction` Phase E),
// so the browser never needs `write:sessions` itself — and no CORS
// preflight hits `/api/v1/sessions` from the browser.
triggerTask: triggerChat,
renewRunAccessToken: ({ chatId, runId, sessionId }) =>
renewRunAccessTokenForChat(chatId, runId, sessionId),
baseURL: process.env.NEXT_PUBLIC_TRIGGER_API_URL,
sessions,
onSessionChange: handleSessionChange,
-26
View File
@@ -6,7 +6,6 @@ import { z } from "zod";
import os from "node:os";
import TurndownService from "turndown";
import { codeSandboxRun, runWithCodeSandbox } from "@/lib/code-sandbox";
import { runInSecureSandbox } from "@/lib/secure-sandbox";
const turndown = new TurndownService();
@@ -284,30 +283,6 @@ export const executeCode = tool({
},
});
export const executeJs = tool({
description:
"Run JavaScript code in an isolated V8 sandbox (secure-exec). " +
"Use for calculations, data transformations, or quick JS snippets. " +
"The code runs as a CommonJS module — assign results to module.exports. " +
"Example: module.exports = { sum: 1 + 2 };",
inputSchema: z.object({
code: z.string().describe("JavaScript code to execute. Assign results to module.exports."),
}),
execute: async ({ code }) => {
return runInSecureSandbox(async (runtime) => {
const result = await runtime.run<unknown>(code);
if (result.code !== 0) {
return {
error: result.errorMessage ?? `Exit code ${result.code}`,
};
}
return { result: result.exports };
});
},
});
export const sendEmail = tool({
description:
"Send an email to a recipient. Requires human approval before sending. " +
@@ -352,7 +327,6 @@ export const chatTools = {
deepResearch,
posthogQuery,
executeCode,
executeJs,
sendEmail,
askUser,
};
@@ -1,48 +0,0 @@
/**
* secure-exec V8 sandbox for PR review with Node filesystem + network access.
*
* Unlike the default secure-sandbox.ts (restricted fs/network), this sandbox
* grants full filesystem and network access so the agent can read cloned repo
* files and make HTTP requests from within sandboxed code.
*
* The sandbox receives a `cwd` (the cloned repo path) which is injected as
* a global `__cwd` constant so sandboxed code can reference the repo root.
*
* 256MB memory, 30s CPU time limit.
*/
import {
NodeRuntime,
NodeFileSystem,
createNodeDriver,
createNodeRuntimeDriverFactory,
allowAllFs,
allowAllNetwork,
} from "secure-exec";
export async function runInPRReviewSandbox<T>(
cwd: string,
runner: (runtime: NodeRuntime) => Promise<T>
): Promise<T | { error: string }> {
const runtime = new NodeRuntime({
systemDriver: createNodeDriver({
filesystem: new NodeFileSystem(),
permissions: {
...allowAllFs,
...allowAllNetwork,
},
}),
runtimeDriverFactory: createNodeRuntimeDriverFactory(),
memoryLimit: 256,
cpuTimeLimitMs: 30_000,
});
try {
// Inject the repo cwd as a global so sandboxed code can use it
await runtime.run(`globalThis.__cwd = ${JSON.stringify(cwd)};`);
return await runner(runtime);
} catch (err) {
return { error: err instanceof Error ? err.message : String(err) };
} finally {
runtime.dispose();
}
}
+1 -87
View File
@@ -6,7 +6,6 @@ import { z } from "zod";
import { resolve } from "node:path";
import { readFile as fsReadFile } from "node:fs/promises";
import { git, githubApi } from "@/lib/pr-review-helpers";
import { runInPRReviewSandbox } from "@/lib/pr-review-sandbox";
// #region Repo context — shared across tools, survives snapshot/restore
export const repo = chat.local<{
@@ -183,93 +182,8 @@ export const readFile = tool({
});
// #endregion
// #region Tool: Execute Code
export const executeCode = tool({
description:
"Run JavaScript code in an isolated V8 sandbox to verify claims about the code. " +
"Use this to PROVE claims (e.g., test a regex, validate parsing logic, check edge cases) " +
"before including them in your review. The sandbox has filesystem and network access. " +
"The repo is cloned at the provided cwd path — use it for absolute file paths. " +
"Assign results to module.exports.",
inputSchema: z.object({
code: z
.string()
.describe(
"JavaScript code to execute. Assign results to module.exports."
),
description: z
.string()
.describe("Brief description of what this code is testing/verifying"),
}),
execute: async ({ code, description }) => {
const { cwd } = repo;
const result = await runInPRReviewSandbox(cwd, async (runtime) => {
const execResult = await runtime.run<unknown>(code);
if (execResult.code !== 0) {
return {
description,
success: false as const,
error: execResult.errorMessage ?? `Exit code ${execResult.code}`,
};
}
return {
description,
success: true as const,
// Sanitize the sandbox's `module.exports` so the value matches the
// strict JSON shape that AI SDK's `jsonValueSchema` accepts. Raw JS
// can produce `Infinity`, `NaN`, `undefined`, `BigInt`, etc., none
// of which survive Zod v4's `z.number()` (which rejects non-finite
// numbers). The full message history is re-validated at the start
// of every subsequent `streamText` call, so an unsanitized value
// here would crash the agent on the *next* turn even though the
// current turn appears to succeed.
result: toJsonValue(execResult.exports),
};
});
// runInPRReviewSandbox returns { error } on catch
if (result && typeof result === "object" && "error" in result && !("success" in result)) {
return { description, success: false, error: result.error };
}
return result;
},
});
/**
* Coerce arbitrary JS to a value compatible with AI SDK's `jsonValueSchema`
* (`null | string | number | boolean | object | array`, where `number` must
* be finite).
*
* Uses `JSON.parse(JSON.stringify(...))` with a replacer so non-finite
* numbers become `null` (matching `JSON.stringify`'s default loss for
* `NaN`/`Infinity` when encountered as object values), `BigInt` is
* stringified, and `undefined` / functions are dropped same coercions
* `JSON.stringify` already applies, but called explicitly so the result
* is a plain JSON value tree the SDK can re-validate on later turns.
*/
function toJsonValue(value: unknown): unknown {
try {
return JSON.parse(
JSON.stringify(value, (_key, v) => {
if (typeof v === "number" && !Number.isFinite(v)) return null;
if (typeof v === "bigint") return v.toString();
return v;
})
);
} catch {
// Circular references or other JSON.stringify failures — fall back to a
// descriptive placeholder so the tool result is still valid JSON.
return { error: "Result was not JSON-serializable" };
}
}
// #endregion
// #region Exports
export const prReviewTools = { fetchPR, readFile, executeCode };
export const prReviewTools = { fetchPR, readFile };
type PRReviewToolSet = typeof prReviewTools;
export type PRReviewUiTools = InferUITools<PRReviewToolSet>;
@@ -1,39 +0,0 @@
/**
* secure-exec V8 sandbox runs JavaScript in-process via V8 isolates.
*
* No external API key needed. ~14ms cold start, ~3MB per isolate.
* A fresh runtime is created per execution no warm/dispose lifecycle needed.
*/
import {
NodeRuntime,
createNodeDriver,
createNodeRuntimeDriverFactory,
} from "secure-exec";
export async function runInSecureSandbox<T>(
runner: (runtime: NodeRuntime) => Promise<T>
): Promise<T | { error: string }> {
const runtime = new NodeRuntime({
systemDriver: createNodeDriver({
permissions: {
fs: (req) => ({
allow: req.path.startsWith("/root") || req.path.startsWith("/tmp"),
}),
network: (req) => ({
allow: req.hostname === "127.0.0.1" || req.hostname === "localhost",
}),
},
}),
runtimeDriverFactory: createNodeRuntimeDriverFactory(),
memoryLimit: 128,
cpuTimeLimitMs: 60_000,
});
try {
return await runner(runtime);
} catch (err) {
return { error: err instanceof Error ? err.message : String(err) };
} finally {
runtime.dispose();
}
}
-59
View File
@@ -1,9 +1,5 @@
import { defineConfig } from "@trigger.dev/sdk";
import { prismaExtension } from "@trigger.dev/build/extensions/prisma";
import { esbuildPlugin } from "@trigger.dev/build/extensions";
import { createRequire } from "node:module";
import fs from "node:fs";
import path from "node:path";
export default defineConfig({
project: process.env.TRIGGER_PROJECT_REF!,
@@ -19,61 +15,6 @@ export default defineConfig({
prismaExtension({
mode: "modern",
}),
// Trigger's ESM shim anchors require.resolve() to the chunk path, so
// node-stdlib-browser's runtime require.resolve("./mock/empty.js") breaks.
// Fix: load the real node-stdlib-browser at build time (where require.resolve
// works), capture the resolved path map, and inline it as a static export.
esbuildPlugin({
name: "node-stdlib-browser-stub",
setup(build) {
build.onResolve({ filter: /^node-stdlib-browser$/ }, () => ({
path: "node-stdlib-browser",
namespace: "nsb-resolved",
}));
build.onLoad({ filter: /.*/, namespace: "nsb-resolved" }, () => {
const buildRequire = createRequire(import.meta.url);
const resolved = buildRequire("node-stdlib-browser");
return {
contents: `export default ${JSON.stringify(resolved)};`,
loader: "js",
};
});
},
}),
// @secure-exec/node's bridge-loader.js runs require.resolve("@secure-exec/core")
// at module scope to locate dist/bridge.js on disk. This fails in Trigger's
// Docker container where the code is bundled into chunks and the package
// isn't on disk. Fix: inline bridge.js content at build time so no runtime
// filesystem access or package resolution is needed.
esbuildPlugin({
name: "inline-secure-exec-bridge",
setup(build) {
build.onLoad(
{ filter: /[\\/]@secure-exec[\\/]node[\\/]dist[\\/]bridge-loader\.js$/ },
(args) => {
const buildRequire = createRequire(args.path);
const coreEntry = buildRequire.resolve("@secure-exec/core");
const coreRoot = path.resolve(path.dirname(coreEntry), "..");
const bridgeCode = fs.readFileSync(path.join(coreRoot, "dist", "bridge.js"), "utf8");
return {
contents: [
`import { getIsolateRuntimeSource } from "@secure-exec/core";`,
`const bridgeCodeCache = ${JSON.stringify(bridgeCode)};`,
`export function getRawBridgeCode() { return bridgeCodeCache; }`,
`export function getBridgeAttachCode() { return getIsolateRuntimeSource("bridgeAttach"); }`,
].join("\n"),
loader: "js",
};
},
);
},
}),
],
external: [
// esbuild must not be bundled — it locates its native binary via a
// relative path from its JS API entry point. secure-exec uses esbuild
// at runtime to bundle polyfills for sandbox code.
"esbuild",
],
keepNames: false,
},