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:
@@ -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` | 1h–24h |
|
||||
| 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)
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Generated
+7
-946
File diff suppressed because it is too large
Load Diff
@@ -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",
|
||||
|
||||
@@ -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 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.
|
||||
* 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,
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user