Skip to content

Commit 88c8f4d

Browse files
committed
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.
1 parent 6ee4380 commit 88c8f4d

13 files changed

Lines changed: 95 additions & 1220 deletions

File tree

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
---
2+
"@trigger.dev/sdk": patch
3+
---
4+
5+
`TriggerChatTransport` fixes for session-scoped auth and end-to-end UI smoke parity:
6+
7+
- `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.
8+
- `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.

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

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -808,6 +808,7 @@ describe("TriggerChatTransport", () => {
808808
expect(renewSpy).toHaveBeenCalledWith({
809809
chatId: "chat-renew-sse",
810810
runId: "run_renew_sse",
811+
sessionId: DEFAULT_SESSION_ID,
811812
});
812813

813814
const patStreamCall = (global.fetch as ReturnType<typeof vi.fn>).mock.calls.find(
@@ -893,6 +894,7 @@ describe("TriggerChatTransport", () => {
893894
expect(renewSpy).toHaveBeenCalledWith({
894895
chatId: "chat-fail-renew",
895896
runId: "run_fail_renew",
897+
sessionId: DEFAULT_SESSION_ID,
896898
});
897899
});
898900

@@ -975,6 +977,7 @@ describe("TriggerChatTransport", () => {
975977
expect(renewSpy).toHaveBeenCalledWith({
976978
chatId: "chat-first",
977979
runId: "run_input_renew",
980+
sessionId: DEFAULT_SESSION_ID,
978981
});
979982
expect(inputCalls).toBe(2);
980983
});

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

Lines changed: 43 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,15 @@ export type RenewRunAccessTokenParams = {
5656
chatId: string;
5757
/** The durable Trigger.dev run backing this chat session. */
5858
runId: string;
59+
/**
60+
* The durable Session friendlyId backing this chat. Present whenever
61+
* the transport has observed a session for the chat (i.e. after the
62+
* first trigger). Servers should mint tokens with both `read:runs:{runId}`
63+
* + `write:inputStreams:{runId}` AND `read:sessions:{sessionId}` +
64+
* `write:sessions:{sessionId}` so the PAT covers the run's live input
65+
* stream AND the Session's `.in` / `.out` channels.
66+
*/
67+
sessionId?: string;
5968
};
6069

6170
/**
@@ -1158,7 +1167,16 @@ export class TriggerChatTransport implements ChatTransport<UIMessage> {
11581167
if (pending) return pending;
11591168

11601169
const doPreload = async () => {
1161-
const state = await this.ensureSession(chatId);
1170+
// Matches sendMessages: on the `triggerTask` callback path, the
1171+
// server action (e.g. `chat.createTriggerAction`) creates the
1172+
// Session with its secret key and returns `sessionId` alongside
1173+
// the run PAT — the browser never needs `write:sessions` itself.
1174+
// On the direct `accessToken` path, do the lazy upsert here so
1175+
// `payload.sessionId` is populated before the run starts.
1176+
let state: ChatSessionState | undefined = this.sessions.get(chatId);
1177+
if (!state?.sessionId && !this.triggerTaskFn) {
1178+
state = await this.ensureSession(chatId);
1179+
}
11621180

11631181
const mergedMetadata =
11641182
this.defaultMetadata || options?.metadata
@@ -1168,20 +1186,36 @@ export class TriggerChatTransport implements ChatTransport<UIMessage> {
11681186
const payload = {
11691187
messages: [] as never[],
11701188
chatId,
1171-
sessionId: state.sessionId,
1189+
...(state?.sessionId ? { sessionId: state.sessionId } : {}),
11721190
trigger: "preload" as const,
11731191
metadata: mergedMetadata,
11741192
...(options?.idleTimeoutInSeconds !== undefined
11751193
? { idleTimeoutInSeconds: options.idleTimeoutInSeconds }
11761194
: {}),
11771195
};
11781196

1179-
const { runId, publicAccessToken } = await this.triggerNewRun(chatId, payload, "preload");
1197+
const result = await this.triggerNewRun(chatId, payload, "preload");
1198+
1199+
// The server action's result carries the `sessionId` it created
1200+
// for the triggerTask callback path; adopt it here.
1201+
const adoptedSessionId = result.sessionId ?? state?.sessionId;
1202+
if (!adoptedSessionId) {
1203+
// Neither path surfaced a sessionId — the server action is
1204+
// misconfigured. Keep the preload run but don't notify; the
1205+
// first sendMessage will recover via its own ensureSession.
1206+
return;
1207+
}
11801208

1181-
state.runId = runId;
1182-
state.publicAccessToken = publicAccessToken;
1183-
this.sessions.set(chatId, state);
1184-
this.notifySessionChange(chatId, state);
1209+
const nextState: ChatSessionState = {
1210+
sessionId: adoptedSessionId,
1211+
runId: result.runId,
1212+
publicAccessToken: result.publicAccessToken,
1213+
lastEventId: state?.lastEventId,
1214+
isStreaming: state?.isStreaming,
1215+
skipToTurnComplete: state?.skipToTurnComplete,
1216+
};
1217+
this.sessions.set(chatId, nextState);
1218+
this.notifySessionChange(chatId, nextState);
11851219
};
11861220

11871221
const promise = doPreload().finally(() => {
@@ -1279,7 +1313,8 @@ export class TriggerChatTransport implements ChatTransport<UIMessage> {
12791313
}
12801314

12811315
try {
1282-
const token = await renew({ chatId, runId });
1316+
const sessionId = this.sessions.get(chatId)?.sessionId;
1317+
const token = await renew({ chatId, runId, sessionId });
12831318
if (typeof token !== "string" || token.length === 0) {
12841319
return undefined;
12851320
}

0 commit comments

Comments
 (0)