From 946c900061b3f9891e709cede2e14b71c2754b3b Mon Sep 17 00:00:00 2001 From: Eric Allam Date: Fri, 28 Aug 2026 13:57:52 +0100 Subject: [PATCH 01/25] feat(core,webapp): address session realtime streams by named channel Generalize a Session's reserved .in/.out pair into named side channels. Core: SessionStreamManager/facade/interface key on an optional channel (reserved = undefined), defineSessionChannel + type extractors, and a channel arg on subscribeToSessionStream. Webapp: S2 stream names gain a channels/{name}/ segment (default pair unchanged for back-compat), plus server-side list/reconfigure control-plane ops, and three channel routes (subscribe/append/records) with a no-wake append path and channel-folded auth. --- ...s.$session.channels.$channel.$io.append.ts | 136 ++++++++++++++++ ....$session.channels.$channel.$io.records.ts | 81 ++++++++++ ...sessions.$session.channels.$channel.$io.ts | 153 ++++++++++++++++++ .../realtime/s2realtimeStreams.server.ts | 134 +++++++++++++-- .../realtime/sessionChannels.server.ts | 30 ++++ packages/core/src/v3/apiClient/index.ts | 10 +- packages/core/src/v3/session-streams-api.ts | 1 + .../core/src/v3/sessionStreams/channels.ts | 50 ++++++ packages/core/src/v3/sessionStreams/index.ts | 93 +++++++---- .../core/src/v3/sessionStreams/manager.ts | 129 +++++++++------ packages/core/src/v3/sessionStreams/types.ts | 55 +++++-- 11 files changed, 765 insertions(+), 107 deletions(-) create mode 100644 apps/webapp/app/routes/realtime.v1.sessions.$session.channels.$channel.$io.append.ts create mode 100644 apps/webapp/app/routes/realtime.v1.sessions.$session.channels.$channel.$io.records.ts create mode 100644 apps/webapp/app/routes/realtime.v1.sessions.$session.channels.$channel.$io.ts create mode 100644 apps/webapp/app/services/realtime/sessionChannels.server.ts create mode 100644 packages/core/src/v3/sessionStreams/channels.ts diff --git a/apps/webapp/app/routes/realtime.v1.sessions.$session.channels.$channel.$io.append.ts b/apps/webapp/app/routes/realtime.v1.sessions.$session.channels.$channel.$io.append.ts new file mode 100644 index 00000000000..48281efb047 --- /dev/null +++ b/apps/webapp/app/routes/realtime.v1.sessions.$session.channels.$channel.$io.append.ts @@ -0,0 +1,136 @@ +import { json } from "@remix-run/server-runtime"; +import { tryCatch } from "@trigger.dev/core/utils"; +import { nanoid } from "nanoid"; +import { z } from "zod"; +import { logger } from "~/services/logger.server"; +import { S2RealtimeStreams } from "~/services/realtime/s2realtimeStreams.server"; +import { + SESSION_CHANNEL_NAME_REGEX, + sessionChannelResources, +} from "~/services/realtime/sessionChannels.server"; +import { + canonicalSessionAddressingKey, + resolveSessionWithWriterFallback, +} from "~/services/realtime/sessions.server"; +import { getRealtimeStreamInstance } from "~/services/realtime/v1StreamsGlobal.server"; +import { + claimSessionStreamPart, + releaseSessionStreamPart, +} from "~/services/sessionStreamWaitpointCache.server"; +import { anyResource, createActionApiRoute } from "~/services/routeBuilders/apiBuilder.server"; +import { ServiceValidationError } from "~/v3/services/common.server"; + +const ParamsSchema = z.object({ + session: z.string(), + channel: z.string().regex(SESSION_CHANNEL_NAME_REGEX), + io: z.enum(["out", "in"]), +}); + +const MAX_APPEND_BODY_BYTES = 1024 * 1024; + +const { action, loader } = createActionApiRoute( + { + params: ParamsSchema, + method: "POST", + maxContentLength: MAX_APPEND_BODY_BYTES, + allowJWT: true, + corsStrategy: "all", + findResource: async (params, auth) => + resolveSessionWithWriterFallback(auth.environment.id, params.session), + authorization: { + action: "write", + resource: (params, _s, _h, _b, session) => { + const ids = new Set([params.session]); + if (session) { + ids.add(session.friendlyId); + if (session.externalId) ids.add(session.externalId); + } + return anyResource(sessionChannelResources(params.channel, ids)); + }, + }, + }, + async ({ request, params, authentication, resource: session }) => { + if (!session) { + return new Response("Session not found", { status: 404 }); + } + + if (session.closedAt) { + return json({ ok: false, error: "Cannot append to a closed session" }, { status: 400 }); + } + + if (session.expiresAt && session.expiresAt.getTime() < Date.now()) { + return json({ ok: false, error: "Cannot append to an expired session" }, { status: 400 }); + } + + if (params.io === "out" && authentication.type !== "PRIVATE") { + return json( + { ok: false, error: "Appending to the out channel requires secret key authentication" }, + { status: 403 } + ); + } + + const realtimeStream = getRealtimeStreamInstance(authentication.environment, "v2", { + session, + }); + + if (!(realtimeStream instanceof S2RealtimeStreams)) { + return json( + { ok: false, error: "Session channels require the S2 realtime backend" }, + { status: 501 } + ); + } + + const addressingKey = canonicalSessionAddressingKey(session, params.session); + const claimKey = `${addressingKey}:channels:${params.channel}`; + + const part = await request.text(); + + const clientPartId = request.headers.get("X-Part-Id"); + const partId = clientPartId ?? nanoid(7); + + const wonClaim = clientPartId + ? await claimSessionStreamPart( + authentication.environment.id, + claimKey, + params.io, + clientPartId + ) + : true; + + let appendSeq: number | undefined; + if (wonClaim) { + const [appendError, seq] = await tryCatch( + realtimeStream.appendPartToSessionStream(part, partId, addressingKey, params.io, params.channel) + ); + appendSeq = seq ?? undefined; + + if (appendError) { + if (clientPartId) { + await releaseSessionStreamPart( + authentication.environment.id, + claimKey, + params.io, + clientPartId + ); + } + if (appendError instanceof ServiceValidationError) { + return json( + { ok: false, error: appendError.message }, + { status: appendError.status ?? 422 } + ); + } + logger.error("Failed to append to session channel stream", { + sessionId: session.id, + io: params.io, + channel: params.channel, + error: appendError, + }); + return json({ ok: false, error: "Something went wrong, please try again." }, { status: 500 }); + } + } + + return json({ ok: true, seq: appendSeq }, { status: 200 }); + } +); + +export { action, loader }; diff --git a/apps/webapp/app/routes/realtime.v1.sessions.$session.channels.$channel.$io.records.ts b/apps/webapp/app/routes/realtime.v1.sessions.$session.channels.$channel.$io.records.ts new file mode 100644 index 00000000000..15bbde33b15 --- /dev/null +++ b/apps/webapp/app/routes/realtime.v1.sessions.$session.channels.$channel.$io.records.ts @@ -0,0 +1,81 @@ +import { json } from "@remix-run/server-runtime"; +import { z } from "zod"; +import { $replica } from "~/db.server"; +import { S2RealtimeStreams } from "~/services/realtime/s2realtimeStreams.server"; +import { + SESSION_CHANNEL_NAME_REGEX, + sessionChannelResources, +} from "~/services/realtime/sessionChannels.server"; +import { + canonicalSessionAddressingKey, + isSessionFriendlyIdForm, + resolveSessionByIdOrExternalId, +} from "~/services/realtime/sessions.server"; +import { getRealtimeStreamInstance } from "~/services/realtime/v1StreamsGlobal.server"; +import { anyResource, createLoaderApiRoute } from "~/services/routeBuilders/apiBuilder.server"; + +const ParamsSchema = z.object({ + session: z.string(), + channel: z.string().regex(SESSION_CHANNEL_NAME_REGEX), + io: z.enum(["out", "in"]), +}); + +const SearchSchema = z.object({ + afterEventId: z.string().regex(/^\d+$/).optional(), +}); + +export const loader = createLoaderApiRoute( + { + params: ParamsSchema, + searchParams: SearchSchema, + allowJWT: true, + corsStrategy: "all", + findResource: async (params, auth) => { + const row = await resolveSessionByIdOrExternalId( + $replica, + auth.environment.id, + params.session + ); + if (!row && isSessionFriendlyIdForm(params.session)) { + return undefined; + } + return { + row, + addressingKey: canonicalSessionAddressingKey(row, params.session), + }; + }, + authorization: { + action: "read", + resource: ({ row, addressingKey }, params) => { + const ids = new Set([addressingKey]); + if (row) { + ids.add(row.friendlyId); + if (row.externalId) ids.add(row.externalId); + } + return anyResource(sessionChannelResources(params.channel, ids)); + }, + }, + }, + async ({ params, authentication, resource, searchParams }) => { + const realtimeStream = getRealtimeStreamInstance(authentication.environment, "v2", { + session: resource.row, + organization: resource.row ? null : authentication.environment.organization, + }); + + if (!(realtimeStream instanceof S2RealtimeStreams)) { + return new Response("Session channels require the S2 realtime backend", { status: 501 }); + } + + const afterSeqNum = + searchParams.afterEventId !== undefined ? Number(searchParams.afterEventId) : undefined; + + const records = await realtimeStream.readSessionStreamRecords( + resource.addressingKey, + params.io, + afterSeqNum, + params.channel + ); + + return json({ records }); + } +); diff --git a/apps/webapp/app/routes/realtime.v1.sessions.$session.channels.$channel.$io.ts b/apps/webapp/app/routes/realtime.v1.sessions.$session.channels.$channel.$io.ts new file mode 100644 index 00000000000..36aa6feb16b --- /dev/null +++ b/apps/webapp/app/routes/realtime.v1.sessions.$session.channels.$channel.$io.ts @@ -0,0 +1,153 @@ +import { json } from "@remix-run/server-runtime"; +import { STREAM_START_HEADER } from "@trigger.dev/core/v3"; +import { z } from "zod"; +import { getRequestAbortSignal } from "~/services/httpAsyncStorage.server"; +import { S2RealtimeStreams } from "~/services/realtime/s2realtimeStreams.server"; +import { + SESSION_CHANNEL_NAME_REGEX, + sessionChannelResources, +} from "~/services/realtime/sessionChannels.server"; +import { + canonicalSessionAddressingKey, + isSessionFriendlyIdForm, + resolveSessionWithWriterFallback, +} from "~/services/realtime/sessions.server"; +import { getRealtimeStreamInstance } from "~/services/realtime/v1StreamsGlobal.server"; +import { + anyResource, + createActionApiRoute, + createLoaderApiRoute, +} from "~/services/routeBuilders/apiBuilder.server"; + +const ParamsSchema = z.object({ + session: z.string(), + channel: z.string().regex(SESSION_CHANNEL_NAME_REGEX), + io: z.enum(["out", "in"]), +}); + +const { action } = createActionApiRoute( + { + params: ParamsSchema, + method: "PUT", + allowJWT: true, + corsStrategy: "all", + authorization: { + action: "write", + resource: (params) => anyResource(sessionChannelResources(params.channel, [params.session])), + }, + }, + async ({ params, authentication }) => { + if (params.io === "out" && authentication.type !== "PRIVATE") { + return new Response("Initializing the out channel requires secret key authentication", { + status: 403, + }); + } + + const maybeSession = await resolveSessionWithWriterFallback( + authentication.environment.id, + params.session + ); + + if (!maybeSession && isSessionFriendlyIdForm(params.session)) { + return new Response("Session not found", { status: 404 }); + } + + if (maybeSession?.closedAt) { + return new Response("Cannot initialize a channel on a closed session", { status: 400 }); + } + + const realtimeStream = getRealtimeStreamInstance(authentication.environment, "v2", { + session: maybeSession, + organization: maybeSession ? null : authentication.environment.organization, + }); + + if (!(realtimeStream instanceof S2RealtimeStreams)) { + return new Response("Session channels require the S2 realtime backend", { status: 501 }); + } + + const addressingKey = canonicalSessionAddressingKey(maybeSession, params.session); + + const { responseHeaders } = await realtimeStream.initializeSessionStream( + addressingKey, + params.io, + params.channel + ); + + return json({ version: "v2" }, { status: 202, headers: responseHeaders }); + } +); + +const loader = createLoaderApiRoute( + { + params: ParamsSchema, + allowJWT: true, + corsStrategy: "all", + findResource: async (params, auth) => { + const row = await resolveSessionWithWriterFallback(auth.environment.id, params.session); + if (!row && isSessionFriendlyIdForm(params.session)) { + return undefined; + } + return { + row, + addressingKey: canonicalSessionAddressingKey(row, params.session), + }; + }, + authorization: { + action: "read", + resource: ({ row, addressingKey }, params) => { + const ids = new Set([addressingKey]); + if (row) { + ids.add(row.friendlyId); + if (row.externalId) ids.add(row.externalId); + } + return anyResource(sessionChannelResources(params.channel, ids)); + }, + }, + }, + async ({ params, request, authentication, resource }) => { + const realtimeStream = getRealtimeStreamInstance(authentication.environment, "v2", { + session: resource.row, + organization: resource.row ? null : authentication.environment.organization, + }); + + if (!(realtimeStream instanceof S2RealtimeStreams)) { + return new Response("Session channels require the S2 realtime backend", { status: 501 }); + } + + if (request.method === "HEAD") { + return new Response(null, { status: 200, headers: { "X-Last-Chunk-Index": "0" } }); + } + + const lastEventId = request.headers.get("Last-Event-ID") ?? undefined; + + const timeoutInSecondsRaw = request.headers.get("Timeout-Seconds"); + let timeoutInSeconds: number | undefined; + if (timeoutInSecondsRaw) { + const parsed = Number(timeoutInSecondsRaw); + if (!Number.isFinite(parsed) || !Number.isInteger(parsed)) { + return new Response("Invalid timeout seconds", { status: 400 }); + } + if (parsed < 1) { + return new Response("Timeout seconds must be greater than 0", { status: 400 }); + } + if (parsed > 600) { + return new Response("Timeout seconds must be less than 600", { status: 400 }); + } + timeoutInSeconds = parsed; + } + + const startFrom = + request.headers.get(STREAM_START_HEADER)?.toLowerCase() === "latest" ? "latest" : undefined; + + return realtimeStream.streamResponseFromSessionStream( + request, + resource.addressingKey, + params.io, + getRequestAbortSignal(), + { lastEventId, timeoutInSeconds, startFrom }, + params.channel + ); + } +); + +export { action, loader }; diff --git a/apps/webapp/app/services/realtime/s2realtimeStreams.server.ts b/apps/webapp/app/services/realtime/s2realtimeStreams.server.ts index 174040b2053..2d32d98e707 100644 --- a/apps/webapp/app/services/realtime/s2realtimeStreams.server.ts +++ b/apps/webapp/app/services/realtime/s2realtimeStreams.server.ts @@ -150,8 +150,14 @@ export class S2RealtimeStreams implements StreamResponder, StreamIngestor { * the session's `friendlyId` and the I/O direction. Used by the session * realtime routes to route traffic to `sessions/{friendlyId}/{out|in}`. */ - public toSessionStreamName(friendlyId: string, io: "out" | "in"): string { - return `${this.streamPrefix}/sessions/${friendlyId}/${io}`; + public toSessionStreamName(friendlyId: string, io: "out" | "in", channel?: string): string { + return `${this.streamPrefix}${this.#sessionStreamRelativeName(friendlyId, io, channel)}`; + } + + #sessionStreamRelativeName(friendlyId: string, io: "out" | "in", channel?: string): string { + return channel + ? `/sessions/${friendlyId}/channels/${channel}/${io}` + : `/sessions/${friendlyId}/${io}`; } async initializeStream( @@ -170,11 +176,12 @@ export class S2RealtimeStreams implements StreamResponder, StreamIngestor { */ async initializeSessionStream( friendlyId: string, - io: "out" | "in" + io: "out" | "in", + channel?: string ): Promise<{ responseHeaders?: Record }> { return this.#initializeStreamByName( - this.toSessionStreamName(friendlyId, io), - `/sessions/${friendlyId}/${io}` + this.toSessionStreamName(friendlyId, io, channel), + this.#sessionStreamRelativeName(friendlyId, io, channel) ); } @@ -217,9 +224,10 @@ export class S2RealtimeStreams implements StreamResponder, StreamIngestor { part: string, partId: string, friendlyId: string, - io: "out" | "in" + io: "out" | "in", + channel?: string ): Promise { - return this.#appendPartByName(part, partId, this.toSessionStreamName(friendlyId, io)); + return this.#appendPartByName(part, partId, this.toSessionStreamName(friendlyId, io, channel)); } async #appendPartByName(part: string, partId: string, s2Stream: string): Promise { @@ -259,9 +267,44 @@ export class S2RealtimeStreams implements StreamResponder, StreamIngestor { async readSessionStreamRecords( friendlyId: string, io: "out" | "in", - afterSeqNum?: number + afterSeqNum?: number, + channel?: string ): Promise { - return this.#readRecordsByName(this.toSessionStreamName(friendlyId, io), afterSeqNum); + return this.#readRecordsByName(this.toSessionStreamName(friendlyId, io, channel), afterSeqNum); + } + + /** + * List the named side channels of a session by enumerating S2 streams under + * the session's `channels/` prefix. The reserved `.in`/`.out` pair lives at + * the two-part `sessions/{id}/{io}` name (not under `channels/`) so it is + * excluded. Server-side control-plane op using the webapp's own S2 token; + * kept off the hot path. Returns distinct channel names. + */ + async listSessionChannels(friendlyId: string): Promise { + const prefix = `${this.streamPrefix}/sessions/${friendlyId}/channels/`; + const names = await this.#s2ListStreamNames(prefix); + const channels = new Set(); + for (const name of names) { + const rest = name.slice(prefix.length); + const channel = rest.split("/")[0]; + if (channel) channels.add(channel); + } + return [...channels]; + } + + /** + * Apply a per-stream native retention override to one direction of a named + * side channel. The durable bound that keeps a run-independent channel from + * growing without a turn loop trimming it. Server-side, webapp S2 token, + * called once per channel lifetime (cached by the caller). + */ + async reconfigureSessionChannelRetention( + friendlyId: string, + io: "out" | "in", + channel: string, + retention: { maxAgeSeconds?: number; deleteOnEmptyMinAgeSeconds?: number } + ): Promise { + await this.#s2ReconfigureStream(this.toSessionStreamName(friendlyId, io, channel), retention); } async #readRecordsByName(s2Stream: string, afterSeqNum?: number): Promise { @@ -402,9 +445,10 @@ export class S2RealtimeStreams implements StreamResponder, StreamIngestor { friendlyId: string, io: "out" | "in", signal: AbortSignal, - options?: StreamResponseOptions + options?: StreamResponseOptions, + channel?: string ): Promise { - const s2Stream = this.toSessionStreamName(friendlyId, io); + const s2Stream = this.toSessionStreamName(friendlyId, io, channel); let waitSeconds = options?.timeoutInSeconds ?? this.s2WaitSeconds; let settled = false; @@ -719,6 +763,74 @@ export class S2RealtimeStreams implements StreamResponder, StreamIngestor { }); } + async #s2ListStreamNames(prefix: string): Promise { + const names: string[] = []; + let startAfter: string | undefined; + + for (let page = 0; page < 100; page++) { + const qs = new URLSearchParams(); + qs.set("prefix", prefix); + if (startAfter) qs.set("start_after", startAfter); + + const res = await fetch(`${this.baseUrl}/streams?${qs}`, { + method: "GET", + headers: { + Authorization: `Bearer ${this.token}`, + Accept: "application/json", + "S2-Basin": this.basin, + }, + }); + + if (!res.ok) { + if (res.status === 404) return names; + const text = await res.text().catch(() => ""); + throw new Error(`S2 listStreams failed: ${res.status} ${res.statusText} ${text}`); + } + + const body = (await res.json()) as { + has_more?: boolean; + streams?: Array<{ name: string; deleted_at?: string | null }>; + }; + const streams = body.streams ?? []; + for (const stream of streams) { + if (stream.deleted_at) continue; + names.push(stream.name); + } + if (!body.has_more || streams.length === 0) break; + startAfter = streams[streams.length - 1]!.name; + } + + return names; + } + + async #s2ReconfigureStream( + stream: string, + retention: { maxAgeSeconds?: number; deleteOnEmptyMinAgeSeconds?: number } + ): Promise { + const config: Record = {}; + if (retention.maxAgeSeconds != null) { + config.retention_policy = { age: retention.maxAgeSeconds }; + } + if (retention.deleteOnEmptyMinAgeSeconds != null) { + config.delete_on_empty = { min_age_secs: retention.deleteOnEmptyMinAgeSeconds }; + } + if (Object.keys(config).length === 0) return; + + const res = await fetch(`${this.baseUrl}/streams/${encodeURIComponent(stream)}`, { + method: "PATCH", + headers: { + Authorization: `Bearer ${this.token}`, + "Content-Type": "application/json", + "S2-Basin": this.basin, + }, + body: JSON.stringify(config), + }); + + if (res.ok) return; + const text = await res.text().catch(() => ""); + throw new Error(`S2 reconfigureStream failed: ${res.status} ${res.statusText} ${text}`); + } + private parseLastEventId(lastEventId?: string): number | undefined { if (!lastEventId) return undefined; // tolerate formats like "1699999999999-5" (take leading digits) diff --git a/apps/webapp/app/services/realtime/sessionChannels.server.ts b/apps/webapp/app/services/realtime/sessionChannels.server.ts new file mode 100644 index 00000000000..fbd576271d8 --- /dev/null +++ b/apps/webapp/app/services/realtime/sessionChannels.server.ts @@ -0,0 +1,30 @@ +import type { RbacResource } from "@trigger.dev/rbac"; + +/** + * Channel names are both a URL path segment and an S2 stream-name segment, and + * they fold into the RBAC resource id as `${key}:channels:${channel}` — so a + * `/` would break addressing and a `:` would break scope parsing. Constrain to + * a safe, bounded alphabet. + */ +export const SESSION_CHANNEL_NAME_REGEX = /^[A-Za-z0-9._-]{1,128}$/; + +export function isValidSessionChannelName(channel: string): boolean { + return SESSION_CHANNEL_NAME_REGEX.test(channel); +} + +/** + * Build the authorization resource set for a named channel. For each candidate + * session key (URL form, friendlyId, externalId) we authorize BOTH the + * channel-folded id (`${key}:channels:${channel}`, matched by a narrow + * channel-scoped token) and the bare session id (`${key}`, matched by a + * session-wide token so it grants every channel). RBAC matches ids exactly, so + * a channel token cannot match the bare session and vice versa. + */ +export function sessionChannelResources(channel: string, keys: Iterable): RbacResource[] { + const resources: RbacResource[] = []; + for (const key of keys) { + resources.push({ type: "sessions", id: `${key}:channels:${channel}` }); + resources.push({ type: "sessions", id: key }); + } + return resources; +} diff --git a/packages/core/src/v3/apiClient/index.ts b/packages/core/src/v3/apiClient/index.ts index c270f86ea62..9d9ea16229a 100644 --- a/packages/core/src/v3/apiClient/index.ts +++ b/packages/core/src/v3/apiClient/index.ts @@ -1477,6 +1477,11 @@ export class ApiClient { options?: { signal?: AbortSignal; baseUrl?: string; + /** + * A named side channel on the session. When omitted, the session's + * reserved default channel (`session.in` / `session.out`) is used. + */ + channel?: string; timeoutInSeconds?: number; onComplete?: () => void; onError?: (error: Error) => void; @@ -1496,7 +1501,10 @@ export class ApiClient { onControl?: (event: ControlEvent) => void; } ): Promise> { - const url = `${options?.baseUrl ?? this.baseUrl}/realtime/v1/sessions/${encodeURIComponent(sessionIdOrExternalId)}/${io}`; + const sessionSegment = `${options?.baseUrl ?? this.baseUrl}/realtime/v1/sessions/${encodeURIComponent(sessionIdOrExternalId)}`; + const url = options?.channel + ? `${sessionSegment}/channels/${encodeURIComponent(options.channel)}/${io}` + : `${sessionSegment}/${io}`; const subscription = new SSEStreamSubscription(url, { headers: this.getHeaders(), diff --git a/packages/core/src/v3/session-streams-api.ts b/packages/core/src/v3/session-streams-api.ts index 638a8674213..8ea970b7070 100644 --- a/packages/core/src/v3/session-streams-api.ts +++ b/packages/core/src/v3/session-streams-api.ts @@ -5,6 +5,7 @@ import { SessionStreamsAPI } from "./sessionStreams/index.js"; export const sessionStreams = SessionStreamsAPI.getInstance(); export * from "./sessionStreams/types.js"; +export * from "./sessionStreams/channels.js"; export * from "./sessionStreams/wireProtocol.js"; export * from "./sessionStreams/chatSnapshot.js"; export * from "./sessionStreams/router.js"; diff --git a/packages/core/src/v3/sessionStreams/channels.ts b/packages/core/src/v3/sessionStreams/channels.ts new file mode 100644 index 00000000000..3707c95cbe7 --- /dev/null +++ b/packages/core/src/v3/sessionStreams/channels.ts @@ -0,0 +1,50 @@ +export type SessionChannelShape = { in?: unknown; out?: unknown }; + +/** + * A typed declaration of a named Session channel. The channel analogue of + * `Task`: `TName` captures the channel's literal name and + * `TShape` its per-direction record types. `__shape` is a phantom carrier + * for `TShape` and is never read at runtime. + */ +export type SessionChannel< + TName extends string = string, + TShape extends SessionChannelShape = SessionChannelShape, +> = { + readonly name: TName; + readonly __shape?: TShape; +}; + +export type AnySessionChannel = SessionChannel; + +/** Extract a channel's literal name, the analogue of `TaskIdentifier`. */ +export type SessionChannelName = C extends SessionChannel + ? N + : never; + +/** Extract the `.out` record type, the analogue of `TaskOutput`. */ +export type SessionChannelOut = C extends SessionChannel + ? S extends { out: infer O } + ? O + : unknown + : never; + +/** Extract the `.in` record type, the analogue of `TaskPayload`. */ +export type SessionChannelIn = C extends SessionChannel + ? S extends { in: infer I } + ? I + : unknown + : never; + +/** + * Declare a named Session channel with typed `.in` / `.out` records, inferred + * on both the producer (`chat.agent`) and consumer (client hook) sides. + * Mirrors the `Task` + `defineSessionChannel` ergonomics: the + * `const TName` capture preserves the channel name as a string literal so it + * flows through `SessionChannelName`, exactly like a task id. + */ +export function defineSessionChannel< + TShape extends SessionChannelShape = SessionChannelShape, + const TName extends string = string, +>(name: TName): SessionChannel { + return { name }; +} diff --git a/packages/core/src/v3/sessionStreams/index.ts b/packages/core/src/v3/sessionStreams/index.ts index a1b6f840cf9..9f046e96089 100644 --- a/packages/core/src/v3/sessionStreams/index.ts +++ b/packages/core/src/v3/sessionStreams/index.ts @@ -36,110 +36,139 @@ export class SessionStreamsAPI implements SessionStreamManager { public on( sessionId: string, io: SessionChannelIO, - handler: (data: unknown) => void | boolean | Promise + handler: (data: unknown) => void | boolean | Promise, + channel?: string ): { off: () => void } { - return this.#getManager().on(sessionId, io, handler); + return this.#getManager().on(sessionId, io, handler, channel); } public onRecord( sessionId: string, io: SessionChannelIO, - handler: (record: SessionStreamRecord) => void | boolean | Promise + handler: (record: SessionStreamRecord) => void | boolean | Promise, + channel?: string ): { off: () => void } { const manager = this.#getManager(); if (!manager.onRecord) { throw new Error("The configured Session stream manager does not support record handlers"); } - return manager.onRecord(sessionId, io, handler); + return manager.onRecord(sessionId, io, handler, channel); } public once( sessionId: string, io: SessionChannelIO, - options?: InputStreamOnceOptions + options?: InputStreamOnceOptions, + channel?: string ): InputStreamOncePromise { - return this.#getManager().once(sessionId, io, options); + return this.#getManager().once(sessionId, io, options, channel); } public onceRecord( sessionId: string, io: SessionChannelIO, - options?: InputStreamOnceOptions + options?: InputStreamOnceOptions, + channel?: string ): InputStreamOncePromise { const manager = this.#getManager(); if (!manager.onceRecord) { throw new Error("The configured Session stream manager does not support record metadata"); } - return manager.onceRecord(sessionId, io, options); + return manager.onceRecord(sessionId, io, options, channel); } public onceRecordWhere( sessionId: string, io: SessionChannelIO, predicate: SessionStreamRecordPredicate, - options?: InputStreamOnceOptions + options?: InputStreamOnceOptions, + channel?: string ): InputStreamOncePromise { const manager = this.#getManager(); if (!manager.onceRecordWhere) { throw new Error("The configured Session stream manager does not support selective records"); } - return manager.onceRecordWhere(sessionId, io, predicate, options); + return manager.onceRecordWhere(sessionId, io, predicate, options, channel); } - public peek(sessionId: string, io: SessionChannelIO): unknown | undefined { - return this.#getManager().peek(sessionId, io); + public peek(sessionId: string, io: SessionChannelIO, channel?: string): unknown | undefined { + return this.#getManager().peek(sessionId, io, channel); } - public peekRecord(sessionId: string, io: SessionChannelIO): SessionStreamRecord | undefined { + public peekRecord( + sessionId: string, + io: SessionChannelIO, + channel?: string + ): SessionStreamRecord | undefined { const manager = this.#getManager(); if (!manager.peekRecord) { throw new Error("The configured Session stream manager does not support record metadata"); } - return manager.peekRecord(sessionId, io); + return manager.peekRecord(sessionId, io, channel); } - public lastSeqNum(sessionId: string, io: SessionChannelIO): number | undefined { - return this.#getManager().lastSeqNum(sessionId, io); + public lastSeqNum(sessionId: string, io: SessionChannelIO, channel?: string): number | undefined { + return this.#getManager().lastSeqNum(sessionId, io, channel); } - public setLastSeqNum(sessionId: string, io: SessionChannelIO, seqNum: number): void { - this.#getManager().setLastSeqNum(sessionId, io, seqNum); + public setLastSeqNum( + sessionId: string, + io: SessionChannelIO, + seqNum: number, + channel?: string + ): void { + this.#getManager().setLastSeqNum(sessionId, io, seqNum, channel); } - public consumeRecord(sessionId: string, io: SessionChannelIO, seqNum: number): void { + public consumeRecord( + sessionId: string, + io: SessionChannelIO, + seqNum: number, + channel?: string + ): void { const manager = this.#getManager(); if (!manager.consumeRecord) { throw new Error("The configured Session stream manager does not support exact consumption"); } - manager.consumeRecord(sessionId, io, seqNum); + manager.consumeRecord(sessionId, io, seqNum, channel); } - public lastDispatchedSeqNum(sessionId: string, io: SessionChannelIO): number | undefined { - return this.#getManager().lastDispatchedSeqNum(sessionId, io); + public lastDispatchedSeqNum( + sessionId: string, + io: SessionChannelIO, + channel?: string + ): number | undefined { + return this.#getManager().lastDispatchedSeqNum(sessionId, io, channel); } - public setLastDispatchedSeqNum(sessionId: string, io: SessionChannelIO, seqNum: number): void { - this.#getManager().setLastDispatchedSeqNum(sessionId, io, seqNum); + public setLastDispatchedSeqNum( + sessionId: string, + io: SessionChannelIO, + seqNum: number, + channel?: string + ): void { + this.#getManager().setLastDispatchedSeqNum(sessionId, io, seqNum, channel); } public setMinTimestamp( sessionId: string, io: SessionChannelIO, - minTimestamp: number | undefined + minTimestamp: number | undefined, + channel?: string ): void { - this.#getManager().setMinTimestamp(sessionId, io, minTimestamp); + this.#getManager().setMinTimestamp(sessionId, io, minTimestamp, channel); } - public shiftBuffer(sessionId: string, io: SessionChannelIO): boolean { - return this.#getManager().shiftBuffer(sessionId, io); + public shiftBuffer(sessionId: string, io: SessionChannelIO, channel?: string): boolean { + return this.#getManager().shiftBuffer(sessionId, io, channel); } - public reconnectStream(sessionId: string, io: SessionChannelIO): void { - this.#getManager().reconnectStream?.(sessionId, io); + public reconnectStream(sessionId: string, io: SessionChannelIO, channel?: string): void { + this.#getManager().reconnectStream?.(sessionId, io, channel); } - public disconnectStream(sessionId: string, io: SessionChannelIO): void { - this.#getManager().disconnectStream(sessionId, io); + public disconnectStream(sessionId: string, io: SessionChannelIO, channel?: string): void { + this.#getManager().disconnectStream(sessionId, io, channel); } public clearHandlers(): void { diff --git a/packages/core/src/v3/sessionStreams/manager.ts b/packages/core/src/v3/sessionStreams/manager.ts index 73c85f972e2..0e833315813 100644 --- a/packages/core/src/v3/sessionStreams/manager.ts +++ b/packages/core/src/v3/sessionStreams/manager.ts @@ -47,8 +47,8 @@ type TailState = { promise: Promise; }; -function keyFor(sessionId: string, io: SessionChannelIO): string { - return `${sessionId}:${io}`; +function keyFor(sessionId: string, io: SessionChannelIO, channel?: string): string { + return `${sessionId}:${channel ?? ""}:${io}`; } /** @@ -103,8 +103,13 @@ export class StandardSessionStreamManager implements SessionStreamManager { private debug: boolean = false ) {} - on(sessionId: string, io: SessionChannelIO, handler: SessionStreamHandler): { off: () => void } { - return this.#register(sessionId, io, { kind: "data", fn: handler }); + on( + sessionId: string, + io: SessionChannelIO, + handler: SessionStreamHandler, + channel?: string + ): { off: () => void } { + return this.#register(sessionId, io, { kind: "data", fn: handler }, channel); } /** @@ -114,17 +119,19 @@ export class StandardSessionStreamManager implements SessionStreamManager { onRecord( sessionId: string, io: SessionChannelIO, - handler: SessionStreamRecordHandler + handler: SessionStreamRecordHandler, + channel?: string ): { off: () => void } { - return this.#register(sessionId, io, { kind: "record", fn: handler }); + return this.#register(sessionId, io, { kind: "record", fn: handler }, channel); } #register( sessionId: string, io: SessionChannelIO, - handler: RegisteredHandler + handler: RegisteredHandler, + channel?: string ): { off: () => void } { - const key = keyFor(sessionId, io); + const key = keyFor(sessionId, io, channel); let handlerSet = this.handlers.get(key); if (!handlerSet) { @@ -136,7 +143,7 @@ export class StandardSessionStreamManager implements SessionStreamManager { // Explicit re-attach clears the "explicitly disconnected" suppression // so the tail can subscribe again now that callers want delivery back. this.explicitlyDisconnected.delete(key); - this.#ensureTailConnected(sessionId, io); + this.#ensureTailConnected(sessionId, io, channel); // Selective drain: offer each buffered record to the new handler and // remove ONLY the ones it consumed (returned `true` — e.g. the @@ -181,9 +188,10 @@ export class StandardSessionStreamManager implements SessionStreamManager { once( sessionId: string, io: SessionChannelIO, - options?: InputStreamOnceOptions + options?: InputStreamOnceOptions, + channel?: string ): InputStreamOncePromise { - const recordPromise = this.onceRecord(sessionId, io, options); + const recordPromise = this.onceRecord(sessionId, io, options, channel); return new InputStreamOncePromise((resolve, reject) => { recordPromise.then((result) => { resolve(result.ok ? { ok: true, output: result.output.data } : result); @@ -194,27 +202,30 @@ export class StandardSessionStreamManager implements SessionStreamManager { onceRecord( sessionId: string, io: SessionChannelIO, - options?: InputStreamOnceOptions + options?: InputStreamOnceOptions, + channel?: string ): InputStreamOncePromise { - return this.#onceRecord(sessionId, io, undefined, options); + return this.#onceRecord(sessionId, io, undefined, options, channel); } onceRecordWhere( sessionId: string, io: SessionChannelIO, predicate: SessionStreamRecordPredicate, - options?: InputStreamOnceOptions + options?: InputStreamOnceOptions, + channel?: string ): InputStreamOncePromise { - return this.#onceRecord(sessionId, io, predicate, options); + return this.#onceRecord(sessionId, io, predicate, options, channel); } #onceRecord( sessionId: string, io: SessionChannelIO, predicate: SessionStreamRecordPredicate | undefined, - options?: InputStreamOnceOptions + options?: InputStreamOnceOptions, + channel?: string ): InputStreamOncePromise { - const key = keyFor(sessionId, io); + const key = keyFor(sessionId, io, channel); if (options?.timeoutMs === 0) { const record = this.#takeBufferedRecord(key, predicate); @@ -228,7 +239,7 @@ export class StandardSessionStreamManager implements SessionStreamManager { } this.explicitlyDisconnected.delete(key); - this.#ensureTailConnected(sessionId, io); + this.#ensureTailConnected(sessionId, io, channel); const record = this.#takeBufferedRecord(key, predicate); if (record) { @@ -293,28 +304,32 @@ export class StandardSessionStreamManager implements SessionStreamManager { return record; } - peek(sessionId: string, io: SessionChannelIO): unknown | undefined { - return this.peekRecord(sessionId, io)?.data; + peek(sessionId: string, io: SessionChannelIO, channel?: string): unknown | undefined { + return this.peekRecord(sessionId, io, channel)?.data; } - peekRecord(sessionId: string, io: SessionChannelIO): SessionStreamRecord | undefined { - return this.buffer.get(keyFor(sessionId, io))?.[0]; + peekRecord( + sessionId: string, + io: SessionChannelIO, + channel?: string + ): SessionStreamRecord | undefined { + return this.buffer.get(keyFor(sessionId, io, channel))?.[0]; } - lastSeqNum(sessionId: string, io: SessionChannelIO): number | undefined { - return this.seqNums.get(keyFor(sessionId, io)); + lastSeqNum(sessionId: string, io: SessionChannelIO, channel?: string): number | undefined { + return this.seqNums.get(keyFor(sessionId, io, channel)); } - setLastSeqNum(sessionId: string, io: SessionChannelIO, seqNum: number): void { - const key = keyFor(sessionId, io); + setLastSeqNum(sessionId: string, io: SessionChannelIO, seqNum: number, channel?: string): void { + const key = keyFor(sessionId, io, channel); const current = this.seqNums.get(key); if (current === undefined || seqNum > current) { this.seqNums.set(key, seqNum); } } - consumeRecord(sessionId: string, io: SessionChannelIO, seqNum: number): void { - const key = keyFor(sessionId, io); + consumeRecord(sessionId: string, io: SessionChannelIO, seqNum: number, channel?: string): void { + const key = keyFor(sessionId, io, channel); const buffered = this.buffer.get(key); const index = buffered?.findIndex((record) => record.seqNum === seqNum) ?? -1; @@ -329,8 +344,12 @@ export class StandardSessionStreamManager implements SessionStreamManager { this.#drainOnceWaitersFromBuffer(key); } - lastDispatchedSeqNum(sessionId: string, io: SessionChannelIO): number | undefined { - const key = keyFor(sessionId, io); + lastDispatchedSeqNum( + sessionId: string, + io: SessionChannelIO, + channel?: string + ): number | undefined { + const key = keyFor(sessionId, io, channel); const highWatermark = this.lastDispatchedSeqNums.get(key); if (highWatermark === undefined) return undefined; @@ -346,10 +365,15 @@ export class StandardSessionStreamManager implements SessionStreamManager { return safeCursor >= 0 ? safeCursor : undefined; } - setLastDispatchedSeqNum(sessionId: string, io: SessionChannelIO, seqNum: number): void { + setLastDispatchedSeqNum( + sessionId: string, + io: SessionChannelIO, + seqNum: number, + channel?: string + ): void { if (!Number.isFinite(seqNum)) return; - this.#advanceLastDispatched(keyFor(sessionId, io), seqNum); + this.#advanceLastDispatched(keyFor(sessionId, io, channel), seqNum); } #advanceLastDispatched(key: string, seqNum: number): void { @@ -380,8 +404,13 @@ export class StandardSessionStreamManager implements SessionStreamManager { } } - setMinTimestamp(sessionId: string, io: SessionChannelIO, minTimestamp: number | undefined): void { - const key = keyFor(sessionId, io); + setMinTimestamp( + sessionId: string, + io: SessionChannelIO, + minTimestamp: number | undefined, + channel?: string + ): void { + const key = keyFor(sessionId, io, channel); if (minTimestamp === undefined) { this.minTimestamps.delete(key); } else { @@ -389,8 +418,8 @@ export class StandardSessionStreamManager implements SessionStreamManager { } } - shiftBuffer(sessionId: string, io: SessionChannelIO): boolean { - const key = keyFor(sessionId, io); + shiftBuffer(sessionId: string, io: SessionChannelIO, channel?: string): boolean { + const key = keyFor(sessionId, io, channel); const buffered = this.buffer.get(key); if (buffered && buffered.length > 0) { const record = buffered.shift()!; @@ -404,8 +433,8 @@ export class StandardSessionStreamManager implements SessionStreamManager { return false; } - disconnectStream(sessionId: string, io: SessionChannelIO): void { - const key = keyFor(sessionId, io); + disconnectStream(sessionId: string, io: SessionChannelIO, channel?: string): void { + const key = keyFor(sessionId, io, channel); const tail = this.tails.get(key); // Mark as explicitly disconnected BEFORE we abort, so the tail's // `.finally` reconnect path sees the flag when it runs (which can be @@ -429,10 +458,10 @@ export class StandardSessionStreamManager implements SessionStreamManager { * its handler just to clear the suppression flag would replay the buffer at * it. */ - reconnectStream(sessionId: string, io: SessionChannelIO): void { - const key = keyFor(sessionId, io); + reconnectStream(sessionId: string, io: SessionChannelIO, channel?: string): void { + const key = keyFor(sessionId, io, channel); this.explicitlyDisconnected.delete(key); - this.#ensureTailConnected(sessionId, io); + this.#ensureTailConnected(sessionId, io, channel); } clearHandlers(): void { @@ -485,12 +514,12 @@ export class StandardSessionStreamManager implements SessionStreamManager { this.buffer.clear(); } - #ensureTailConnected(sessionId: string, io: SessionChannelIO): void { - const key = keyFor(sessionId, io); + #ensureTailConnected(sessionId: string, io: SessionChannelIO, channel?: string): void { + const key = keyFor(sessionId, io, channel); if (this.tails.has(key)) return; const abortController = new AbortController(); - const promise = this.#runTail(sessionId, io, abortController.signal) + const promise = this.#runTail(sessionId, io, abortController.signal, channel) .catch((error) => { if (this.debug) { console.error(`[SessionStreamManager] Tail error for "${key}":`, error); @@ -530,15 +559,20 @@ export class StandardSessionStreamManager implements SessionStreamManager { const stillHasWaiters = this.onceWaiters.has(key) && this.onceWaiters.get(key)!.length > 0; if (!stillHasHandlers && !stillHasWaiters) return; - this.#ensureTailConnected(sessionId, io); + this.#ensureTailConnected(sessionId, io, channel); }, delayMs); } }); this.tails.set(key, { abortController, promise }); } - async #runTail(sessionId: string, io: SessionChannelIO, signal: AbortSignal): Promise { - const key = keyFor(sessionId, io); + async #runTail( + sessionId: string, + io: SessionChannelIO, + signal: AbortSignal, + channel?: string + ): Promise { + const key = keyFor(sessionId, io, channel); try { const lastSeq = this.seqNums.get(key); // Dispatch is driven from `onPart` (not the for-await loop) so each @@ -549,6 +583,7 @@ export class StandardSessionStreamManager implements SessionStreamManager { const stream = await this.apiClient.subscribeToSessionStream(sessionId, io, { signal, baseUrl: this.baseUrl, + channel, timeoutInSeconds: 600, lastEventId: lastSeq !== undefined ? String(lastSeq) : undefined, onPart: (part) => { diff --git a/packages/core/src/v3/sessionStreams/types.ts b/packages/core/src/v3/sessionStreams/types.ts index cc6bde884cc..ccf394829fc 100644 --- a/packages/core/src/v3/sessionStreams/types.ts +++ b/packages/core/src/v3/sessionStreams/types.ts @@ -47,7 +47,8 @@ export interface SessionStreamManager { on( sessionId: string, io: SessionChannelIO, - handler: (data: unknown) => void | boolean | Promise + handler: (data: unknown) => void | boolean | Promise, + channel?: string ): { off: () => void }; /** @@ -57,21 +58,24 @@ export interface SessionStreamManager { onRecord?( sessionId: string, io: SessionChannelIO, - handler: (record: SessionStreamRecord) => void | boolean | Promise + handler: (record: SessionStreamRecord) => void | boolean | Promise, + channel?: string ): { off: () => void }; /** Wait for the next record on the given channel (buffered or live). */ once( sessionId: string, io: SessionChannelIO, - options?: InputStreamOnceOptions + options?: InputStreamOnceOptions, + channel?: string ): InputStreamOncePromise; /** Wait for and consume the next record, including its durable metadata. */ onceRecord?( sessionId: string, io: SessionChannelIO, - options?: InputStreamOnceOptions + options?: InputStreamOnceOptions, + channel?: string ): InputStreamOncePromise; /** @@ -83,23 +87,28 @@ export interface SessionStreamManager { sessionId: string, io: SessionChannelIO, predicate: SessionStreamRecordPredicate, - options?: InputStreamOnceOptions + options?: InputStreamOnceOptions, + channel?: string ): InputStreamOncePromise; /** Non-blocking peek at the head of the channel buffer. */ - peek(sessionId: string, io: SessionChannelIO): unknown | undefined; + peek(sessionId: string, io: SessionChannelIO, channel?: string): unknown | undefined; /** Non-blocking peek at the head record, including its durable metadata. */ - peekRecord?(sessionId: string, io: SessionChannelIO): SessionStreamRecord | undefined; + peekRecord?( + sessionId: string, + io: SessionChannelIO, + channel?: string + ): SessionStreamRecord | undefined; /** Last S2 sequence number seen on the given channel. */ - lastSeqNum(sessionId: string, io: SessionChannelIO): number | undefined; + lastSeqNum(sessionId: string, io: SessionChannelIO, channel?: string): number | undefined; /** Advance the last-seen sequence number (prevents SSE replay after `.wait` resume). */ - setLastSeqNum(sessionId: string, io: SessionChannelIO, seqNum: number): void; + setLastSeqNum(sessionId: string, io: SessionChannelIO, seqNum: number, channel?: string): void; /** Consume one exact record delivered through the waitpoint path. */ - consumeRecord?(sessionId: string, io: SessionChannelIO, seqNum: number): void; + consumeRecord?(sessionId: string, io: SessionChannelIO, seqNum: number, channel?: string): void; /** * Highest sequence number that is safe to persist as consumed. When a later @@ -111,7 +120,11 @@ export interface SessionStreamManager { * `turn-complete` control record so the next worker boot can resume * the channel from this point without replaying processed messages. */ - lastDispatchedSeqNum(sessionId: string, io: SessionChannelIO): number | undefined; + lastDispatchedSeqNum( + sessionId: string, + io: SessionChannelIO, + channel?: string + ): number | undefined; /** * Seed the committed-consume cursor at worker boot — e.g. from the @@ -119,7 +132,12 @@ export interface SessionStreamManager { * `.out`. Monotonic: only ever advances forward, never backwards. Existing * unconsumed records still constrain {@link lastDispatchedSeqNum}. */ - setLastDispatchedSeqNum(sessionId: string, io: SessionChannelIO, seqNum: number): void; + setLastDispatchedSeqNum( + sessionId: string, + io: SessionChannelIO, + seqNum: number, + channel?: string + ): void; /** * Set a per-stream lower-bound SSE timestamp. Records whose timestamp @@ -129,16 +147,21 @@ export interface SessionStreamManager { * * Pass `undefined` to clear the filter. */ - setMinTimestamp(sessionId: string, io: SessionChannelIO, minTimestamp: number | undefined): void; + setMinTimestamp( + sessionId: string, + io: SessionChannelIO, + minTimestamp: number | undefined, + channel?: string + ): void; /** Remove and discard the first buffered record. Returns true if one was removed. */ - shiftBuffer(sessionId: string, io: SessionChannelIO): boolean; + shiftBuffer(sessionId: string, io: SessionChannelIO, channel?: string): boolean; /** Abort the SSE tail while preserving buffered records. Called before `.wait` suspends. */ - disconnectStream(sessionId: string, io: SessionChannelIO): void; + disconnectStream(sessionId: string, io: SessionChannelIO, channel?: string): void; /** Re-open a channel closed by {@link disconnectStream}, registering nothing. */ - reconnectStream?(sessionId: string, io: SessionChannelIO): void; + reconnectStream?(sessionId: string, io: SessionChannelIO, channel?: string): void; /** Clear all `.on` handlers; abort tails without pending once-waiters. */ clearHandlers(): void; From 25fcd1bb9b50625066044e5a62f0cc451e44a960 Mon Sep 17 00:00:00 2001 From: Eric Allam Date: Fri, 28 Aug 2026 14:07:08 +0100 Subject: [PATCH 02/25] feat(sdk,core): session.channel() handle, channel spans, and native retention Add SessionHandle.channel(name, options) returning a named .in/.out pair, threading the channel through the SDK write/read/control/trim paths and the apiClient session methods. Channel writes carry channel + io span attributes and accessory chips so the span inspector renders them. A side channel's .in is subscribe-only (.on/.once/.peek); .wait()/waitWithIdleTimeout throw, since a side channel does not wake a run. Named channels get a default native S2 retention (24h + delete-on-empty), overridable per channel, applied server-side at initialize and cached per stream. --- ...sessions.$session.channels.$channel.$io.ts | 43 +++++- .../realtime/s2realtimeStreams.server.ts | 59 +++++++- packages/core/src/v3/apiClient/index.ts | 40 ++++-- .../realtimeStreams/sessionStreamOneshot.ts | 14 +- packages/trigger-sdk/src/v3/sessions.ts | 128 +++++++++++++++--- 5 files changed, 246 insertions(+), 38 deletions(-) diff --git a/apps/webapp/app/routes/realtime.v1.sessions.$session.channels.$channel.$io.ts b/apps/webapp/app/routes/realtime.v1.sessions.$session.channels.$channel.$io.ts index 36aa6feb16b..b1de21d2c90 100644 --- a/apps/webapp/app/routes/realtime.v1.sessions.$session.channels.$channel.$io.ts +++ b/apps/webapp/app/routes/realtime.v1.sessions.$session.channels.$channel.$io.ts @@ -1,8 +1,13 @@ import { json } from "@remix-run/server-runtime"; import { STREAM_START_HEADER } from "@trigger.dev/core/v3"; +import { tryCatch } from "@trigger.dev/core/utils"; import { z } from "zod"; +import { logger } from "~/services/logger.server"; import { getRequestAbortSignal } from "~/services/httpAsyncStorage.server"; -import { S2RealtimeStreams } from "~/services/realtime/s2realtimeStreams.server"; +import { + DEFAULT_SESSION_CHANNEL_RETENTION, + S2RealtimeStreams, +} from "~/services/realtime/s2realtimeStreams.server"; import { SESSION_CHANNEL_NAME_REGEX, sessionChannelResources, @@ -25,6 +30,12 @@ const ParamsSchema = z.object({ io: z.enum(["out", "in"]), }); +function parsePositiveIntHeader(value: string | null): number | undefined { + if (value == null) return undefined; + const parsed = Number(value); + return Number.isInteger(parsed) && parsed > 0 ? parsed : undefined; +} + const { action } = createActionApiRoute( { params: ParamsSchema, @@ -36,7 +47,7 @@ const { action } = createActionApiRoute( resource: (params) => anyResource(sessionChannelResources(params.channel, [params.session])), }, }, - async ({ params, authentication }) => { + async ({ params, authentication, request }) => { if (params.io === "out" && authentication.type !== "PRIVATE") { return new Response("Initializing the out channel requires secret key authentication", { status: 403, @@ -67,6 +78,34 @@ const { action } = createActionApiRoute( const addressingKey = canonicalSessionAddressingKey(maybeSession, params.session); + const maxAgeSeconds = parsePositiveIntHeader( + request.headers.get("x-channel-max-age-seconds") + ); + const deleteOnEmptyMinAgeSeconds = parsePositiveIntHeader( + request.headers.get("x-channel-delete-on-empty-seconds") + ); + const retention = + maxAgeSeconds != null || deleteOnEmptyMinAgeSeconds != null + ? { maxAgeSeconds, deleteOnEmptyMinAgeSeconds } + : DEFAULT_SESSION_CHANNEL_RETENTION; + + const [retentionError] = await tryCatch( + realtimeStream.ensureSessionChannelRetention( + addressingKey, + params.io, + params.channel, + retention + ) + ); + if (retentionError) { + logger.warn("Failed to ensure session channel retention", { + addressingKey, + channel: params.channel, + io: params.io, + error: retentionError, + }); + } + const { responseHeaders } = await realtimeStream.initializeSessionStream( addressingKey, params.io, diff --git a/apps/webapp/app/services/realtime/s2realtimeStreams.server.ts b/apps/webapp/app/services/realtime/s2realtimeStreams.server.ts index 2d32d98e707..27c0aec11ef 100644 --- a/apps/webapp/app/services/realtime/s2realtimeStreams.server.ts +++ b/apps/webapp/app/services/realtime/s2realtimeStreams.server.ts @@ -78,6 +78,11 @@ export type S2RealtimeStreamsOptions = { const S2_TOKEN_OPS = ["append", "create-stream", "trim"] as const; const S2_TOKEN_OPS_FINGERPRINT = [...S2_TOKEN_OPS].sort().join(","); +export const DEFAULT_SESSION_CHANNEL_RETENTION = { + maxAgeSeconds: 60 * 60 * 24, + deleteOnEmptyMinAgeSeconds: 60 * 60, +} as const; + /** * Placeholder handed back as the S2 access token when `skipAccessTokens` is set * and no token is configured (self-hosted s2-lite ignores the token entirely). @@ -118,6 +123,8 @@ export class S2RealtimeStreams implements StreamResponder, StreamIngestor { accessToken: string; }>; + readonly #retentionEnsured = new Set(); + constructor(opts: S2RealtimeStreamsOptions) { this.basin = opts.basin; this.baseUrl = @@ -293,18 +300,30 @@ export class S2RealtimeStreams implements StreamResponder, StreamIngestor { } /** - * Apply a per-stream native retention override to one direction of a named - * side channel. The durable bound that keeps a run-independent channel from - * growing without a turn loop trimming it. Server-side, webapp S2 token, - * called once per channel lifetime (cached by the caller). + * Ensure a named side channel's stream has native S2 retention applied: the + * durable bound that keeps a run-independent channel from growing without a + * turn loop trimming it. Creates the stream with the retention config (the + * common path — initialize runs before the first write), falling back to a + * reconfigure if it already exists. Idempotent and cached per stream so it + * runs at most once per channel per process; a control-plane op kept off the + * hot path. */ - async reconfigureSessionChannelRetention( + async ensureSessionChannelRetention( friendlyId: string, io: "out" | "in", channel: string, retention: { maxAgeSeconds?: number; deleteOnEmptyMinAgeSeconds?: number } ): Promise { - await this.#s2ReconfigureStream(this.toSessionStreamName(friendlyId, io, channel), retention); + if (this.skipAccessTokens) return; + + const stream = this.toSessionStreamName(friendlyId, io, channel); + if (this.#retentionEnsured.has(stream)) return; + + const created = await this.#s2CreateStreamWithConfig(stream, retention); + if (!created) { + await this.#s2ReconfigureStream(stream, retention); + } + this.#retentionEnsured.add(stream); } async #readRecordsByName(s2Stream: string, afterSeqNum?: number): Promise { @@ -803,6 +822,34 @@ export class S2RealtimeStreams implements StreamResponder, StreamIngestor { return names; } + async #s2CreateStreamWithConfig( + stream: string, + retention: { maxAgeSeconds?: number; deleteOnEmptyMinAgeSeconds?: number } + ): Promise { + const config: Record = {}; + if (retention.maxAgeSeconds != null) { + config.retention_policy = { age: retention.maxAgeSeconds }; + } + if (retention.deleteOnEmptyMinAgeSeconds != null) { + config.delete_on_empty = { min_age_secs: retention.deleteOnEmptyMinAgeSeconds }; + } + + const res = await fetch(`${this.baseUrl}/streams`, { + method: "POST", + headers: { + Authorization: `Bearer ${this.token}`, + "Content-Type": "application/json", + "S2-Basin": this.basin, + }, + body: JSON.stringify({ stream, config }), + }); + + if (res.ok) return true; + if (res.status === 409) return false; + const text = await res.text().catch(() => ""); + throw new Error(`S2 createStream failed: ${res.status} ${res.statusText} ${text}`); + } + async #s2ReconfigureStream( stream: string, retention: { maxAgeSeconds?: number; deleteOnEmptyMinAgeSeconds?: number } diff --git a/packages/core/src/v3/apiClient/index.ts b/packages/core/src/v3/apiClient/index.ts index 9d9ea16229a..471a8161650 100644 --- a/packages/core/src/v3/apiClient/index.ts +++ b/packages/core/src/v3/apiClient/index.ts @@ -1387,18 +1387,33 @@ export class ApiClient { async initializeSessionStream( sessionIdOrExternalId: string, io: "out" | "in", - requestOptions?: ZodFetchOptions + requestOptions?: ZodFetchOptions, + channel?: string, + retention?: { maxAgeSeconds?: number; deleteOnEmptyMinAgeSeconds?: number } ) { // The server returns S2 credentials in response headers alongside a tiny // JSON body with the realtime version. Follow the same shape as // `createStream` so downstream clients can feed them into // `StreamsWriterV2`. + const base = `${this.baseUrl}/realtime/v1/sessions/${encodeURIComponent(sessionIdOrExternalId)}`; + const url = channel + ? `${base}/channels/${encodeURIComponent(channel)}/${io}` + : `${base}/${io}`; + const retentionHeaders: Record = {}; + if (channel && retention?.maxAgeSeconds != null) { + retentionHeaders["x-channel-max-age-seconds"] = String(retention.maxAgeSeconds); + } + if (channel && retention?.deleteOnEmptyMinAgeSeconds != null) { + retentionHeaders["x-channel-delete-on-empty-seconds"] = String( + retention.deleteOnEmptyMinAgeSeconds + ); + } return zodfetch( CreateStreamResponseBody, - `${this.baseUrl}/realtime/v1/sessions/${encodeURIComponent(sessionIdOrExternalId)}/${io}`, + url, { method: "PUT", - headers: this.#getHeaders(false), + headers: { ...this.#getHeaders(false), ...retentionHeaders }, }, mergeRequestOptions(this.defaultRequestOptions, requestOptions) ) @@ -1413,16 +1428,21 @@ export class ApiClient { sessionIdOrExternalId: string, io: "out" | "in", part: TBody, - requestOptions?: ZodFetchOptions + requestOptions?: ZodFetchOptions, + channel?: string ) { // Generated once per logical append, outside zodfetch, so its internal // retries reuse the same part id and the server-side dedupe collapses a // retried POST whose first attempt actually committed. Full-length nanoid // (~126 bits) to match the browser transport's randomUUID entropy. const partId = nanoid(); + const base = `${this.baseUrl}/realtime/v1/sessions/${encodeURIComponent(sessionIdOrExternalId)}`; + const appendUrl = channel + ? `${base}/channels/${encodeURIComponent(channel)}/${io}/append` + : `${base}/${io}/append`; return zodfetch( AppendToStreamResponseBody, - `${this.baseUrl}/realtime/v1/sessions/${encodeURIComponent(sessionIdOrExternalId)}/${io}/append`, + appendUrl, { method: "POST", headers: { ...this.#getHeaders(false), "X-Part-Id": partId }, @@ -1446,15 +1466,19 @@ export class ApiClient { async readSessionStreamRecords( sessionIdOrExternalId: string, io: "out" | "in", - options?: { afterEventId?: string; baseUrl?: string } + options?: { afterEventId?: string; baseUrl?: string; channel?: string } ) { const qs = new URLSearchParams(); if (options?.afterEventId !== undefined) { qs.set("afterEventId", options.afterEventId); } - const url = `${options?.baseUrl ?? this.baseUrl}/realtime/v1/sessions/${encodeURIComponent( + const recordsBase = `${options?.baseUrl ?? this.baseUrl}/realtime/v1/sessions/${encodeURIComponent( sessionIdOrExternalId - )}/${io}/records${qs.toString() ? `?${qs.toString()}` : ""}`; + )}`; + const recordsPath = options?.channel + ? `${recordsBase}/channels/${encodeURIComponent(options.channel)}/${io}/records` + : `${recordsBase}/${io}/records`; + const url = `${recordsPath}${qs.toString() ? `?${qs.toString()}` : ""}`; return zodfetch( ReadSessionStreamRecordsResponseBody, url, diff --git a/packages/core/src/v3/realtimeStreams/sessionStreamOneshot.ts b/packages/core/src/v3/realtimeStreams/sessionStreamOneshot.ts index 9aa25fa82dd..9fd8911d511 100644 --- a/packages/core/src/v3/realtimeStreams/sessionStreamOneshot.ts +++ b/packages/core/src/v3/realtimeStreams/sessionStreamOneshot.ts @@ -23,8 +23,8 @@ import type { StreamWriteResult } from "./types.js"; type IO = "out" | "in"; -async function getS2Stream(apiClient: ApiClient, sessionId: string, io: IO) { - const response = await apiClient.initializeSessionStream(sessionId, io); +async function getS2Stream(apiClient: ApiClient, sessionId: string, io: IO, channel?: string) { + const response = await apiClient.initializeSessionStream(sessionId, io, undefined, channel); const headers = response.headers ?? {}; const accessToken = headers["x-s2-access-token"]; const basin = headers["x-s2-basin"]; @@ -65,9 +65,10 @@ export async function writeSessionControlRecord( sessionId: string, io: IO, subtype: TriggerControlSubtype | string, - extraHeaders?: ReadonlyArray + extraHeaders?: ReadonlyArray, + channel?: string ): Promise { - const stream = await getS2Stream(apiClient, sessionId, io); + const stream = await getS2Stream(apiClient, sessionId, io, channel); const headers: ReadonlyArray = [ [TRIGGER_CONTROL_HEADER, subtype], ...(extraHeaders ?? []), @@ -93,9 +94,10 @@ export async function writeSessionControlRecord( export async function trimSessionStream( apiClient: ApiClient, sessionId: string, - earliestSeqNum: number + earliestSeqNum: number, + channel?: string ): Promise { - const stream = await getS2Stream(apiClient, sessionId, "out"); + const stream = await getS2Stream(apiClient, sessionId, "out", channel); await stream.append(AppendInput.create([AppendRecord.trim(earliestSeqNum)])); } diff --git a/packages/trigger-sdk/src/v3/sessions.ts b/packages/trigger-sdk/src/v3/sessions.ts index 9758d534f21..836b33b0abe 100644 --- a/packages/trigger-sdk/src/v3/sessions.ts +++ b/packages/trigger-sdk/src/v3/sessions.ts @@ -252,8 +252,36 @@ export class SessionHandle { this.out = overrides?.out ?? new SessionOutputChannel(id); this.in = overrides?.in ?? new SessionInputChannel(id); } + + /** + * Open a named side channel on this session: a durable, cross-run `.in`/`.out` + * pair addressed by `name` rather than the reserved default pair. Writing a + * side channel's `.in` does not wake or trigger a run — a run observes it via + * `.in.on()` / `.in.once()`. Records outlive any single run and are bounded by + * the channel's retention (a sensible default, overridable via `options`). + */ + channel(name: string, options?: SessionChannelOptions): SessionChannelHandle { + if (!SESSION_CHANNEL_NAME_REGEX.test(name)) { + throw new Error( + `Invalid session channel name "${name}": use 1-128 chars from [A-Za-z0-9._-].` + ); + } + return { + name, + out: new SessionOutputChannel(this.id, name, options?.retention), + in: new SessionInputChannel(this.id, name), + }; + } } +export type SessionChannelHandle = { + readonly name: string; + readonly out: SessionOutputChannel; + readonly in: SessionInputChannel; +}; + +const SESSION_CHANNEL_NAME_REGEX = /^[A-Za-z0-9._-]{1,128}$/; + /** * Options accepted by {@link SessionOutputChannel.pipe}. Session-scoped, * so it omits the `target` field (self/parent/root/runId) that run-scoped @@ -261,6 +289,22 @@ export class SessionHandle { */ export type SessionPipeStreamOptions = Omit; +/** + * Retention for a named side channel. `maxAgeSeconds` and + * `deleteOnEmptyMinAgeSeconds` are applied server-side as native S2 per-stream + * config on first initialize; `keepLastN` is a producer-side trim floor the + * writer enforces by appending an S2 trim command as records accumulate. + */ +export type SessionChannelRetention = { + maxAgeSeconds?: number; + deleteOnEmptyMinAgeSeconds?: number; + keepLastN?: number; +}; + +export type SessionChannelOptions = { + retention?: SessionChannelRetention; +}; + /** * The `.out` side of a Session's bidirectional channel pair. Mirrors the * consume-side of {@link streams.define}: `pipe` / `writer` / `append` @@ -279,7 +323,11 @@ export class SessionOutputChannel { // Evicts on failure (so the next call retries) and on `reset()`. #initPromise?: Promise; - constructor(public readonly sessionId: string) {} + constructor( + public readonly sessionId: string, + public readonly channel?: string, + private readonly retention?: SessionChannelRetention + ) {} /** * Drop the cached `initializeSessionStream` response. Surfaces for @@ -412,6 +460,7 @@ export class SessionOutputChannel { return apiClient.subscribeToSessionStream(this.sessionId, "out", { signal: options?.signal, + channel: this.channel, timeoutInSeconds: options?.timeoutInSeconds, lastEventId: options?.lastEventId != null ? String(options.lastEventId) : undefined, onPart: options?.onPart, @@ -433,12 +482,18 @@ export class SessionOutputChannel { attributes: { session: this.sessionId, io: "out", + ...(this.channel ? { channel: this.channel } : {}), [SemanticInternalAttributes.ENTITY_TYPE]: "session-stream", - [SemanticInternalAttributes.ENTITY_ID]: `${this.sessionId}:out`, + [SemanticInternalAttributes.ENTITY_ID]: `${this.sessionId}:${this.channel ?? ""}:out`, [SemanticInternalAttributes.STYLE_ICON]: "sessions", ...(collapsed ? { [SemanticInternalAttributes.COLLAPSED]: true } : {}), ...accessoryAttributes({ - items: [{ text: `${this.sessionId}.out`, variant: "normal" }], + items: this.channel + ? [ + { text: this.channel, variant: "normal" }, + { text: "out", variant: "normal" }, + ] + : [{ text: `${this.sessionId}.out`, variant: "normal" }], style: "codepath", }), }, @@ -481,7 +536,9 @@ export class SessionOutputChannel { const fresh = apiClient.initializeSessionStream( this.sessionId, "out", - options?.requestOptions + options?.requestOptions, + this.channel, + this.retention ); this.#initPromise = fresh; // Evict on failure so the next call retries instead of returning a @@ -569,7 +626,14 @@ export class SessionOutputChannel { extraHeaders?: ReadonlyArray ): Promise { const apiClient = apiClientManager.clientOrThrow(); - return writeSessionControlRecord(apiClient, this.sessionId, "out", subtype, extraHeaders); + return writeSessionControlRecord( + apiClient, + this.sessionId, + "out", + subtype, + extraHeaders, + this.channel + ); } /** @@ -583,7 +647,7 @@ export class SessionOutputChannel { */ async trimTo(earliestSeqNum: number): Promise { const apiClient = apiClientManager.clientOrThrow(); - await trimSessionStream(apiClient, this.sessionId, earliestSeqNum); + await trimSessionStream(apiClient, this.sessionId, earliestSeqNum, this.channel); } } @@ -595,7 +659,18 @@ export class SessionOutputChannel { * conversation can survive across run boundaries. */ export class SessionInputChannel { - constructor(public readonly sessionId: string) {} + constructor( + public readonly sessionId: string, + public readonly channel?: string + ) {} + + #assertReservedChannelForWait(method: string): void { + if (this.channel) { + throw new Error( + `session.channel("${this.channel}").in.${method} is not supported: a named side channel does not wake a run. Use .in.on() / .in.once() to observe it instead.` + ); + } + } /** * Send a single record to the channel. Called by external clients @@ -607,17 +682,24 @@ export class SessionInputChannel { const apiClient = apiClientManager.clientOrThrow(); const body = typeof value === "string" ? value : JSON.stringify(value); + const spanName = this.channel + ? `sessions.open(${this.sessionId}).channel(${this.channel}).in.send()` + : `sessions.open(${this.sessionId}).in.send()`; + const $requestOptions = mergeRequestOptions( { tracer, - name: `sessions.open(${this.sessionId}).in.send()`, + name: spanName, icon: "sessions", - attributes: sessionAttributes(this.sessionId, { io: "in" }), + attributes: sessionAttributes(this.sessionId, { + io: "in", + ...(this.channel ? { channel: this.channel } : {}), + }), }, requestOptions ); - await apiClient.appendToSessionStream(this.sessionId, "in", body, $requestOptions); + await apiClient.appendToSessionStream(this.sessionId, "in", body, $requestOptions, this.channel); } /** @@ -634,7 +716,8 @@ export class SessionInputChannel { return sessionStreams.on( this.sessionId, "in", - handler as (data: unknown) => void | boolean | Promise + handler as (data: unknown) => void | boolean | Promise, + this.channel ); } @@ -647,7 +730,7 @@ export class SessionInputChannel { const ctx = taskContext.ctx; const runId = ctx?.run.id; - const innerPromise = sessionStreams.once(this.sessionId, "in", options); + const innerPromise = sessionStreams.once(this.sessionId, "in", options, this.channel); return new InputStreamOncePromise((resolve, reject) => { tracer @@ -662,12 +745,22 @@ export class SessionInputChannel { [SemanticInternalAttributes.STYLE_ICON]: "sessions", [SemanticInternalAttributes.ENTITY_TYPE]: "session-stream", ...(runId - ? { [SemanticInternalAttributes.ENTITY_ID]: `${runId}:${this.sessionId}:in` } + ? { + [SemanticInternalAttributes.ENTITY_ID]: `${runId}:${this.sessionId}:${ + this.channel ?? "" + }:in`, + } : {}), session: this.sessionId, io: "in", + ...(this.channel ? { channel: this.channel } : {}), ...accessoryAttributes({ - items: [{ text: `${this.sessionId}.in`, variant: "normal" }], + items: this.channel + ? [ + { text: this.channel, variant: "normal" }, + { text: "in", variant: "normal" }, + ] + : [{ text: `${this.sessionId}.in`, variant: "normal" }], style: "codepath", }), }, @@ -679,7 +772,7 @@ export class SessionInputChannel { /** Non-blocking peek at the head of the `.in` buffer. */ peek(): T | undefined { - return sessionStreams.peek(this.sessionId, "in") as T | undefined; + return sessionStreams.peek(this.sessionId, "in", this.channel) as T | undefined; } /** @@ -693,7 +786,7 @@ export class SessionInputChannel { * past already-processed user messages. */ lastDispatchedSeqNum(): number | undefined { - return sessionStreams.lastDispatchedSeqNum(this.sessionId, "in"); + return sessionStreams.lastDispatchedSeqNum(this.sessionId, "in", this.channel); } /** @@ -717,6 +810,7 @@ export class SessionInputChannel { async awaitWake( options?: InputStreamWaitOptions & { lastSeqNum?: number } ): Promise<{ ok: true; waitpointId: string } | { ok: false; error: Error }> { + this.#assertReservedChannelForWait("awaitWake()"); const ctx = taskContext.ctx; if (!ctx) { @@ -774,6 +868,7 @@ export class SessionInputChannel { wait(options?: InputStreamWaitOptions): ManualWaitpointPromise { return new ManualWaitpointPromise(async (resolve, reject) => { try { + this.#assertReservedChannelForWait("wait()"); const apiClient = apiClientManager.clientOrThrow(); const result = await tracer.startActiveSpan( @@ -843,6 +938,7 @@ export class SessionInputChannel { async waitWithIdleTimeout( options: InputStreamWaitWithIdleTimeoutOptions ): Promise<{ ok: true; output: T } | { ok: false; error?: Error }> { + this.#assertReservedChannelForWait("waitWithIdleTimeout()"); // eslint-disable-next-line no-this-alias const self = this; const spanName = From dde4f1737a19a257e35bc7059b620c6ffda8514c Mon Sep 17 00:00:00 2001 From: Eric Allam Date: Fri, 28 Aug 2026 14:11:47 +0100 Subject: [PATCH 03/25] test(core): session stream manager isolates named channels Two channels on the same (session, io) never cross buffers, and a named channel is isolated from the reserved default channel. --- .../src/v3/sessionStreams/manager.test.ts | 81 +++++++++++++++++++ 1 file changed, 81 insertions(+) diff --git a/packages/core/src/v3/sessionStreams/manager.test.ts b/packages/core/src/v3/sessionStreams/manager.test.ts index 4262674bfe3..f5e7af6ee99 100644 --- a/packages/core/src/v3/sessionStreams/manager.test.ts +++ b/packages/core/src/v3/sessionStreams/manager.test.ts @@ -70,6 +70,87 @@ function repeatingApiClient(record: { } as unknown as ApiClient; } +function channelAwareApiClient( + byChannel: Record> +): ApiClient { + const delivered = new Set(); + return { + async subscribeToSessionStream( + _sessionIdOrExternalId: string, + _io: "out" | "in", + options?: { + onPart?: (part: SSEStreamPart) => void; + signal?: AbortSignal; + channel?: string; + } + ) { + const channelKey = options?.channel ?? ""; + if (!delivered.has(channelKey)) { + delivered.add(channelKey); + for (const record of byChannel[channelKey] ?? []) { + options?.onPart?.(record as SSEStreamPart); + } + } + const signal = options?.signal; + // eslint-disable-next-line require-yield + return (async function* () { + if (signal?.aborted) return; + await new Promise((resolve) => { + signal?.addEventListener("abort", () => resolve(), { once: true }); + }); + })() as unknown as Awaited>; + }, + } as unknown as ApiClient; +} + +describe("StandardSessionStreamManager — named channels", () => { + const sessionId = "session-1"; + const io = "in" as const; + + it("routes records to the addressed channel and never across channels", async () => { + const manager = new StandardSessionStreamManager( + channelAwareApiClient({ + a: [{ id: "0", chunk: { v: "a-record" }, timestamp: 1000 }], + b: [{ id: "0", chunk: { v: "b-record" }, timestamp: 1000 }], + }), + "http://localhost" + ); + + const fromA = await manager.once(sessionId, io, { timeoutMs: 500 }, "a"); + const fromB = await manager.once(sessionId, io, { timeoutMs: 500 }, "b"); + + expect(fromA).toEqual({ ok: true, output: { v: "a-record" } }); + expect(fromB).toEqual({ ok: true, output: { v: "b-record" } }); + + manager.disconnectStream(sessionId, io, "a"); + manager.disconnectStream(sessionId, io, "b"); + manager.disconnect(); + }); + + it("keeps the reserved channel isolated from a named channel", async () => { + const manager = new StandardSessionStreamManager( + channelAwareApiClient({ + "": [{ id: "0", chunk: { v: "reserved" }, timestamp: 1000 }], + screenshots: [{ id: "0", chunk: { v: "named" }, timestamp: 1000 }], + }), + "http://localhost" + ); + + const reserved = await manager.once(sessionId, io, { timeoutMs: 500 }); + const named = await manager.once(sessionId, io, { timeoutMs: 500 }, "screenshots"); + + expect(reserved).toEqual({ ok: true, output: { v: "reserved" } }); + expect(named).toEqual({ ok: true, output: { v: "named" } }); + + expect(manager.peek(sessionId, io)).toBeUndefined(); + expect(manager.peek(sessionId, io, "screenshots")).toBeUndefined(); + + manager.disconnectStream(sessionId, io); + manager.disconnectStream(sessionId, io, "screenshots"); + manager.disconnect(); + }); +}); + describe("StandardSessionStreamManager — minTimestamp filter", () => { const sessionId = "session-1"; const io = "in" as const; From d80c49006c0ad7627678409967561a600e1723da Mon Sep 17 00:00:00 2001 From: Eric Allam Date: Fri, 28 Aug 2026 14:19:03 +0100 Subject: [PATCH 04/25] chore: changeset for session side channels --- .changeset/session-side-channels.md | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) create mode 100644 .changeset/session-side-channels.md diff --git a/.changeset/session-side-channels.md b/.changeset/session-side-channels.md new file mode 100644 index 00000000000..12b1e3958d2 --- /dev/null +++ b/.changeset/session-side-channels.md @@ -0,0 +1,17 @@ +--- +"@trigger.dev/react-hooks": patch +"@trigger.dev/core": patch +"@trigger.dev/sdk": patch +--- + +Named side channels on a Session: durable, two-way realtime streams that outlive a single run and are shared across runs. Open a channel with `session.channel(name)` to get an `.in`/`.out` pair addressed by name rather than the reserved default pair. Writing a side channel's `.in` does not wake or trigger a run, so a channel can carry out-of-band data (a stream of frames, a control signal) that many clients read while the agent produces it. + +```ts +// Producer (inside a task): stream frames on a named channel, wakes nothing +await session.channel("screenshots").out.append(frame); + +// A run observes a side channel's .in without suspending +session.channel("screenshots").in.on((data) => { /* ... */ }); +``` + +Define channel record types once and infer them on both sides with `defineSessionChannel`, and read a channel from React with `useSessionStreamChannel`. Channels get a default retention that keeps them bounded, overridable per channel. From 86cf30f75550c56e06be2d1fd6380b55faa67774 Mon Sep 17 00:00:00 2001 From: Eric Allam Date: Fri, 28 Aug 2026 14:23:58 +0100 Subject: [PATCH 05/25] chore(webapp): drop channel list-streams (unsupported on self-hosted s2-lite) --- .../realtime/s2realtimeStreams.server.ts | 59 ------------------- 1 file changed, 59 deletions(-) diff --git a/apps/webapp/app/services/realtime/s2realtimeStreams.server.ts b/apps/webapp/app/services/realtime/s2realtimeStreams.server.ts index 27c0aec11ef..9f655e09ac0 100644 --- a/apps/webapp/app/services/realtime/s2realtimeStreams.server.ts +++ b/apps/webapp/app/services/realtime/s2realtimeStreams.server.ts @@ -280,25 +280,6 @@ export class S2RealtimeStreams implements StreamResponder, StreamIngestor { return this.#readRecordsByName(this.toSessionStreamName(friendlyId, io, channel), afterSeqNum); } - /** - * List the named side channels of a session by enumerating S2 streams under - * the session's `channels/` prefix. The reserved `.in`/`.out` pair lives at - * the two-part `sessions/{id}/{io}` name (not under `channels/`) so it is - * excluded. Server-side control-plane op using the webapp's own S2 token; - * kept off the hot path. Returns distinct channel names. - */ - async listSessionChannels(friendlyId: string): Promise { - const prefix = `${this.streamPrefix}/sessions/${friendlyId}/channels/`; - const names = await this.#s2ListStreamNames(prefix); - const channels = new Set(); - for (const name of names) { - const rest = name.slice(prefix.length); - const channel = rest.split("/")[0]; - if (channel) channels.add(channel); - } - return [...channels]; - } - /** * Ensure a named side channel's stream has native S2 retention applied: the * durable bound that keeps a run-independent channel from growing without a @@ -782,46 +763,6 @@ export class S2RealtimeStreams implements StreamResponder, StreamIngestor { }); } - async #s2ListStreamNames(prefix: string): Promise { - const names: string[] = []; - let startAfter: string | undefined; - - for (let page = 0; page < 100; page++) { - const qs = new URLSearchParams(); - qs.set("prefix", prefix); - if (startAfter) qs.set("start_after", startAfter); - - const res = await fetch(`${this.baseUrl}/streams?${qs}`, { - method: "GET", - headers: { - Authorization: `Bearer ${this.token}`, - Accept: "application/json", - "S2-Basin": this.basin, - }, - }); - - if (!res.ok) { - if (res.status === 404) return names; - const text = await res.text().catch(() => ""); - throw new Error(`S2 listStreams failed: ${res.status} ${res.statusText} ${text}`); - } - - const body = (await res.json()) as { - has_more?: boolean; - streams?: Array<{ name: string; deleted_at?: string | null }>; - }; - const streams = body.streams ?? []; - for (const stream of streams) { - if (stream.deleted_at) continue; - names.push(stream.name); - } - if (!body.has_more || streams.length === 0) break; - startAfter = streams[streams.length - 1]!.name; - } - - return names; - } - async #s2CreateStreamWithConfig( stream: string, retention: { maxAgeSeconds?: number; deleteOnEmptyMinAgeSeconds?: number } From 9b7be3d20de7db76965df7ec17e4961234bcec5b Mon Sep 17 00:00:00 2001 From: Eric Allam Date: Fri, 28 Aug 2026 14:25:46 +0100 Subject: [PATCH 06/25] feat(react-hooks): add useSessionStreamChannel for named session channels Read one side of a named Session side channel from React, with record types inferred from a defineSessionChannel declaration. Builds on subscribeToSessionStream with the channel option; reuses the useSessionStream from/maxRecords/cursor-resume behavior, keyed per channel. --- .../src/hooks/useSessionStreamChannel.ts | 376 ++++++++++++++++++ packages/react-hooks/src/index.ts | 1 + 2 files changed, 377 insertions(+) create mode 100644 packages/react-hooks/src/hooks/useSessionStreamChannel.ts diff --git a/packages/react-hooks/src/hooks/useSessionStreamChannel.ts b/packages/react-hooks/src/hooks/useSessionStreamChannel.ts new file mode 100644 index 00000000000..4076a0eb936 --- /dev/null +++ b/packages/react-hooks/src/hooks/useSessionStreamChannel.ts @@ -0,0 +1,376 @@ +"use client"; + +import type { + AnySessionChannel, + ApiClient, + ControlEvent, + SessionChannelIn, + SessionChannelName, + SessionChannelOut, + SSEStreamPart, +} from "@trigger.dev/core/v3"; +import { useCallback, useEffect, useId, useRef, useState } from "react"; +import { createThrottledQueue } from "../utils/throttle.js"; +import type { KeyedMutator } from "../utils/trigger-swr.js"; +import { useSWR } from "../utils/trigger-swr.js"; +import { useStableRequestCallback } from "../utils/useStableRequestCallback.js"; +import type { UseApiClientOptions } from "./useApiClient.js"; +import { useApiClient } from "./useApiClient.js"; + +type ChannelRecord = S extends "out" + ? SessionChannelOut + : SessionChannelIn; + +export type UseSessionStreamChannelInstance = { + /** The records received so far on the channel, in arrival order. */ + records: Array; + /** The cursor of the last record seen; pass back as `lastEventId` to resume. */ + lastEventId: string | undefined; + /** The last control record seen on the channel. */ + lastControl: ControlEvent | undefined; + error: Error | undefined; + /** Abort the current request immediately, keep the records received so far. */ + stop: () => void; +}; + +export type UseSessionStreamChannelOptions< + TChannel extends AnySessionChannel, + S extends "in" | "out", +> = UseApiClientOptions & { + /** The id or external id of the session that owns the channel. */ + sessionId: string; + id?: string; + enabled?: boolean; + /** + * Which side of the channel to read. + * + * @default "out" + */ + io?: S; + /** + * The number of milliseconds to throttle the record updates. + * + * @default 16 + */ + throttleInMs?: number; + /** + * The number of seconds to wait for new data before the stream closes. + * + * @default 60 seconds + */ + timeoutInSeconds?: number; + /** The cursor to resume from. If not provided, reads per `from`. */ + lastEventId?: string | number; + /** + * Where a fresh subscription (no `lastEventId`) starts reading. + * + * - `"beginning"` (default): replay the full channel history, then live-tail. + * - `"latest"`: start at the current tail, for a last-value / live view. + * + * Ignored when `lastEventId` is set. + */ + from?: "beginning" | "latest"; + /** + * Cap the number of records kept in `records`. Use `maxRecords: 1` with + * `from: "latest"` for a bounded last-value view. + */ + maxRecords?: number; + /** Invoked once per throttled flush with the batch of records (control records included). */ + onRecords?: (records: Array>>) => void; + /** Called when a control record is received on the channel. */ + onControl?: (event: ControlEvent) => void; +}; + +/** + * Read one side of a named Session side channel, with record types inferred + * from a `defineSessionChannel` declaration passed as the type argument. + * + * The channel name is typesafe (`SessionChannelName`) and `records` + * is typed from the channel's `.out` / `.in` record type. Called without the + * type argument, the channel name is any string and `records` is `unknown`. + * + * Requires a Public Access Token scoped to the session (or to the channel). + * + * @example + * ```tsx + * import type { screenshotsChannel } from "./shared/channels"; + * + * const { records } = useSessionStreamChannel("screenshots", { + * sessionId, + * accessToken, + * io: "out", + * from: "latest", + * maxRecords: 1, + * }); + * ``` + */ +export function useSessionStreamChannel< + TChannel extends AnySessionChannel = AnySessionChannel, + S extends "in" | "out" = "out", +>( + channel: SessionChannelName, + options: UseSessionStreamChannelOptions +): UseSessionStreamChannelInstance> { + type TRecord = ChannelRecord; + + const hookId = useId(); + const idKey = options.id ?? hookId; + const io = (options.io ?? "out") as "out" | "in"; + const sessionId = options.sessionId; + const channelName = channel as string; + + const [initialRecordsFallback] = useState([] as Array); + + const { data: records, mutate: mutateRecords } = useSWR>( + [idKey, sessionId, channelName, io, "records"], + null, + { fallbackData: initialRecordsFallback } + ); + + const recordsRef = useRef>(records ?? ([] as Array)); + useEffect(() => { + recordsRef.current = records || ([] as Array); + }, [records]); + + const { data: lastEventId = undefined, mutate: setLastEventId } = useSWR( + [idKey, sessionId, channelName, io, "lastEventId"], + null + ); + const lastEventIdRef = useRef(lastEventId); + const channelIdentityRef = useRef(`${idKey}:${sessionId}:${channelName}:${io}`); + useEffect(() => { + const identity = `${idKey}:${sessionId}:${channelName}:${io}`; + if (channelIdentityRef.current !== identity) { + channelIdentityRef.current = identity; + lastEventIdRef.current = lastEventId; + } + }, [idKey, sessionId, channelName, io, lastEventId]); + + const { data: lastControl = undefined, mutate: setLastControl } = useSWR( + [idKey, sessionId, channelName, io, "lastControl"], + null + ); + + const { data: _isComplete = false, mutate: setIsComplete } = useSWR( + [idKey, sessionId, channelName, io, "complete"], + null + ); + + const { data: error = undefined, mutate: setError } = useSWR( + [idKey, sessionId, channelName, io, "error"], + null + ); + + const abortControllerRef = useRef(null); + + const stop = useCallback(() => { + if (abortControllerRef.current) { + abortControllerRef.current.abort(); + abortControllerRef.current = null; + } + }, []); + + const onRecordsCallback = options.onRecords; + const onRecords = useCallback( + (recordsBatch: Array>) => { + if (onRecordsCallback) { + onRecordsCallback(recordsBatch); + } + }, + [onRecordsCallback] + ); + + const onControlCallback = options.onControl; + const onControl = useCallback( + (event: ControlEvent) => { + if (onControlCallback) { + onControlCallback(event); + } + }, + [onControlCallback] + ); + + const apiClient = useApiClient(options); + const timeoutInSeconds = options.timeoutInSeconds; + const startEventId = options.lastEventId; + const throttleInMs = options.throttleInMs; + const from = options.from; + const maxRecords = options.maxRecords; + + useEffect(() => { + if (maxRecords != null && maxRecords >= 0) { + const current = recordsRef.current; + if (current.length > maxRecords) { + mutateRecords(current.slice(current.length - maxRecords)); + } + } + }, [maxRecords, mutateRecords]); + + const triggerRequest = useCallback(async () => { + try { + if (!sessionId || !apiClient) { + return; + } + + const abortController = new AbortController(); + abortControllerRef.current = abortController; + + await processSessionChannelStream( + sessionId, + io, + channelName, + apiClient, + mutateRecords, + recordsRef, + setLastEventId, + setLastControl, + setError, + onRecords, + onControl, + abortControllerRef, + timeoutInSeconds, + startEventId !== undefined ? String(startEventId) : lastEventIdRef.current, + throttleInMs ?? 16, + from, + maxRecords + ); + } catch (err) { + if ((err as any).name === "AbortError") { + abortControllerRef.current = null; + return; + } + + setError(err as Error); + } finally { + if (abortControllerRef.current) { + abortControllerRef.current = null; + } + + setIsComplete(true); + } + }, [ + sessionId, + io, + channelName, + apiClient, + mutateRecords, + setLastEventId, + setLastControl, + setError, + setIsComplete, + onRecords, + onControl, + timeoutInSeconds, + startEventId, + throttleInMs, + from, + maxRecords, + ]); + const requestSubscription = useStableRequestCallback(triggerRequest); + + useEffect(() => { + if (typeof options.enabled === "boolean" && !options.enabled) { + return; + } + + if (!sessionId) { + return; + } + + requestSubscription().finally(() => {}); + + return () => { + stop(); + }; + }, [sessionId, channelName, io, stop, options.enabled, requestSubscription]); + + return { records: records ?? initialRecordsFallback, lastEventId, lastControl, error, stop }; +} + +async function processSessionChannelStream( + sessionIdOrExternalId: string, + io: "out" | "in", + channel: string, + apiClient: ApiClient, + mutateRecordsData: KeyedMutator>, + existingRecordsRef: React.MutableRefObject>, + setLastEventId: KeyedMutator, + setLastControl: KeyedMutator, + onError: (e: Error) => void, + onRecords: (records: Array>) => void, + onControl: (event: ControlEvent) => void, + abortControllerRef: React.MutableRefObject, + timeoutInSeconds?: number, + lastEventId?: string, + throttleInMs?: number, + from?: "beginning" | "latest", + maxRecords?: number +) { + let lastSeenEventId: string | undefined; + let publishedEventId: string | undefined; + let partsBatch: Array> = []; + + const publishLastEventId = () => { + if (lastSeenEventId !== publishedEventId) { + publishedEventId = lastSeenEventId; + setLastEventId(lastSeenEventId); + } + }; + + const flushParts = () => { + if (partsBatch.length === 0) return; + const batch = partsBatch; + partsBatch = []; + onRecords(batch); + }; + + try { + const stream = await apiClient.subscribeToSessionStream(sessionIdOrExternalId, io, { + signal: abortControllerRef.current?.signal, + channel, + timeoutInSeconds, + lastEventId, + from, + onPart: (part) => { + lastSeenEventId = part.id; + partsBatch.push(part); + }, + onControl: (event) => { + setLastControl(event); + onControl(event); + }, + }); + + const recordsQueue = createThrottledQueue(async (newRecords) => { + const combined = [...existingRecordsRef.current, ...newRecords]; + const bounded = + maxRecords != null && maxRecords >= 0 && combined.length > maxRecords + ? combined.slice(combined.length - maxRecords) + : combined; + existingRecordsRef.current = bounded; + mutateRecordsData(bounded); + publishLastEventId(); + flushParts(); + }, throttleInMs); + + for await (const record of stream) { + recordsQueue.add(record); + } + + await recordsQueue.flush(); + publishLastEventId(); + flushParts(); + } catch (err) { + if ((err as any).name === "AbortError") { + return; + } + + if (err instanceof Error) { + onError(err); + } else { + onError(new Error(String(err))); + } + + throw err; + } +} diff --git a/packages/react-hooks/src/index.ts b/packages/react-hooks/src/index.ts index 57aa3b16877..6f6c967409b 100644 --- a/packages/react-hooks/src/index.ts +++ b/packages/react-hooks/src/index.ts @@ -6,3 +6,4 @@ export * from "./hooks/useTaskTrigger.js"; export * from "./hooks/useWaitToken.js"; export * from "./hooks/useInputStreamSend.js"; export * from "./hooks/useSessionStream.js"; +export * from "./hooks/useSessionStreamChannel.js"; From 5b1f61bb80705976131b18baed1ae3d3f9ded189 Mon Sep 17 00:00:00 2001 From: Eric Allam Date: Fri, 28 Aug 2026 14:52:40 +0100 Subject: [PATCH 07/25] feat(sdk): chat.channel() and chat.session() for the current run's Session Ergonomic accessors so a chat.agent run can open a named side channel on its own Session without threading the session id: chat.channel(name) is a shortcut for chat.session().channel(name). --- packages/trigger-sdk/src/v3/ai.ts | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/packages/trigger-sdk/src/v3/ai.ts b/packages/trigger-sdk/src/v3/ai.ts index bcc70fa9ce0..0a715b9b50a 100644 --- a/packages/trigger-sdk/src/v3/ai.ts +++ b/packages/trigger-sdk/src/v3/ai.ts @@ -107,6 +107,8 @@ type ToolCallOptions = { import { readFileInSkill, runBashInSkill } from "./agentSkillsRuntime.js"; import { ensureAiSdkTelemetry } from "./aiAutoTelemetry.js"; import { + type SessionChannelHandle, + type SessionChannelOptions, type SessionHandle, type SessionPipeStreamOptions, sessions, @@ -11849,6 +11851,16 @@ export const chat = { response: chatResponse, /** Pre-built input stream for receiving messages from the transport. */ messages: messagesInput, + /** The current chat.agent run's Session handle. See {@link SessionHandle}. */ + session: getChatSession, + /** + * Open a named side channel on the current chat.agent run's Session: a + * durable, cross-run `.in`/`.out` pair addressed by `name`, separate from the + * chat transcript. Writing its `.in` does not wake a run. Shortcut for + * `chat.session().channel(name)`. + */ + channel: (name: string, options?: SessionChannelOptions): SessionChannelHandle => + getChatSession().channel(name, options), /** Create a managed stop signal wired to the stop input stream. See {@link createStopSignal}. */ createStopSignal, /** Signal the frontend that the current turn is complete. See {@link chatWriteTurnComplete}. */ From 436e45537dd994b50befc83519243fcba3a4f0ed Mon Sep 17 00:00:00 2001 From: Eric Allam Date: Fri, 28 Aug 2026 15:07:03 +0100 Subject: [PATCH 08/25] refactor(sdk,core): sessions.defineChannel + typed channel handles Move the channel-definition helper off @trigger.dev/core's public surface onto the sessions namespace as sessions.defineChannel (the channel types stay in core, so react-hooks can infer them). Make the channel classes generic on their record type so session.channel(def) / chat.channel(def) type .out.append and .in on/once/peek/send from the definition; a bare name string still works, typed unknown. --- .changeset/session-side-channels.md | 13 ++-- .../core/src/v3/sessionStreams/channels.ts | 14 ---- packages/trigger-sdk/src/v3/ai.ts | 12 +++- packages/trigger-sdk/src/v3/sessions.ts | 67 ++++++++++++++----- 4 files changed, 65 insertions(+), 41 deletions(-) diff --git a/.changeset/session-side-channels.md b/.changeset/session-side-channels.md index 12b1e3958d2..846319ee871 100644 --- a/.changeset/session-side-channels.md +++ b/.changeset/session-side-channels.md @@ -4,14 +4,13 @@ "@trigger.dev/sdk": patch --- -Named side channels on a Session: durable, two-way realtime streams that outlive a single run and are shared across runs. Open a channel with `session.channel(name)` to get an `.in`/`.out` pair addressed by name rather than the reserved default pair. Writing a side channel's `.in` does not wake or trigger a run, so a channel can carry out-of-band data (a stream of frames, a control signal) that many clients read while the agent produces it. +Named side channels on a Session: durable, two-way realtime streams that outlive a single run and are shared across runs. Open a channel with `sessions.open(id).channel(name)` (or `chat.channel(name)` inside a `chat.agent`) to get an `.in`/`.out` pair addressed by name rather than the reserved default pair. Writing a side channel's `.in` does not wake or trigger a run, so a channel can carry out-of-band data (a stream of frames, a control signal) that many clients read while the agent produces it. ```ts -// Producer (inside a task): stream frames on a named channel, wakes nothing -await session.channel("screenshots").out.append(frame); - -// A run observes a side channel's .in without suspending -session.channel("screenshots").in.on((data) => { /* ... */ }); +// Inside a chat.agent: stream frames on a named channel, wakes nothing +const frames = chat.channel("screenshots"); +await frames.out.append(frame); +frames.in.on((control) => { /* client control, no suspend */ }); ``` -Define channel record types once and infer them on both sides with `defineSessionChannel`, and read a channel from React with `useSessionStreamChannel`. Channels get a default retention that keeps them bounded, overridable per channel. +Declare channel record types once with `sessions.defineChannel(...)` and infer them on both the producer and the consumer, including `useSessionStreamChannel` in React. Channels get a default retention that keeps them bounded, overridable per channel. diff --git a/packages/core/src/v3/sessionStreams/channels.ts b/packages/core/src/v3/sessionStreams/channels.ts index 3707c95cbe7..2fd2f62736d 100644 --- a/packages/core/src/v3/sessionStreams/channels.ts +++ b/packages/core/src/v3/sessionStreams/channels.ts @@ -34,17 +34,3 @@ export type SessionChannelIn = C extends SessionCha ? I : unknown : never; - -/** - * Declare a named Session channel with typed `.in` / `.out` records, inferred - * on both the producer (`chat.agent`) and consumer (client hook) sides. - * Mirrors the `Task` + `defineSessionChannel` ergonomics: the - * `const TName` capture preserves the channel name as a string literal so it - * flows through `SessionChannelName`, exactly like a task id. - */ -export function defineSessionChannel< - TShape extends SessionChannelShape = SessionChannelShape, - const TName extends string = string, ->(name: TName): SessionChannel { - return { name }; -} diff --git a/packages/trigger-sdk/src/v3/ai.ts b/packages/trigger-sdk/src/v3/ai.ts index 0a715b9b50a..7ef71c875db 100644 --- a/packages/trigger-sdk/src/v3/ai.ts +++ b/packages/trigger-sdk/src/v3/ai.ts @@ -107,13 +107,17 @@ type ToolCallOptions = { import { readFileInSkill, runBashInSkill } from "./agentSkillsRuntime.js"; import { ensureAiSdkTelemetry } from "./aiAutoTelemetry.js"; import { - type SessionChannelHandle, + type SessionChannelHandleFor, type SessionChannelOptions, type SessionHandle, type SessionPipeStreamOptions, sessions, type SessionSubscribeOptions, } from "./sessions.js"; +import type { + AnySessionChannel, + SessionChannelName, +} from "@trigger.dev/core/v3"; import { createTask } from "./shared.js"; import { markChatAgentRunForStreamsWarning } from "./streams.js"; import { tracer } from "./tracer.js"; @@ -11859,8 +11863,10 @@ export const chat = { * chat transcript. Writing its `.in` does not wake a run. Shortcut for * `chat.session().channel(name)`. */ - channel: (name: string, options?: SessionChannelOptions): SessionChannelHandle => - getChatSession().channel(name, options), + channel: ( + channel: SessionChannelName | C, + options?: SessionChannelOptions + ): SessionChannelHandleFor => getChatSession().channel(channel, options), /** Create a managed stop signal wired to the stop input stream. See {@link createStopSignal}. */ createStopSignal, /** Signal the frontend that the current turn is complete. See {@link chatWriteTurnComplete}. */ diff --git a/packages/trigger-sdk/src/v3/sessions.ts b/packages/trigger-sdk/src/v3/sessions.ts index 836b33b0abe..e948014a951 100644 --- a/packages/trigger-sdk/src/v3/sessions.ts +++ b/packages/trigger-sdk/src/v3/sessions.ts @@ -21,6 +21,12 @@ import type { UpdateSessionRequestBody, WriterStreamOptions, CursorPagePromise, + AnySessionChannel, + SessionChannel, + SessionChannelIn, + SessionChannelName, + SessionChannelOut, + SessionChannelShape, } from "@trigger.dev/core/v3"; import { InputStreamOncePromise, @@ -58,6 +64,7 @@ export const sessions = { close: closeSession, list: listSessions, open, + defineChannel, }; // Test hook: lets `@trigger.dev/sdk/ai/test` replace `sessions.open()` with @@ -256,11 +263,18 @@ export class SessionHandle { /** * Open a named side channel on this session: a durable, cross-run `.in`/`.out` * pair addressed by `name` rather than the reserved default pair. Writing a - * side channel's `.in` does not wake or trigger a run — a run observes it via + * side channel's `.in` does not wake or trigger a run; a run observes it via * `.in.on()` / `.in.once()`. Records outlive any single run and are bounded by * the channel's retention (a sensible default, overridable via `options`). + * + * Pass a `sessions.defineChannel(...)` definition to type `.in`/`.out` records; + * a bare name string works too, with records typed `unknown`. */ - channel(name: string, options?: SessionChannelOptions): SessionChannelHandle { + channel( + channel: SessionChannelName | C, + options?: SessionChannelOptions + ): SessionChannelHandleFor { + const name = typeof channel === "string" ? channel : channel.name; if (!SESSION_CHANNEL_NAME_REGEX.test(name)) { throw new Error( `Invalid session channel name "${name}": use 1-128 chars from [A-Za-z0-9._-].` @@ -270,18 +284,37 @@ export class SessionHandle { name, out: new SessionOutputChannel(this.id, name, options?.retention), in: new SessionInputChannel(this.id, name), - }; + } as SessionChannelHandleFor; } } -export type SessionChannelHandle = { +export type SessionChannelHandleFor = { readonly name: string; - readonly out: SessionOutputChannel; - readonly in: SessionInputChannel; + readonly out: SessionOutputChannel>; + readonly in: SessionInputChannel>; }; +export type SessionChannelHandle = SessionChannelHandleFor; + const SESSION_CHANNEL_NAME_REGEX = /^[A-Za-z0-9._-]{1,128}$/; +/** + * Declare a named Session channel with typed `.in` / `.out` records, inferred + * on both the producer and the consumer. The channel analogue of a task + * definition: pass the result to `session.channel(...)` / `chat.channel(...)` + * and to `useSessionStreamChannel` so the record types line up + * on every side. + */ +function defineChannel< + TShape extends SessionChannelShape = SessionChannelShape, + const TName extends string = string, +>(name: TName): SessionChannel { + if (!SESSION_CHANNEL_NAME_REGEX.test(name)) { + throw new Error(`Invalid session channel name "${name}": use 1-128 chars from [A-Za-z0-9._-].`); + } + return { name }; +} + /** * Options accepted by {@link SessionOutputChannel.pipe}. Session-scoped, * so it omits the `target` field (self/parent/root/runId) that run-scoped @@ -312,7 +345,7 @@ export type SessionChannelOptions = { * consume via SSE. S2 credentials for direct writes are fetched * internally by `pipe`/`writer` — there's no public `initialize()`. */ -export class SessionOutputChannel { +export class SessionOutputChannel { // Cache of the in-flight / resolved `initializeSessionStream` PUT for // this channel. Every `pipe()` / `writer()` call needs the same S2 // credentials, so we share a single promise instead of re-PUTing on @@ -348,8 +381,8 @@ export class SessionOutputChannel { * which would give SSE consumers a JSON-string instead of an object. * Mirrors how `streams.define.append` delegates to `streams.writer`. */ - async append(value: T, options?: SessionPipeStreamOptions): Promise { - const { waitUntilComplete } = this.writer({ + async append(value: TOut, options?: SessionPipeStreamOptions): Promise { + const { waitUntilComplete } = this.writer({ ...options, spanName: "sessions.append()", execute: ({ write }) => { @@ -365,7 +398,7 @@ export class SessionOutputChannel { * {@link SessionStreamInstance}. Parallel to {@link streams.pipe} but * session-scoped — no `target` option because the session is the target. */ - pipe( + pipe( value: AsyncIterable | ReadableStream, options?: SessionPipeStreamOptions ): PipeStreamResult { @@ -379,7 +412,7 @@ export class SessionOutputChannel { * stream and await completion. Span is collapsible via `options.spanName` * / `options.collapsed`. */ - writer(options: WriterStreamOptions): PipeStreamResult { + writer(options: WriterStreamOptions): PipeStreamResult { let controller!: ReadableStreamDefaultController; const ongoingStreamPromises: Promise[] = []; @@ -455,7 +488,7 @@ export class SessionOutputChannel { * shared {@link SSEStreamSubscription} plumbing used by run-scoped * realtime streams. */ - async read(options?: SessionSubscribeOptions): Promise> { + async read(options?: SessionSubscribeOptions): Promise> { const apiClient = apiClientManager.clientOrThrow(); return apiClient.subscribeToSessionStream(this.sessionId, "out", { @@ -658,7 +691,7 @@ export class SessionOutputChannel { * external clients. Keyed on the session rather than the run so a * conversation can survive across run boundaries. */ -export class SessionInputChannel { +export class SessionInputChannel { constructor( public readonly sessionId: string, public readonly channel?: string @@ -678,7 +711,7 @@ export class SessionInputChannel { * Matches {@link streams.input.send} but session-scoped — the session * is the address, no `runId` required. */ - async send(value: unknown, requestOptions?: ApiRequestOptions): Promise { + async send(value: TIn, requestOptions?: ApiRequestOptions): Promise { const apiClient = apiClientManager.clientOrThrow(); const body = typeof value === "string" ? value : JSON.stringify(value); @@ -712,7 +745,7 @@ export class SessionInputChannel { * won't be buffered for a later `once()` and won't be re-delivered on a * future `on()` attach. Plain observers should return nothing. */ - on(handler: (data: T) => void | boolean | Promise): { off: () => void } { + on(handler: (data: T) => void | boolean | Promise): { off: () => void } { return sessionStreams.on( this.sessionId, "in", @@ -726,7 +759,7 @@ export class SessionInputChannel { * Returns `{ ok: true, output }` on arrival or `{ ok: false, error }` * when the timeout fires. Chain `.unwrap()` to get the data directly. */ - once(options?: InputStreamOnceOptions): InputStreamOncePromise { + once(options?: InputStreamOnceOptions): InputStreamOncePromise { const ctx = taskContext.ctx; const runId = ctx?.run.id; @@ -771,7 +804,7 @@ export class SessionInputChannel { } /** Non-blocking peek at the head of the `.in` buffer. */ - peek(): T | undefined { + peek(): T | undefined { return sessionStreams.peek(this.sessionId, "in", this.channel) as T | undefined; } From 225edfe9fd2f590b63d3a14154be38f57dce8833 Mon Sep 17 00:00:00 2001 From: Eric Allam Date: Fri, 28 Aug 2026 15:34:22 +0100 Subject: [PATCH 09/25] fix(react-hooks,sdk): review + CI fixes for session channels - useSessionStreamChannel: only clear the abort-controller ref when it still points at the current request, so a finishing older request can't strand a newer subscription (CodeRabbit). - Drop the unenforced keepLastN retention option until producer-side trim lands. - Update the initializeSessionStream call-arity assertions in sessions.test.ts. - Formatting. --- ...s.$session.channels.$channel.$io.append.ts | 13 +++++++++++-- ...sessions.$session.channels.$channel.$io.ts | 4 +--- .../core/src/v3/sessionStreams/channels.ts | 19 ++++++------------- .../src/hooks/useSessionStreamChannel.ts | 13 ++++++------- packages/trigger-sdk/src/v3/ai.ts | 5 +---- packages/trigger-sdk/src/v3/sessions.test.ts | 6 +++--- packages/trigger-sdk/src/v3/sessions.ts | 12 ++++++++---- 7 files changed, 36 insertions(+), 36 deletions(-) diff --git a/apps/webapp/app/routes/realtime.v1.sessions.$session.channels.$channel.$io.append.ts b/apps/webapp/app/routes/realtime.v1.sessions.$session.channels.$channel.$io.append.ts index 48281efb047..7372c644713 100644 --- a/apps/webapp/app/routes/realtime.v1.sessions.$session.channels.$channel.$io.append.ts +++ b/apps/webapp/app/routes/realtime.v1.sessions.$session.channels.$channel.$io.append.ts @@ -100,7 +100,13 @@ const { action, loader } = createActionApiRoute( let appendSeq: number | undefined; if (wonClaim) { const [appendError, seq] = await tryCatch( - realtimeStream.appendPartToSessionStream(part, partId, addressingKey, params.io, params.channel) + realtimeStream.appendPartToSessionStream( + part, + partId, + addressingKey, + params.io, + params.channel + ) ); appendSeq = seq ?? undefined; @@ -125,7 +131,10 @@ const { action, loader } = createActionApiRoute( channel: params.channel, error: appendError, }); - return json({ ok: false, error: "Something went wrong, please try again." }, { status: 500 }); + return json( + { ok: false, error: "Something went wrong, please try again." }, + { status: 500 } + ); } } diff --git a/apps/webapp/app/routes/realtime.v1.sessions.$session.channels.$channel.$io.ts b/apps/webapp/app/routes/realtime.v1.sessions.$session.channels.$channel.$io.ts index b1de21d2c90..9ac0d5bcc2d 100644 --- a/apps/webapp/app/routes/realtime.v1.sessions.$session.channels.$channel.$io.ts +++ b/apps/webapp/app/routes/realtime.v1.sessions.$session.channels.$channel.$io.ts @@ -78,9 +78,7 @@ const { action } = createActionApiRoute( const addressingKey = canonicalSessionAddressingKey(maybeSession, params.session); - const maxAgeSeconds = parsePositiveIntHeader( - request.headers.get("x-channel-max-age-seconds") - ); + const maxAgeSeconds = parsePositiveIntHeader(request.headers.get("x-channel-max-age-seconds")); const deleteOnEmptyMinAgeSeconds = parsePositiveIntHeader( request.headers.get("x-channel-delete-on-empty-seconds") ); diff --git a/packages/core/src/v3/sessionStreams/channels.ts b/packages/core/src/v3/sessionStreams/channels.ts index 2fd2f62736d..85916cd7975 100644 --- a/packages/core/src/v3/sessionStreams/channels.ts +++ b/packages/core/src/v3/sessionStreams/channels.ts @@ -17,20 +17,13 @@ export type SessionChannel< export type AnySessionChannel = SessionChannel; /** Extract a channel's literal name, the analogue of `TaskIdentifier`. */ -export type SessionChannelName = C extends SessionChannel - ? N - : never; +export type SessionChannelName = + C extends SessionChannel ? N : never; /** Extract the `.out` record type, the analogue of `TaskOutput`. */ -export type SessionChannelOut = C extends SessionChannel - ? S extends { out: infer O } - ? O - : unknown - : never; +export type SessionChannelOut = + C extends SessionChannel ? (S extends { out: infer O } ? O : unknown) : never; /** Extract the `.in` record type, the analogue of `TaskPayload`. */ -export type SessionChannelIn = C extends SessionChannel - ? S extends { in: infer I } - ? I - : unknown - : never; +export type SessionChannelIn = + C extends SessionChannel ? (S extends { in: infer I } ? I : unknown) : never; diff --git a/packages/react-hooks/src/hooks/useSessionStreamChannel.ts b/packages/react-hooks/src/hooks/useSessionStreamChannel.ts index 4076a0eb936..a862d2e89bc 100644 --- a/packages/react-hooks/src/hooks/useSessionStreamChannel.ts +++ b/packages/react-hooks/src/hooks/useSessionStreamChannel.ts @@ -146,10 +146,9 @@ export function useSessionStreamChannel< } }, [idKey, sessionId, channelName, io, lastEventId]); - const { data: lastControl = undefined, mutate: setLastControl } = useSWR( - [idKey, sessionId, channelName, io, "lastControl"], - null - ); + const { data: lastControl = undefined, mutate: setLastControl } = useSWR< + undefined | ControlEvent + >([idKey, sessionId, channelName, io, "lastControl"], null); const { data: _isComplete = false, mutate: setIsComplete } = useSWR( [idKey, sessionId, channelName, io, "complete"], @@ -207,12 +206,13 @@ export function useSessionStreamChannel< }, [maxRecords, mutateRecords]); const triggerRequest = useCallback(async () => { + let abortController: AbortController | null = null; try { if (!sessionId || !apiClient) { return; } - const abortController = new AbortController(); + abortController = new AbortController(); abortControllerRef.current = abortController; await processSessionChannelStream( @@ -236,13 +236,12 @@ export function useSessionStreamChannel< ); } catch (err) { if ((err as any).name === "AbortError") { - abortControllerRef.current = null; return; } setError(err as Error); } finally { - if (abortControllerRef.current) { + if (abortControllerRef.current === abortController) { abortControllerRef.current = null; } diff --git a/packages/trigger-sdk/src/v3/ai.ts b/packages/trigger-sdk/src/v3/ai.ts index 7ef71c875db..37a0f4b4d6e 100644 --- a/packages/trigger-sdk/src/v3/ai.ts +++ b/packages/trigger-sdk/src/v3/ai.ts @@ -114,10 +114,7 @@ import { sessions, type SessionSubscribeOptions, } from "./sessions.js"; -import type { - AnySessionChannel, - SessionChannelName, -} from "@trigger.dev/core/v3"; +import type { AnySessionChannel, SessionChannelName } from "@trigger.dev/core/v3"; import { createTask } from "./shared.js"; import { markChatAgentRunForStreamsWarning } from "./streams.js"; import { tracer } from "./tracer.js"; diff --git a/packages/trigger-sdk/src/v3/sessions.test.ts b/packages/trigger-sdk/src/v3/sessions.test.ts index abeccb0c12d..7acc63e86eb 100644 --- a/packages/trigger-sdk/src/v3/sessions.test.ts +++ b/packages/trigger-sdk/src/v3/sessions.test.ts @@ -96,7 +96,7 @@ describe("SessionOutputChannel initializeSessionStream cache", () => { await Promise.all([p1.waitUntilComplete(), p2.waitUntilComplete(), p3.waitUntilComplete()]); expect(spy).toHaveBeenCalledTimes(1); - expect(spy).toHaveBeenCalledWith("session-1", "out", undefined); + expect(spy).toHaveBeenCalledWith("session-1", "out", undefined, undefined, undefined); }); it("evicts on initialize failure so the next call retries instead of returning a poisoned entry", async () => { @@ -150,8 +150,8 @@ describe("SessionOutputChannel initializeSessionStream cache", () => { ]); expect(spy).toHaveBeenCalledTimes(2); - expect(spy).toHaveBeenCalledWith("session-a", "out", undefined); - expect(spy).toHaveBeenCalledWith("session-b", "out", undefined); + expect(spy).toHaveBeenCalledWith("session-a", "out", undefined, undefined, undefined); + expect(spy).toHaveBeenCalledWith("session-b", "out", undefined, undefined, undefined); }); it("evicts the cache when a writer's wait() rejects (simulated stale-token failure)", async () => { diff --git a/packages/trigger-sdk/src/v3/sessions.ts b/packages/trigger-sdk/src/v3/sessions.ts index e948014a951..cee275c8f1f 100644 --- a/packages/trigger-sdk/src/v3/sessions.ts +++ b/packages/trigger-sdk/src/v3/sessions.ts @@ -325,13 +325,11 @@ export type SessionPipeStreamOptions = Omit; /** * Retention for a named side channel. `maxAgeSeconds` and * `deleteOnEmptyMinAgeSeconds` are applied server-side as native S2 per-stream - * config on first initialize; `keepLastN` is a producer-side trim floor the - * writer enforces by appending an S2 trim command as records accumulate. + * config on first initialize. */ export type SessionChannelRetention = { maxAgeSeconds?: number; deleteOnEmptyMinAgeSeconds?: number; - keepLastN?: number; }; export type SessionChannelOptions = { @@ -732,7 +730,13 @@ export class SessionInputChannel { requestOptions ); - await apiClient.appendToSessionStream(this.sessionId, "in", body, $requestOptions, this.channel); + await apiClient.appendToSessionStream( + this.sessionId, + "in", + body, + $requestOptions, + this.channel + ); } /** From 2f77568c4d66f47221d6983406061f81c00ab6a9 Mon Sep 17 00:00:00 2001 From: Eric Allam Date: Fri, 28 Aug 2026 15:39:05 +0100 Subject: [PATCH 10/25] fix(react-hooks): make useSessionStreamChannel sessionId optional for parity Matches useSessionStream, which accepts an undefined id so the hook can render before the session id resolves. The subscription already gates on a set id. --- packages/react-hooks/src/hooks/useSessionStreamChannel.ts | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/packages/react-hooks/src/hooks/useSessionStreamChannel.ts b/packages/react-hooks/src/hooks/useSessionStreamChannel.ts index a862d2e89bc..5258a0d5777 100644 --- a/packages/react-hooks/src/hooks/useSessionStreamChannel.ts +++ b/packages/react-hooks/src/hooks/useSessionStreamChannel.ts @@ -37,8 +37,11 @@ export type UseSessionStreamChannelOptions< TChannel extends AnySessionChannel, S extends "in" | "out", > = UseApiClientOptions & { - /** The id or external id of the session that owns the channel. */ - sessionId: string; + /** + * The id or external id of the session that owns the channel. May be + * undefined while it resolves; the subscription starts once it is set. + */ + sessionId?: string; id?: string; enabled?: boolean; /** From 5705e515fed501832e37b2e631323efb8466cb97 Mon Sep 17 00:00:00 2001 From: Eric Allam Date: Fri, 28 Aug 2026 15:43:33 +0100 Subject: [PATCH 11/25] docs(ai-chat): document session side channels Add a Side channels concept page (define, produce on .out, observe .in without waking a run, read in React with useSessionStreamChannel, retention, the 1 MiB pointer note, auth) and a Named side channels section on the useSessionStream page. Register the new page in the nav. --- docs/ai-chat/side-channels.mdx | 125 +++++++++++++++++++ docs/docs.json | 1 + docs/realtime/react-hooks/session-stream.mdx | 17 +++ 3 files changed, 143 insertions(+) create mode 100644 docs/ai-chat/side-channels.mdx diff --git a/docs/ai-chat/side-channels.mdx b/docs/ai-chat/side-channels.mdx new file mode 100644 index 00000000000..f34decc1068 --- /dev/null +++ b/docs/ai-chat/side-channels.mdx @@ -0,0 +1,125 @@ +--- +title: "Side channels" +sidebarTitle: "Side channels" +description: "Named, durable stream pairs on a Session, separate from the chat transcript. A side channel outlives a single run, is shared across runs, and its input does not wake a run." +--- + +**A side channel is a named `.in`/`.out` stream pair on a [Session](/ai-chat/sessions), separate from the reserved chat transcript.** Like the transcript it is durable and cross-run, but it is addressed by a name, and writing its `.in` does not wake or trigger a run. + +Use one when an agent needs to stream out-of-band data alongside the conversation: a feed of browser screenshots, progress telemetry, or a control channel the client writes to. The transcript stays clean, and many clients can read the side channel live while the agent produces it. + +```mermaid +flowchart LR + A["chat.agent run"] -- "frames" --> OUT([channel .out]) + OUT --> C[Browser clients] + C -- "control (pause, viewport)" --> IN([channel .in]) + IN -. "observed, no run wake" .-> A +``` + +## Define the channel once + +Declare the channel's record types in one shared module with `sessions.defineChannel`, then import it on both the producer and the consumer so the types line up. + +```ts /trigger/channels.ts +import { sessions } from "@trigger.dev/sdk"; + +export type ScreenshotFrame = { url: string; step: number }; +export type ViewportControl = { paused: boolean }; + +export const screenshots = sessions.defineChannel<{ + out: ScreenshotFrame; + in: ViewportControl; +}>("screenshots"); +``` + +## Produce on `.out` from a chat.agent + +Inside a `chat.agent` run, `chat.channel(...)` opens a channel on the current run's Session. Writing `.out` is durable and cross-run, and wakes nothing. The client control arrives on `.in.on(...)` without waking a run: + +```ts /trigger/browser-agent.ts +import { chat } from "@trigger.dev/sdk/ai"; +import { streamText } from "ai"; +import { screenshots } from "./channels"; + +export const browserAgent = chat.agent({ + id: "browser-agent", + run: async ({ messages, signal }) => { + const frames = chat.channel(screenshots); + + frames.in.on((control) => setPaused(control.paused)); // control: ViewportControl + + driveBrowser({ + signal, + onFrame: (frame) => frames.out.append(frame), // frame: ScreenshotFrame + }); + + return streamText({ model, messages, abortSignal: signal }); // transcript, as usual + }, +}); +``` + +Outside a `chat.agent` run, open the channel from a session handle instead: `sessions.open(sessionId).channel(screenshots)`. The handle exposes the same `.out` (`append` / `pipe` / `writer`) and `.in` (`send` / `on` / `once` / `peek`) surface as the reserved pair. + + + A side channel's `.in` is subscribe-only from the run's side (`.on` / `.once` / `.peek`). `.wait()` + is not supported on a named channel, because a side channel never suspends or wakes a run. + + +## Read `.out` in React + +`useSessionStreamChannel` reads one side of a channel and updates a `records` array. Pass the channel definition as the type argument so `records` is typed from it. `from: "latest"` with `maxRecords: 1` gives a live "latest frame" view with bounded memory: + +```tsx app/components/Screencast.tsx +"use client"; + +import { useSessionStreamChannel } from "@trigger.dev/react-hooks"; +import type { screenshots } from "../trigger/channels"; + +export function Screencast({ sessionId, accessToken }: { sessionId: string; accessToken: string }) { + const { records } = useSessionStreamChannel("screenshots", { + sessionId, + accessToken, + io: "out", + from: "latest", + maxRecords: 1, + }); + + const latest = records[0]; // ScreenshotFrame | undefined + return latest ? {`frame :

Waiting…

; +} +``` + +`useSessionStreamChannel` has the same options and return shape as [`useSessionStream`](/realtime/react-hooks/session-stream) (`io`, `from`, `maxRecords`, `lastEventId`, `onRecords`, `onControl`, `throttleInMs`, `timeoutInSeconds`), plus the typed channel generic. A bare name string works without the generic, with `records` typed `unknown`. + +The client writes the `.in` control with a session handle: `sessions.open(sessionId).channel(screenshots).in.send({ paused: true })`. This appends to the channel and does not wake a run. + +## Retention + +A side channel has no chat turn loop trimming it, so each channel gets a default native retention (bounded age plus delete-when-empty) applied on first use. Override it per channel: + +```ts +chat.channel(screenshots, { + retention: { maxAgeSeconds: 60 * 60, deleteOnEmptyMinAgeSeconds: 5 * 60 }, +}); +``` + + + Records are capped at ~1 MiB each. Stream a pointer, not bytes: write large payloads (a screenshot + PNG) to object storage and put the URL on the channel. A base64 image inflates ~33% and will exceed + the cap. Pointers also keep the channel small and cheap to keep-last. + + +## Auth + +A side channel is covered by the session's public access token: a token scoped to `read:sessions:{id}` / `write:sessions:{id}` grants every channel of that session. Mint a narrower token scoped to a single channel with `read:sessions:{id}:channels:{name}`. Writing a channel's `.out` requires secret-key auth (only the agent run), so a browser cannot forge frames; `.in` is writable with the session token. See [Realtime auth](/realtime/auth). + +## Next steps + + + + The durable, cross-run primitive side channels are built on. + + + The `useSessionStream` hook `useSessionStreamChannel` mirrors. + + diff --git a/docs/docs.json b/docs/docs.json index 669bc1ebc9d..d7524a9664b 100644 --- a/docs/docs.json +++ b/docs/docs.json @@ -97,6 +97,7 @@ "ai-chat/frontend", "ai-chat/server-chat", "ai-chat/sessions", + "ai-chat/side-channels", "ai-chat/chat-local", "ai-chat/types", "ai-chat/custom-agents", diff --git a/docs/realtime/react-hooks/session-stream.mdx b/docs/realtime/react-hooks/session-stream.mdx index 1d08f5f1300..c60e872d2f3 100644 --- a/docs/realtime/react-hooks/session-stream.mdx +++ b/docs/realtime/react-hooks/session-stream.mdx @@ -107,3 +107,20 @@ const { records, lastControl } = useSessionStream(sessionId, { ``` For an expiring token on a long-lived subscription, pass `refreshAccessToken` (see [Realtime auth](/realtime/auth)). To read a session channel outside React, use [`session.out.read()`](/ai-chat/sessions). + +## Named side channels + +`useSessionStream` reads a session's reserved channel. To read a [named side channel](/ai-chat/side-channels) — a durable, cross-run stream separate from the chat transcript — use `useSessionStreamChannel`. It takes the channel name as its first argument and has the same options and return shape, plus a channel-definition type argument that types `records`: + +```tsx +import { useSessionStreamChannel } from "@trigger.dev/react-hooks"; +import type { screenshots } from "../trigger/channels"; + +const { records } = useSessionStreamChannel("screenshots", { + sessionId, + accessToken, + io: "out", + from: "latest", + maxRecords: 1, +}); +``` From b8dd764a90d5e2bf35eeffa8e1ce94fa45730666 Mon Sep 17 00:00:00 2001 From: Eric Allam Date: Fri, 28 Aug 2026 15:52:38 +0100 Subject: [PATCH 12/25] docs(ai-chat): cover non-agent channel usage (task + backend) Make the non-chat.agent path first-class: channels are a Session primitive, so document producing on a channel from a task-bound Session run and from a backend holding the secret key, not just from chat.channel(). --- docs/ai-chat/side-channels.mdx | 35 +++++++++++++++++++++++++++++++--- 1 file changed, 32 insertions(+), 3 deletions(-) diff --git a/docs/ai-chat/side-channels.mdx b/docs/ai-chat/side-channels.mdx index f34decc1068..14e3cd89645 100644 --- a/docs/ai-chat/side-channels.mdx +++ b/docs/ai-chat/side-channels.mdx @@ -6,7 +6,7 @@ description: "Named, durable stream pairs on a Session, separate from the chat t **A side channel is a named `.in`/`.out` stream pair on a [Session](/ai-chat/sessions), separate from the reserved chat transcript.** Like the transcript it is durable and cross-run, but it is addressed by a name, and writing its `.in` does not wake or trigger a run. -Use one when an agent needs to stream out-of-band data alongside the conversation: a feed of browser screenshots, progress telemetry, or a control channel the client writes to. The transcript stays clean, and many clients can read the side channel live while the agent produces it. +Side channels are a Session primitive, not a chat feature. Any Session can carry them: a `chat.agent`, a task-bound Session, or an external process holding your secret key. Use one to stream out-of-band data alongside (or instead of) a transcript: a feed of browser screenshots, progress telemetry, or a control channel the client writes to. Many clients can read the channel live while a run, or your backend, produces it. ```mermaid flowchart LR @@ -58,13 +58,42 @@ export const browserAgent = chat.agent({ }); ``` -Outside a `chat.agent` run, open the channel from a session handle instead: `sessions.open(sessionId).channel(screenshots)`. The handle exposes the same `.out` (`append` / `pipe` / `writer`) and `.in` (`send` / `on` / `once` / `peek`) surface as the reserved pair. - A side channel's `.in` is subscribe-only from the run's side (`.on` / `.once` / `.peek`). `.wait()` is not supported on a named channel, because a side channel never suspends or wakes a run. +## From a task or your backend + +Nothing here needs a `chat.agent`. Open a channel on any Session by id with `sessions.open(sessionId).channel(...)`; the handle exposes the same `.out` (`append` / `pipe` / `writer`) and `.in` (`send` / `on` / `once` / `peek`) surface as the reserved pair. Create the Session with [`sessions.start`](/ai-chat/sessions) bound to any task, then produce from that task's run: + +```ts /trigger/render-frames.ts +import { sessions, task } from "@trigger.dev/sdk"; +import { screenshots } from "./channels"; + +export const renderFrames = task({ + id: "render-frames", + run: async (payload: { sessionId: string; steps: number }) => { + const frames = sessions.open(payload.sessionId).channel(screenshots); + for (let step = 1; step <= payload.steps; step++) { + frames.in.on((control) => setPaused(control.paused)); + await frames.out.append({ url: await renderStep(step), step }); + } + }, +}); +``` + +Or produce from your own backend, which holds the secret key that `.out` writes require: + +```ts Your backend code +import { sessions } from "@trigger.dev/sdk"; +import { screenshots } from "./trigger/channels"; + +await sessions.open(sessionId).channel(screenshots).out.append({ url, step }); +``` + +Either way the client reads the channel the same way, below. + ## Read `.out` in React `useSessionStreamChannel` reads one side of a channel and updates a `records` array. Pass the channel definition as the type argument so `records` is typed from it. `from: "latest"` with `maxRecords: 1` gives a live "latest frame" view with bounded memory: From 85912a399dc51f5e7f79079a30f3c91cd48475c8 Mon Sep 17 00:00:00 2001 From: Eric Allam Date: Fri, 28 Aug 2026 16:03:36 +0100 Subject: [PATCH 13/25] chore(core): format subscribeToSessionStream channel URL line --- packages/core/src/v3/apiClient/index.ts | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/packages/core/src/v3/apiClient/index.ts b/packages/core/src/v3/apiClient/index.ts index 471a8161650..d72608289a6 100644 --- a/packages/core/src/v3/apiClient/index.ts +++ b/packages/core/src/v3/apiClient/index.ts @@ -1396,9 +1396,7 @@ export class ApiClient { // `createStream` so downstream clients can feed them into // `StreamsWriterV2`. const base = `${this.baseUrl}/realtime/v1/sessions/${encodeURIComponent(sessionIdOrExternalId)}`; - const url = channel - ? `${base}/channels/${encodeURIComponent(channel)}/${io}` - : `${base}/${io}`; + const url = channel ? `${base}/channels/${encodeURIComponent(channel)}/${io}` : `${base}/${io}`; const retentionHeaders: Record = {}; if (channel && retention?.maxAgeSeconds != null) { retentionHeaders["x-channel-max-age-seconds"] = String(retention.maxAgeSeconds); From d66a43ba605e930cd324f81e1278fb2cf2d4c71d Mon Sep 17 00:00:00 2001 From: Eric Allam Date: Fri, 28 Aug 2026 16:06:18 +0100 Subject: [PATCH 14/25] chore: satisfy code-quality (oxlint no-duplicates, knip) Merge the channel-type import into the existing @trigger.dev/core/v3 import in ai.ts, and drop the unused isValidSessionChannelName helper (routes validate via the exported regex directly). --- apps/webapp/app/services/realtime/sessionChannels.server.ts | 6 +----- packages/trigger-sdk/src/v3/ai.ts | 3 ++- 2 files changed, 3 insertions(+), 6 deletions(-) diff --git a/apps/webapp/app/services/realtime/sessionChannels.server.ts b/apps/webapp/app/services/realtime/sessionChannels.server.ts index fbd576271d8..4c444be6643 100644 --- a/apps/webapp/app/services/realtime/sessionChannels.server.ts +++ b/apps/webapp/app/services/realtime/sessionChannels.server.ts @@ -2,16 +2,12 @@ import type { RbacResource } from "@trigger.dev/rbac"; /** * Channel names are both a URL path segment and an S2 stream-name segment, and - * they fold into the RBAC resource id as `${key}:channels:${channel}` — so a + * they fold into the RBAC resource id as `${key}:channels:${channel}`, so a * `/` would break addressing and a `:` would break scope parsing. Constrain to * a safe, bounded alphabet. */ export const SESSION_CHANNEL_NAME_REGEX = /^[A-Za-z0-9._-]{1,128}$/; -export function isValidSessionChannelName(channel: string): boolean { - return SESSION_CHANNEL_NAME_REGEX.test(channel); -} - /** * Build the authorization resource set for a named channel. For each candidate * session key (URL form, friendlyId, externalId) we authorize BOTH the diff --git a/packages/trigger-sdk/src/v3/ai.ts b/packages/trigger-sdk/src/v3/ai.ts index 37a0f4b4d6e..be619a07cd1 100644 --- a/packages/trigger-sdk/src/v3/ai.ts +++ b/packages/trigger-sdk/src/v3/ai.ts @@ -41,6 +41,8 @@ import { type RouterCheckpoint, type SessionRouteTable, type SessionStreamRecord, + type AnySessionChannel, + type SessionChannelName, } from "@trigger.dev/core/v3"; import type { FinishReason, @@ -114,7 +116,6 @@ import { sessions, type SessionSubscribeOptions, } from "./sessions.js"; -import type { AnySessionChannel, SessionChannelName } from "@trigger.dev/core/v3"; import { createTask } from "./shared.js"; import { markChatAgentRunForStreamsWarning } from "./streams.js"; import { tracer } from "./tracer.js"; From 3ba8508c37f6806a0560c2048ab625dd6306e667 Mon Sep 17 00:00:00 2001 From: Eric Allam Date: Fri, 28 Aug 2026 21:06:04 +0100 Subject: [PATCH 15/25] feat(webapp): render session channel streams in the span inspector Clicking a session channel span now renders the channel's live records (reusing the realtime stream viewer) instead of generic properties, and session spans show the session icon. Covers named channels and the reserved transcript pair. --- .../webapp/app/components/runs/v3/RunIcon.tsx | 3 + .../app/presenters/v3/SpanPresenter.server.ts | 43 ++++++++ ...ssions.$sessionId.channels.$channel.$io.ts | 98 +++++++++++++++++++ .../route.tsx | 13 +++ 4 files changed, 157 insertions(+) create mode 100644 apps/webapp/app/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.runs.$runParam.realtime.v1.sessions.$sessionId.channels.$channel.$io.ts diff --git a/apps/webapp/app/components/runs/v3/RunIcon.tsx b/apps/webapp/app/components/runs/v3/RunIcon.tsx index dc6b144691a..86d9065a602 100644 --- a/apps/webapp/app/components/runs/v3/RunIcon.tsx +++ b/apps/webapp/app/components/runs/v3/RunIcon.tsx @@ -36,6 +36,7 @@ import { PythonLogoIcon } from "~/assets/icons/PythonLogoIcon"; import { TraceIcon } from "~/assets/icons/TraceIcon"; import { WaitpointTokenIcon } from "~/assets/icons/WaitpointTokenIcon"; import { StreamsIcon } from "~/assets/icons/StreamsIcon"; +import { AIChatIcon } from "~/assets/icons/AIChatIcon"; type TaskIconProps = { name: string | undefined; @@ -169,6 +170,8 @@ export function RunIcon({ name, className, spanName }: TaskIconProps) { className={cn(className, "text-text-dimmed group-hover/spannode:text-text-bright")} /> ); + case "sessions": + return ; case "hero-sparkles": return ( | undefined) + : undefined; + + return { + ...data, + entity: { + type: "session-stream" as const, + object: { + runId: runFriendlyId, + sessionId, + channel: channel.length > 0 ? channel : undefined, + io, + metadata, + }, + }, + }; + } case "prompt": { const promptData = extractPromptSpanData(span.properties as Record); diff --git a/apps/webapp/app/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.runs.$runParam.realtime.v1.sessions.$sessionId.channels.$channel.$io.ts b/apps/webapp/app/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.runs.$runParam.realtime.v1.sessions.$sessionId.channels.$channel.$io.ts new file mode 100644 index 00000000000..ef9da6f94b0 --- /dev/null +++ b/apps/webapp/app/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.runs.$runParam.realtime.v1.sessions.$sessionId.channels.$channel.$io.ts @@ -0,0 +1,98 @@ +import { type LoaderFunctionArgs } from "@remix-run/server-runtime"; +import { z } from "zod"; +import { $replica, prisma } from "~/db.server"; +import { runStore } from "~/v3/runStore.server"; +import { findProjectBySlug } from "~/models/project.server"; +import { findEnvironmentBySlug } from "~/models/runtimeEnvironment.server"; +import { getRequestAbortSignal } from "~/services/httpAsyncStorage.server"; +import { S2RealtimeStreams } from "~/services/realtime/s2realtimeStreams.server"; +import { SESSION_CHANNEL_NAME_REGEX } from "~/services/realtime/sessionChannels.server"; +import { + canonicalSessionAddressingKey, + resolveSessionWithWriterFallback, +} from "~/services/realtime/sessions.server"; +import { getRealtimeStreamInstance } from "~/services/realtime/v1StreamsGlobal.server"; +import { requireUserId } from "~/services/session.server"; +import { EnvironmentParamSchema } from "~/utils/pathBuilder"; + +const ParamsSchema = z.object({ + runParam: z.string(), + sessionId: z.string(), + channel: z.string().regex(SESSION_CHANNEL_NAME_REGEX), + io: z.enum(["out", "in"]), +}); + +export async function loader({ request, params }: LoaderFunctionArgs) { + const userId = await requireUserId(request); + const { organizationSlug, projectParam, envParam } = EnvironmentParamSchema.parse(params); + const { runParam, sessionId, channel, io } = ParamsSchema.parse(params); + + const project = await findProjectBySlug(organizationSlug, projectParam, userId); + if (!project) { + return new Response("Project not found", { status: 404 }); + } + + const environment = await findEnvironmentBySlug(project.id, envParam, userId); + if (!environment) { + return new Response("Environment not found", { status: 404 }); + } + + const runWhere = { + friendlyId: runParam, + runtimeEnvironmentId: environment.id, + }; + const runArgs = { + select: { id: true, friendlyId: true }, + }; + const run = + (await runStore.findRun(runWhere, runArgs, $replica)) ?? + (await runStore.findRunOnPrimary(runWhere, runArgs)); + + if (!run) { + return new Response("Run not found", { status: 404 }); + } + + const session = await resolveSessionWithWriterFallback(environment.id, sessionId); + + if (!session) { + return new Response("Session not found", { status: 404 }); + } + + const linkWhere = { runId: run.id, sessionId: session.id }; + const linkedSessionRun = + (await $replica.sessionRun.findFirst({ where: linkWhere, select: { id: true } })) ?? + (await prisma.sessionRun.findFirst({ where: linkWhere, select: { id: true } })); + + if (!linkedSessionRun) { + return new Response("Session not found for run", { status: 404 }); + } + + const realtimeStream = getRealtimeStreamInstance(environment, "v2", { session }); + + if (!(realtimeStream instanceof S2RealtimeStreams)) { + return new Response("Session channels require the S2 realtime backend", { + status: 501, + }); + } + + const lastEventId = request.headers.get("Last-Event-ID") || undefined; + const timeoutInSecondsRaw = request.headers.get("Timeout-Seconds"); + let timeoutInSeconds: number | undefined; + if (timeoutInSecondsRaw !== null) { + timeoutInSeconds = Number(timeoutInSecondsRaw); + if (!Number.isInteger(timeoutInSeconds) || timeoutInSeconds < 1 || timeoutInSeconds > 600) { + return new Response("Invalid timeout", { status: 400 }); + } + } + + const addressingKey = canonicalSessionAddressingKey(session, sessionId); + + return realtimeStream.streamResponseFromSessionStream( + request, + addressingKey, + io, + getRequestAbortSignal(), + { lastEventId, timeoutInSeconds }, + channel + ); +} diff --git a/apps/webapp/app/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.runs.$runParam.spans.$spanParam/route.tsx b/apps/webapp/app/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.runs.$runParam.spans.$spanParam/route.tsx index 1a59963656b..3fb61ea6d96 100644 --- a/apps/webapp/app/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.runs.$runParam.spans.$spanParam/route.tsx +++ b/apps/webapp/app/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.runs.$runParam.spans.$spanParam/route.tsx @@ -1803,6 +1803,19 @@ function SpanEntity({ span }: { span: Span }) { /> ); } + case "session-stream": { + const { runId, sessionId, channel, io } = span.entity.object; + const base = `/resources/orgs/${organization.slug}/projects/${project.slug}/env/${environment.slug}/runs/${runId}/realtime/v1/sessions/${sessionId}`; + const resourcePath = channel ? `${base}/channels/${channel}/${io}` : `${base}/${io}`; + const displayName = channel ? `${channel}.${io}` : `${sessionId}.${io}`; + return ( + + ); + } case "ai-generation": case "ai-summary": { return ( From b18ca54840dc76157b64976bdd8731f0cc7c3893 Mon Sep 17 00:00:00 2001 From: Eric Allam Date: Fri, 28 Aug 2026 21:49:08 +0100 Subject: [PATCH 16/25] fix(webapp): apply native retention to a channel's .in stream The .in side is only ever created via the append route, never PUT-initialized, so it never received the bounded-age plus delete-on-empty retention channels advertise. Ensure it best-effort on append (cached per stream). --- ...s.$session.channels.$channel.$io.append.ts | 23 ++++++++++++++++++- 1 file changed, 22 insertions(+), 1 deletion(-) diff --git a/apps/webapp/app/routes/realtime.v1.sessions.$session.channels.$channel.$io.append.ts b/apps/webapp/app/routes/realtime.v1.sessions.$session.channels.$channel.$io.append.ts index 7372c644713..15f9960963a 100644 --- a/apps/webapp/app/routes/realtime.v1.sessions.$session.channels.$channel.$io.append.ts +++ b/apps/webapp/app/routes/realtime.v1.sessions.$session.channels.$channel.$io.append.ts @@ -3,7 +3,10 @@ import { tryCatch } from "@trigger.dev/core/utils"; import { nanoid } from "nanoid"; import { z } from "zod"; import { logger } from "~/services/logger.server"; -import { S2RealtimeStreams } from "~/services/realtime/s2realtimeStreams.server"; +import { + DEFAULT_SESSION_CHANNEL_RETENTION, + S2RealtimeStreams, +} from "~/services/realtime/s2realtimeStreams.server"; import { SESSION_CHANNEL_NAME_REGEX, sessionChannelResources, @@ -81,6 +84,24 @@ const { action, loader } = createActionApiRoute( } const addressingKey = canonicalSessionAddressingKey(session, params.session); + + const [retentionError] = await tryCatch( + realtimeStream.ensureSessionChannelRetention( + addressingKey, + params.io, + params.channel, + DEFAULT_SESSION_CHANNEL_RETENTION + ) + ); + if (retentionError) { + logger.warn("Failed to ensure session channel retention", { + addressingKey, + channel: params.channel, + io: params.io, + error: retentionError, + }); + } + const claimKey = `${addressingKey}:channels:${params.channel}`; const part = await request.text(); From f993d0061e1bb08f0292be91b657e61140acc278 Mon Sep 17 00:00:00 2001 From: Eric Allam Date: Fri, 28 Aug 2026 22:28:13 +0100 Subject: [PATCH 17/25] refactor(sdk,core,webapp): channels inherit basin retention Channel streams are created on demand on first write and inherit the org basin's retention (bounded age plus delete-on-empty from its default stream config), the same as the reserved chat streams. Drop the per-stream create-with-config / reconfigure calls, the per-process cache, and the channel retention option on the SDK, which added control-plane latency to the write path for no gain. --- ...s.$session.channels.$channel.$io.append.ts | 23 +---- ...sessions.$session.channels.$channel.$io.ts | 41 +-------- .../realtime/s2realtimeStreams.server.ts | 90 ------------------- docs/ai-chat/side-channels.mdx | 10 +-- packages/core/src/v3/apiClient/index.ts | 14 +-- packages/trigger-sdk/src/v3/ai.ts | 6 +- packages/trigger-sdk/src/v3/sessions.test.ts | 6 +- packages/trigger-sdk/src/v3/sessions.ts | 27 ++---- 8 files changed, 17 insertions(+), 200 deletions(-) diff --git a/apps/webapp/app/routes/realtime.v1.sessions.$session.channels.$channel.$io.append.ts b/apps/webapp/app/routes/realtime.v1.sessions.$session.channels.$channel.$io.append.ts index 15f9960963a..7372c644713 100644 --- a/apps/webapp/app/routes/realtime.v1.sessions.$session.channels.$channel.$io.append.ts +++ b/apps/webapp/app/routes/realtime.v1.sessions.$session.channels.$channel.$io.append.ts @@ -3,10 +3,7 @@ import { tryCatch } from "@trigger.dev/core/utils"; import { nanoid } from "nanoid"; import { z } from "zod"; import { logger } from "~/services/logger.server"; -import { - DEFAULT_SESSION_CHANNEL_RETENTION, - S2RealtimeStreams, -} from "~/services/realtime/s2realtimeStreams.server"; +import { S2RealtimeStreams } from "~/services/realtime/s2realtimeStreams.server"; import { SESSION_CHANNEL_NAME_REGEX, sessionChannelResources, @@ -84,24 +81,6 @@ const { action, loader } = createActionApiRoute( } const addressingKey = canonicalSessionAddressingKey(session, params.session); - - const [retentionError] = await tryCatch( - realtimeStream.ensureSessionChannelRetention( - addressingKey, - params.io, - params.channel, - DEFAULT_SESSION_CHANNEL_RETENTION - ) - ); - if (retentionError) { - logger.warn("Failed to ensure session channel retention", { - addressingKey, - channel: params.channel, - io: params.io, - error: retentionError, - }); - } - const claimKey = `${addressingKey}:channels:${params.channel}`; const part = await request.text(); diff --git a/apps/webapp/app/routes/realtime.v1.sessions.$session.channels.$channel.$io.ts b/apps/webapp/app/routes/realtime.v1.sessions.$session.channels.$channel.$io.ts index 9ac0d5bcc2d..36aa6feb16b 100644 --- a/apps/webapp/app/routes/realtime.v1.sessions.$session.channels.$channel.$io.ts +++ b/apps/webapp/app/routes/realtime.v1.sessions.$session.channels.$channel.$io.ts @@ -1,13 +1,8 @@ import { json } from "@remix-run/server-runtime"; import { STREAM_START_HEADER } from "@trigger.dev/core/v3"; -import { tryCatch } from "@trigger.dev/core/utils"; import { z } from "zod"; -import { logger } from "~/services/logger.server"; import { getRequestAbortSignal } from "~/services/httpAsyncStorage.server"; -import { - DEFAULT_SESSION_CHANNEL_RETENTION, - S2RealtimeStreams, -} from "~/services/realtime/s2realtimeStreams.server"; +import { S2RealtimeStreams } from "~/services/realtime/s2realtimeStreams.server"; import { SESSION_CHANNEL_NAME_REGEX, sessionChannelResources, @@ -30,12 +25,6 @@ const ParamsSchema = z.object({ io: z.enum(["out", "in"]), }); -function parsePositiveIntHeader(value: string | null): number | undefined { - if (value == null) return undefined; - const parsed = Number(value); - return Number.isInteger(parsed) && parsed > 0 ? parsed : undefined; -} - const { action } = createActionApiRoute( { params: ParamsSchema, @@ -47,7 +36,7 @@ const { action } = createActionApiRoute( resource: (params) => anyResource(sessionChannelResources(params.channel, [params.session])), }, }, - async ({ params, authentication, request }) => { + async ({ params, authentication }) => { if (params.io === "out" && authentication.type !== "PRIVATE") { return new Response("Initializing the out channel requires secret key authentication", { status: 403, @@ -78,32 +67,6 @@ const { action } = createActionApiRoute( const addressingKey = canonicalSessionAddressingKey(maybeSession, params.session); - const maxAgeSeconds = parsePositiveIntHeader(request.headers.get("x-channel-max-age-seconds")); - const deleteOnEmptyMinAgeSeconds = parsePositiveIntHeader( - request.headers.get("x-channel-delete-on-empty-seconds") - ); - const retention = - maxAgeSeconds != null || deleteOnEmptyMinAgeSeconds != null - ? { maxAgeSeconds, deleteOnEmptyMinAgeSeconds } - : DEFAULT_SESSION_CHANNEL_RETENTION; - - const [retentionError] = await tryCatch( - realtimeStream.ensureSessionChannelRetention( - addressingKey, - params.io, - params.channel, - retention - ) - ); - if (retentionError) { - logger.warn("Failed to ensure session channel retention", { - addressingKey, - channel: params.channel, - io: params.io, - error: retentionError, - }); - } - const { responseHeaders } = await realtimeStream.initializeSessionStream( addressingKey, params.io, diff --git a/apps/webapp/app/services/realtime/s2realtimeStreams.server.ts b/apps/webapp/app/services/realtime/s2realtimeStreams.server.ts index 9f655e09ac0..58c30ce5973 100644 --- a/apps/webapp/app/services/realtime/s2realtimeStreams.server.ts +++ b/apps/webapp/app/services/realtime/s2realtimeStreams.server.ts @@ -78,11 +78,6 @@ export type S2RealtimeStreamsOptions = { const S2_TOKEN_OPS = ["append", "create-stream", "trim"] as const; const S2_TOKEN_OPS_FINGERPRINT = [...S2_TOKEN_OPS].sort().join(","); -export const DEFAULT_SESSION_CHANNEL_RETENTION = { - maxAgeSeconds: 60 * 60 * 24, - deleteOnEmptyMinAgeSeconds: 60 * 60, -} as const; - /** * Placeholder handed back as the S2 access token when `skipAccessTokens` is set * and no token is configured (self-hosted s2-lite ignores the token entirely). @@ -123,8 +118,6 @@ export class S2RealtimeStreams implements StreamResponder, StreamIngestor { accessToken: string; }>; - readonly #retentionEnsured = new Set(); - constructor(opts: S2RealtimeStreamsOptions) { this.basin = opts.basin; this.baseUrl = @@ -280,33 +273,6 @@ export class S2RealtimeStreams implements StreamResponder, StreamIngestor { return this.#readRecordsByName(this.toSessionStreamName(friendlyId, io, channel), afterSeqNum); } - /** - * Ensure a named side channel's stream has native S2 retention applied: the - * durable bound that keeps a run-independent channel from growing without a - * turn loop trimming it. Creates the stream with the retention config (the - * common path — initialize runs before the first write), falling back to a - * reconfigure if it already exists. Idempotent and cached per stream so it - * runs at most once per channel per process; a control-plane op kept off the - * hot path. - */ - async ensureSessionChannelRetention( - friendlyId: string, - io: "out" | "in", - channel: string, - retention: { maxAgeSeconds?: number; deleteOnEmptyMinAgeSeconds?: number } - ): Promise { - if (this.skipAccessTokens) return; - - const stream = this.toSessionStreamName(friendlyId, io, channel); - if (this.#retentionEnsured.has(stream)) return; - - const created = await this.#s2CreateStreamWithConfig(stream, retention); - if (!created) { - await this.#s2ReconfigureStream(stream, retention); - } - this.#retentionEnsured.add(stream); - } - async #readRecordsByName(s2Stream: string, afterSeqNum?: number): Promise { const startSeq = afterSeqNum != null ? afterSeqNum + 1 : 0; @@ -763,62 +729,6 @@ export class S2RealtimeStreams implements StreamResponder, StreamIngestor { }); } - async #s2CreateStreamWithConfig( - stream: string, - retention: { maxAgeSeconds?: number; deleteOnEmptyMinAgeSeconds?: number } - ): Promise { - const config: Record = {}; - if (retention.maxAgeSeconds != null) { - config.retention_policy = { age: retention.maxAgeSeconds }; - } - if (retention.deleteOnEmptyMinAgeSeconds != null) { - config.delete_on_empty = { min_age_secs: retention.deleteOnEmptyMinAgeSeconds }; - } - - const res = await fetch(`${this.baseUrl}/streams`, { - method: "POST", - headers: { - Authorization: `Bearer ${this.token}`, - "Content-Type": "application/json", - "S2-Basin": this.basin, - }, - body: JSON.stringify({ stream, config }), - }); - - if (res.ok) return true; - if (res.status === 409) return false; - const text = await res.text().catch(() => ""); - throw new Error(`S2 createStream failed: ${res.status} ${res.statusText} ${text}`); - } - - async #s2ReconfigureStream( - stream: string, - retention: { maxAgeSeconds?: number; deleteOnEmptyMinAgeSeconds?: number } - ): Promise { - const config: Record = {}; - if (retention.maxAgeSeconds != null) { - config.retention_policy = { age: retention.maxAgeSeconds }; - } - if (retention.deleteOnEmptyMinAgeSeconds != null) { - config.delete_on_empty = { min_age_secs: retention.deleteOnEmptyMinAgeSeconds }; - } - if (Object.keys(config).length === 0) return; - - const res = await fetch(`${this.baseUrl}/streams/${encodeURIComponent(stream)}`, { - method: "PATCH", - headers: { - Authorization: `Bearer ${this.token}`, - "Content-Type": "application/json", - "S2-Basin": this.basin, - }, - body: JSON.stringify(config), - }); - - if (res.ok) return; - const text = await res.text().catch(() => ""); - throw new Error(`S2 reconfigureStream failed: ${res.status} ${res.statusText} ${text}`); - } - private parseLastEventId(lastEventId?: string): number | undefined { if (!lastEventId) return undefined; // tolerate formats like "1699999999999-5" (take leading digits) diff --git a/docs/ai-chat/side-channels.mdx b/docs/ai-chat/side-channels.mdx index 14e3cd89645..64b2718ad70 100644 --- a/docs/ai-chat/side-channels.mdx +++ b/docs/ai-chat/side-channels.mdx @@ -124,18 +124,12 @@ The client writes the `.in` control with a session handle: `sessions.open(sessio ## Retention -A side channel has no chat turn loop trimming it, so each channel gets a default native retention (bounded age plus delete-when-empty) applied on first use. Override it per channel: - -```ts -chat.channel(screenshots, { - retention: { maxAgeSeconds: 60 * 60, deleteOnEmptyMinAgeSeconds: 5 * 60 }, -}); -``` +A side channel's streams are bounded by the same retention as the rest of your realtime streams: streams are created on demand when first written and age out on your plan's retention window, with empty streams cleaned up automatically. A channel needs no separate setup or trimming. Records are capped at ~1 MiB each. Stream a pointer, not bytes: write large payloads (a screenshot PNG) to object storage and put the URL on the channel. A base64 image inflates ~33% and will exceed - the cap. Pointers also keep the channel small and cheap to keep-last. + the cap. Pointers also keep the channel small. ## Auth diff --git a/packages/core/src/v3/apiClient/index.ts b/packages/core/src/v3/apiClient/index.ts index d72608289a6..585aeaed6c2 100644 --- a/packages/core/src/v3/apiClient/index.ts +++ b/packages/core/src/v3/apiClient/index.ts @@ -1388,8 +1388,7 @@ export class ApiClient { sessionIdOrExternalId: string, io: "out" | "in", requestOptions?: ZodFetchOptions, - channel?: string, - retention?: { maxAgeSeconds?: number; deleteOnEmptyMinAgeSeconds?: number } + channel?: string ) { // The server returns S2 credentials in response headers alongside a tiny // JSON body with the realtime version. Follow the same shape as @@ -1397,21 +1396,12 @@ export class ApiClient { // `StreamsWriterV2`. const base = `${this.baseUrl}/realtime/v1/sessions/${encodeURIComponent(sessionIdOrExternalId)}`; const url = channel ? `${base}/channels/${encodeURIComponent(channel)}/${io}` : `${base}/${io}`; - const retentionHeaders: Record = {}; - if (channel && retention?.maxAgeSeconds != null) { - retentionHeaders["x-channel-max-age-seconds"] = String(retention.maxAgeSeconds); - } - if (channel && retention?.deleteOnEmptyMinAgeSeconds != null) { - retentionHeaders["x-channel-delete-on-empty-seconds"] = String( - retention.deleteOnEmptyMinAgeSeconds - ); - } return zodfetch( CreateStreamResponseBody, url, { method: "PUT", - headers: { ...this.#getHeaders(false), ...retentionHeaders }, + headers: this.#getHeaders(false), }, mergeRequestOptions(this.defaultRequestOptions, requestOptions) ) diff --git a/packages/trigger-sdk/src/v3/ai.ts b/packages/trigger-sdk/src/v3/ai.ts index be619a07cd1..2cfd6b22b43 100644 --- a/packages/trigger-sdk/src/v3/ai.ts +++ b/packages/trigger-sdk/src/v3/ai.ts @@ -110,7 +110,6 @@ import { readFileInSkill, runBashInSkill } from "./agentSkillsRuntime.js"; import { ensureAiSdkTelemetry } from "./aiAutoTelemetry.js"; import { type SessionChannelHandleFor, - type SessionChannelOptions, type SessionHandle, type SessionPipeStreamOptions, sessions, @@ -11862,9 +11861,8 @@ export const chat = { * `chat.session().channel(name)`. */ channel: ( - channel: SessionChannelName | C, - options?: SessionChannelOptions - ): SessionChannelHandleFor => getChatSession().channel(channel, options), + channel: SessionChannelName | C + ): SessionChannelHandleFor => getChatSession().channel(channel), /** Create a managed stop signal wired to the stop input stream. See {@link createStopSignal}. */ createStopSignal, /** Signal the frontend that the current turn is complete. See {@link chatWriteTurnComplete}. */ diff --git a/packages/trigger-sdk/src/v3/sessions.test.ts b/packages/trigger-sdk/src/v3/sessions.test.ts index 7acc63e86eb..8a2b7062d26 100644 --- a/packages/trigger-sdk/src/v3/sessions.test.ts +++ b/packages/trigger-sdk/src/v3/sessions.test.ts @@ -96,7 +96,7 @@ describe("SessionOutputChannel initializeSessionStream cache", () => { await Promise.all([p1.waitUntilComplete(), p2.waitUntilComplete(), p3.waitUntilComplete()]); expect(spy).toHaveBeenCalledTimes(1); - expect(spy).toHaveBeenCalledWith("session-1", "out", undefined, undefined, undefined); + expect(spy).toHaveBeenCalledWith("session-1", "out", undefined, undefined); }); it("evicts on initialize failure so the next call retries instead of returning a poisoned entry", async () => { @@ -150,8 +150,8 @@ describe("SessionOutputChannel initializeSessionStream cache", () => { ]); expect(spy).toHaveBeenCalledTimes(2); - expect(spy).toHaveBeenCalledWith("session-a", "out", undefined, undefined, undefined); - expect(spy).toHaveBeenCalledWith("session-b", "out", undefined, undefined, undefined); + expect(spy).toHaveBeenCalledWith("session-a", "out", undefined, undefined); + expect(spy).toHaveBeenCalledWith("session-b", "out", undefined, undefined); }); it("evicts the cache when a writer's wait() rejects (simulated stale-token failure)", async () => { diff --git a/packages/trigger-sdk/src/v3/sessions.ts b/packages/trigger-sdk/src/v3/sessions.ts index cee275c8f1f..4638dae27ca 100644 --- a/packages/trigger-sdk/src/v3/sessions.ts +++ b/packages/trigger-sdk/src/v3/sessions.ts @@ -265,14 +265,13 @@ export class SessionHandle { * pair addressed by `name` rather than the reserved default pair. Writing a * side channel's `.in` does not wake or trigger a run; a run observes it via * `.in.on()` / `.in.once()`. Records outlive any single run and are bounded by - * the channel's retention (a sensible default, overridable via `options`). + * the org's stream retention, the same as the reserved chat streams. * * Pass a `sessions.defineChannel(...)` definition to type `.in`/`.out` records; * a bare name string works too, with records typed `unknown`. */ channel( - channel: SessionChannelName | C, - options?: SessionChannelOptions + channel: SessionChannelName | C ): SessionChannelHandleFor { const name = typeof channel === "string" ? channel : channel.name; if (!SESSION_CHANNEL_NAME_REGEX.test(name)) { @@ -282,7 +281,7 @@ export class SessionHandle { } return { name, - out: new SessionOutputChannel(this.id, name, options?.retention), + out: new SessionOutputChannel(this.id, name), in: new SessionInputChannel(this.id, name), } as SessionChannelHandleFor; } @@ -322,20 +321,6 @@ function defineChannel< */ export type SessionPipeStreamOptions = Omit; -/** - * Retention for a named side channel. `maxAgeSeconds` and - * `deleteOnEmptyMinAgeSeconds` are applied server-side as native S2 per-stream - * config on first initialize. - */ -export type SessionChannelRetention = { - maxAgeSeconds?: number; - deleteOnEmptyMinAgeSeconds?: number; -}; - -export type SessionChannelOptions = { - retention?: SessionChannelRetention; -}; - /** * The `.out` side of a Session's bidirectional channel pair. Mirrors the * consume-side of {@link streams.define}: `pipe` / `writer` / `append` @@ -356,8 +341,7 @@ export class SessionOutputChannel { constructor( public readonly sessionId: string, - public readonly channel?: string, - private readonly retention?: SessionChannelRetention + public readonly channel?: string ) {} /** @@ -568,8 +552,7 @@ export class SessionOutputChannel { this.sessionId, "out", options?.requestOptions, - this.channel, - this.retention + this.channel ); this.#initPromise = fresh; // Evict on failure so the next call retries instead of returning a From c2b22468c0bff3bb21a5dadc8e85f3a26588df41 Mon Sep 17 00:00:00 2001 From: Eric Allam Date: Sat, 29 Aug 2026 09:16:41 +0100 Subject: [PATCH 18/25] feat(cli): MCP tools to read and write session channels Add read_session_channel and write_session_channel MCP tools so an MCP client can observe a session's realtime streams (a named side channel or the reserved transcript pair) and send control input to a running agent on a channel's .in without waking a run. Read is a point-in-time drain with cursor pagination; write appends one record to a channel's .in. --- packages/cli-v3/src/mcp/config.ts | 12 ++ packages/cli-v3/src/mcp/tools.ts | 4 + .../cli-v3/src/mcp/tools/sessionChannels.ts | 161 ++++++++++++++++++ 3 files changed, 177 insertions(+) create mode 100644 packages/cli-v3/src/mcp/tools/sessionChannels.ts diff --git a/packages/cli-v3/src/mcp/config.ts b/packages/cli-v3/src/mcp/config.ts index 227b0c5506e..676d9a84bfa 100644 --- a/packages/cli-v3/src/mcp/config.ts +++ b/packages/cli-v3/src/mcp/config.ts @@ -241,4 +241,16 @@ export const toolsMetadata = { description: "Close an agent chat conversation. The agent exits its loop gracefully. Without this, the agent will close on its own when its idle timeout expires.", }, + read_session_channel: { + name: "read_session_channel", + title: "Read Session Channel", + description: + "Read records from a session's realtime stream: a named side channel (pass `channel`) or the reserved chat transcript pair (omit `channel`). Returns whatever records currently exist after the optional cursor and closes, so it is a point-in-time drain, not a live subscription. Read `out` for the producer's feed (e.g. a screencast) or `in` for what clients have sent. Use the returned nextCursor as `afterEventId` to page forward.", + }, + write_session_channel: { + name: "write_session_channel", + title: "Write Session Channel", + description: + "Append one record to a named side channel's `in` stream on a session. Use this to send control input to a running agent (e.g. a pause/viewport command) without waking or triggering a run. Requires a `channel` name; the reserved transcript and the `out` side are not writable here (`out` is producer-only). Pass a JSON string as `value` for structured records.", + }, }; diff --git a/packages/cli-v3/src/mcp/tools.ts b/packages/cli-v3/src/mcp/tools.ts index 8fa9eabf6f8..080dfff774e 100644 --- a/packages/cli-v3/src/mcp/tools.ts +++ b/packages/cli-v3/src/mcp/tools.ts @@ -32,6 +32,7 @@ import { } from "./tools/prompts.js"; import { listAgentsTool } from "./tools/agents.js"; import { startAgentChatTool, sendAgentMessageTool, closeAgentChatTool } from "./tools/agentChat.js"; +import { readSessionChannelTool, writeSessionChannelTool } from "./tools/sessionChannels.js"; import { respondWithError } from "./utils.js"; /** Tool names that perform write/mutating operations. */ @@ -49,6 +50,7 @@ const WRITE_TOOLS = new Set([ startAgentChatTool.name, sendAgentMessageTool.name, closeAgentChatTool.name, + writeSessionChannelTool.name, ]); export function registerTools(context: McpContext) { @@ -90,6 +92,8 @@ export function registerTools(context: McpContext) { startAgentChatTool, sendAgentMessageTool, closeAgentChatTool, + readSessionChannelTool, + writeSessionChannelTool, getReportTool, ]; diff --git a/packages/cli-v3/src/mcp/tools/sessionChannels.ts b/packages/cli-v3/src/mcp/tools/sessionChannels.ts new file mode 100644 index 00000000000..3cd84187009 --- /dev/null +++ b/packages/cli-v3/src/mcp/tools/sessionChannels.ts @@ -0,0 +1,161 @@ +import { z } from "zod"; +import { toolsMetadata } from "../config.js"; +import { CommonProjectsInput } from "../schemas.js"; +import { respondWithError, toolHandler } from "../utils.js"; + +const SESSION_CHANNEL_NAME_REGEX = /^[A-Za-z0-9._-]{1,128}$/; + +const ReadSessionChannelInput = CommonProjectsInput.extend({ + sessionId: z + .string() + .describe("The session id (session_* friendlyId) or the externalId it was created with."), + channel: z + .string() + .describe( + "The named side channel to read. Omit to read the session's reserved chat transcript pair." + ) + .optional(), + io: z + .enum(["out", "in"]) + .describe("Which side to read: `out` (producer feed) or `in` (client input).") + .default("out"), + afterEventId: z + .string() + .describe( + "Cursor: only return records after this event id. Use the nextCursor from a prior read." + ) + .optional(), + maxRecords: z + .number() + .int() + .positive() + .max(500) + .describe("Maximum records to return (default 100).") + .default(100), +}); + +export const readSessionChannelTool = { + name: toolsMetadata.read_session_channel.name, + title: toolsMetadata.read_session_channel.title, + description: toolsMetadata.read_session_channel.description, + inputSchema: ReadSessionChannelInput.shape, + handler: toolHandler(ReadSessionChannelInput.shape, async (input, { ctx }) => { + ctx.logger?.log("calling read_session_channel", { input }); + + if (ctx.options.devOnly && input.environment !== "dev") { + return respondWithError(`This MCP server is only available for the dev environment.`); + } + + if (input.channel !== undefined && !SESSION_CHANNEL_NAME_REGEX.test(input.channel)) { + return respondWithError( + `Invalid channel name "${input.channel}": use 1-128 chars from [A-Za-z0-9._-].` + ); + } + + const projectRef = await ctx.getProjectRef({ + projectRef: input.projectRef, + cwd: input.configPath, + }); + + const apiClient = await ctx.getApiClient({ + projectRef, + environment: input.environment, + scopes: ["read:sessions"], + branch: input.branch, + }); + + const { records } = await apiClient.readSessionStreamRecords(input.sessionId, input.io, { + channel: input.channel, + afterEventId: input.afterEventId, + }); + + const limited = records.slice(0, input.maxRecords); + const hasMore = records.length > limited.length; + const nextCursor = limited.at(-1)?.seqNum; + + const label = input.channel ? `channel "${input.channel}"` : "reserved pair"; + const header = `Session ${input.sessionId} ${label} .${input.io}: ${limited.length} record${ + limited.length === 1 ? "" : "s" + }${hasMore ? ` (more available)` : ""}`; + + const lines = limited.map((record) => { + const data = typeof record.data === "string" ? record.data : JSON.stringify(record.data); + return `#${record.seqNum} ${data}`; + }); + + const footer = + nextCursor !== undefined && hasMore + ? `\n\nMore records available. Read again with afterEventId "${nextCursor}" to continue.` + : ""; + + return { + content: [ + { + type: "text", + text: [header, "", ...lines].join("\n") + footer, + }, + ], + }; + }), +}; + +const WriteSessionChannelInput = CommonProjectsInput.extend({ + sessionId: z + .string() + .describe("The session id (session_* friendlyId) or the externalId it was created with."), + channel: z.string().describe("The named side channel to write to."), + value: z + .string() + .describe( + "The record to append to the channel's `in` stream. Pass a JSON string for structured records (e.g. '{\"paused\":true}')." + ), +}); + +export const writeSessionChannelTool = { + name: toolsMetadata.write_session_channel.name, + title: toolsMetadata.write_session_channel.title, + description: toolsMetadata.write_session_channel.description, + inputSchema: WriteSessionChannelInput.shape, + handler: toolHandler(WriteSessionChannelInput.shape, async (input, { ctx }) => { + ctx.logger?.log("calling write_session_channel", { input }); + + if (ctx.options.devOnly && input.environment !== "dev") { + return respondWithError(`This MCP server is only available for the dev environment.`); + } + + if (!SESSION_CHANNEL_NAME_REGEX.test(input.channel)) { + return respondWithError( + `Invalid channel name "${input.channel}": use 1-128 chars from [A-Za-z0-9._-].` + ); + } + + const projectRef = await ctx.getProjectRef({ + projectRef: input.projectRef, + cwd: input.configPath, + }); + + const apiClient = await ctx.getApiClient({ + projectRef, + environment: input.environment, + scopes: ["write:sessions"], + branch: input.branch, + }); + + await apiClient.appendToSessionStream( + input.sessionId, + "in", + input.value, + undefined, + input.channel + ); + + return { + content: [ + { + type: "text", + text: `Wrote 1 record to session ${input.sessionId} channel "${input.channel}" .in. This does not wake or trigger a run.`, + }, + ], + }; + }), +}; From 2e66e1f5c911b324619a0d1fae507e41c785f98b Mon Sep 17 00:00:00 2001 From: Eric Allam Date: Sat, 29 Aug 2026 09:24:29 +0100 Subject: [PATCH 19/25] feat(cli): richer args for the session channel MCP tools write_session_channel now accepts an object value (not just a pre-stringified JSON string). read_session_channel takes an optional timeoutInSeconds to wait for the next record when none exist yet, a bounded tail on top of the default point-in-time read. --- packages/cli-v3/src/mcp/config.ts | 4 +- .../cli-v3/src/mcp/tools/sessionChannels.ts | 42 +++++++++++++------ 2 files changed, 31 insertions(+), 15 deletions(-) diff --git a/packages/cli-v3/src/mcp/config.ts b/packages/cli-v3/src/mcp/config.ts index 676d9a84bfa..a1e3f194b7c 100644 --- a/packages/cli-v3/src/mcp/config.ts +++ b/packages/cli-v3/src/mcp/config.ts @@ -245,12 +245,12 @@ export const toolsMetadata = { name: "read_session_channel", title: "Read Session Channel", description: - "Read records from a session's realtime stream: a named side channel (pass `channel`) or the reserved chat transcript pair (omit `channel`). Returns whatever records currently exist after the optional cursor and closes, so it is a point-in-time drain, not a live subscription. Read `out` for the producer's feed (e.g. a screencast) or `in` for what clients have sent. Use the returned nextCursor as `afterEventId` to page forward.", + "Read records from a session's realtime stream: a named side channel (pass `channel`) or the reserved chat transcript pair (omit `channel`). By default returns whatever records exist after the optional cursor and closes (a point-in-time drain). Set `timeoutInSeconds` to wait for the next record when none exist yet. Read `out` for the producer's feed (e.g. a screencast) or `in` for what clients have sent. Use the returned nextCursor as `afterEventId` to page forward.", }, write_session_channel: { name: "write_session_channel", title: "Write Session Channel", description: - "Append one record to a named side channel's `in` stream on a session. Use this to send control input to a running agent (e.g. a pause/viewport command) without waking or triggering a run. Requires a `channel` name; the reserved transcript and the `out` side are not writable here (`out` is producer-only). Pass a JSON string as `value` for structured records.", + "Append one record to a named side channel's `in` stream on a session. Use this to send control input to a running agent (e.g. a pause/viewport command) without waking or triggering a run. Requires a `channel` name; the reserved transcript and the `out` side are not writable here (`out` is producer-only). Pass `value` as an object for a structured record or a string for a raw one.", }, }; diff --git a/packages/cli-v3/src/mcp/tools/sessionChannels.ts b/packages/cli-v3/src/mcp/tools/sessionChannels.ts index 3cd84187009..7254461f74e 100644 --- a/packages/cli-v3/src/mcp/tools/sessionChannels.ts +++ b/packages/cli-v3/src/mcp/tools/sessionChannels.ts @@ -32,6 +32,15 @@ const ReadSessionChannelInput = CommonProjectsInput.extend({ .max(500) .describe("Maximum records to return (default 100).") .default(100), + timeoutInSeconds: z + .number() + .int() + .positive() + .max(60) + .describe( + "Wait up to this many seconds for at least one record when none exist yet (a bounded tail). Omit for an immediate point-in-time read." + ) + .optional(), }); export const readSessionChannelTool = { @@ -64,10 +73,21 @@ export const readSessionChannelTool = { branch: input.branch, }); - const { records } = await apiClient.readSessionStreamRecords(input.sessionId, input.io, { - channel: input.channel, - afterEventId: input.afterEventId, - }); + const drain = () => + apiClient.readSessionStreamRecords(input.sessionId, input.io, { + channel: input.channel, + afterEventId: input.afterEventId, + }); + + let { records } = await drain(); + + if (records.length === 0 && input.timeoutInSeconds !== undefined) { + const deadline = Date.now() + input.timeoutInSeconds * 1000; + while (records.length === 0 && Date.now() < deadline) { + await new Promise((resolve) => setTimeout(resolve, 750)); + ({ records } = await drain()); + } + } const limited = records.slice(0, input.maxRecords); const hasMore = records.length > limited.length; @@ -105,9 +125,9 @@ const WriteSessionChannelInput = CommonProjectsInput.extend({ .describe("The session id (session_* friendlyId) or the externalId it was created with."), channel: z.string().describe("The named side channel to write to."), value: z - .string() + .union([z.string(), z.record(z.unknown())]) .describe( - "The record to append to the channel's `in` stream. Pass a JSON string for structured records (e.g. '{\"paused\":true}')." + "The record to append to the channel's `in` stream. Pass an object for a structured record (e.g. { paused: true }) or a string for a raw record." ), }); @@ -141,13 +161,9 @@ export const writeSessionChannelTool = { branch: input.branch, }); - await apiClient.appendToSessionStream( - input.sessionId, - "in", - input.value, - undefined, - input.channel - ); + const body = typeof input.value === "string" ? input.value : JSON.stringify(input.value); + + await apiClient.appendToSessionStream(input.sessionId, "in", body, undefined, input.channel); return { content: [ From ba325a45b542dacae3e2f199d103dfe932f8671e Mon Sep 17 00:00:00 2001 From: Eric Allam Date: Sat, 29 Aug 2026 09:29:39 +0100 Subject: [PATCH 20/25] docs: document the session channel MCP tools Add read_session_channel and write_session_channel to the MCP tools reference, and a short From MCP section to the side channels guide. --- docs/ai-chat/side-channels.mdx | 4 ++++ docs/mcp-tools.mdx | 37 ++++++++++++++++++++++++++++++++++ 2 files changed, 41 insertions(+) diff --git a/docs/ai-chat/side-channels.mdx b/docs/ai-chat/side-channels.mdx index 64b2718ad70..a86675beb8b 100644 --- a/docs/ai-chat/side-channels.mdx +++ b/docs/ai-chat/side-channels.mdx @@ -122,6 +122,10 @@ export function Screencast({ sessionId, accessToken }: { sessionId: string; acce The client writes the `.in` control with a session handle: `sessions.open(sessionId).channel(screenshots).in.send({ paused: true })`. This appends to the channel and does not wake a run. +## From MCP + +An MCP client can read and write a session's channels with two [MCP tools](/mcp-tools): `read_session_channel` drains a channel's records (with an optional `timeoutInSeconds` to wait for the next one), and `write_session_channel` appends a record to a channel's `.in` to send control input to a running agent. Reading `.out` gives the producer feed (e.g. the screencast); writing `.in` does not wake a run, and `.out` stays producer-only. + ## Retention A side channel's streams are bounded by the same retention as the rest of your realtime streams: streams are created on demand when first written and age out on your plan's retention window, with empty streams cleaned up automatically. A channel needs no separate setup or trimming. diff --git a/docs/mcp-tools.mdx b/docs/mcp-tools.mdx index d35a733b091..6f95b3c0965 100644 --- a/docs/mcp-tools.mdx +++ b/docs/mcp-tools.mdx @@ -271,3 +271,40 @@ Close an agent chat conversation. The agent exits its loop gracefully. Without t The `start_agent_chat`, `send_agent_message`, and `close_agent_chat` tools are write operations and are not available in readonly mode. + +## Session Channel Tools + +Read and write a session's realtime streams: a named [side channel](/ai-chat/side-channels) or the reserved chat transcript pair. Use these to observe an agent's out-of-band output (a screencast, telemetry) or to send it control input. + +### read_session_channel + +Read records from a session's realtime stream. By default it returns the records that exist right now after an optional cursor and closes, so it is a point-in-time drain, not a live subscription. Set `timeoutInSeconds` to wait for the next record when none exist yet. + +**Parameters:** +- `sessionId` (required): the session id (`session_*`) or the externalId it was created with +- `channel` (optional): the named side channel to read. Omit to read the reserved chat transcript pair +- `io` (optional, default: `out`): which side to read, `out` (producer feed) or `in` (client input) +- `afterEventId` (optional): cursor. Only return records after this event id. Use the `nextCursor` from a prior read to page forward +- `maxRecords` (optional, default: `100`): maximum records to return +- `timeoutInSeconds` (optional): wait up to this many seconds for at least one record when none exist yet + +**Example usage:** +- `"Read the latest frames on the screencast channel for this session"` +- `"Wait for the next control message on the session's status channel"` + +### write_session_channel + +Append one record to a named side channel's `in` stream. Sends control input to a running agent (e.g. a pause command) without waking or triggering a run. The reserved transcript and a channel's `out` side are not writable here; `out` is producer-only. + +**Parameters:** +- `sessionId` (required): the session id or externalId +- `channel` (required): the named side channel to write to +- `value` (required): the record to append. Pass an object for a structured record (e.g. `{ paused: true }`) or a string for a raw one + +**Example usage:** +- `"Pause the screencast on this session"` +- `"Send { paused: true } to the viewport channel"` + + + `write_session_channel` is a write operation and is not available in readonly mode. + From ba3fbc65619d50eae8ceca7bf5d14ecea02ee0d0 Mon Sep 17 00:00:00 2001 From: Eric Allam Date: Sat, 29 Aug 2026 09:58:07 +0100 Subject: [PATCH 21/25] feat(webapp): session channels as tabs on the session detail page The session detail page lists a session's named channels (via an S2 prefix list in the loader) and shows each as a tab next to Rendered and Raw. Selecting a channel renders its records in the same table as the Raw transcript view, sourced from that channel's out and in streams. --- .../route.tsx | 110 ++++++++++++++++-- ...Param.realtime.v1.channels.$channel.$io.ts | 71 +++++++++++ .../realtime/s2realtimeStreams.server.ts | 52 +++++++++ 3 files changed, 224 insertions(+), 9 deletions(-) create mode 100644 apps/webapp/app/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.sessions.$sessionParam.realtime.v1.channels.$channel.$io.ts diff --git a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.sessions.$sessionParam/route.tsx b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.sessions.$sessionParam/route.tsx index 3f8017227d1..4dbced125ff 100644 --- a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.sessions.$sessionParam/route.tsx +++ b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.sessions.$sessionParam/route.tsx @@ -57,6 +57,14 @@ import { redirectWithErrorMessage } from "~/models/message.server"; import { findProjectBySlug } from "~/models/project.server"; import { findEnvironmentBySlug } from "~/models/runtimeEnvironment.server"; import { SessionPresenter } from "~/presenters/v3/SessionPresenter.server"; +import { tryCatch } from "@trigger.dev/core/utils"; +import { S2RealtimeStreams } from "~/services/realtime/s2realtimeStreams.server"; +import { + canonicalSessionAddressingKey, + resolveSessionByIdOrExternalId, +} from "~/services/realtime/sessions.server"; +import { getRealtimeStreamInstance } from "~/services/realtime/v1StreamsGlobal.server"; +import { logger } from "~/services/logger.server"; import { type StreamChunk, useRealtimeStream, @@ -115,11 +123,34 @@ export const loader = async ({ request, params }: LoaderFunctionArgs) => { throw new Response("Session not found", { status: 404 }); } - return typedjson({ session, loadedAt: Date.now() }); + let channels: string[] = []; + const streamSessionId = session.agentView?.sessionId; + if (streamSessionId) { + const [channelsError, listed] = await tryCatch( + (async () => { + const row = await resolveSessionByIdOrExternalId($replica, environment.id, streamSessionId); + if (!row) return [] as string[]; + const realtimeStream = getRealtimeStreamInstance(environment, "v2", { session: row }); + if (!(realtimeStream instanceof S2RealtimeStreams)) return [] as string[]; + const addressingKey = canonicalSessionAddressingKey(row, streamSessionId); + return realtimeStream.listSessionChannels(addressingKey); + })() + ); + if (channelsError) { + logger.warn("Failed to list session channels", { + sessionId: streamSessionId, + error: channelsError, + }); + } else { + channels = listed ?? []; + } + } + + return typedjson({ session, channels, loadedAt: Date.now() }); }; export default function Page() { - const { session, loadedAt } = useTypedLoaderData(); + const { session, channels, loadedAt } = useTypedLoaderData(); const organization = useOrganization(); const project = useProject(); const environment = useEnvironment(); @@ -158,7 +189,7 @@ export default function Page() { - + >["session"]; -function ConversationPane({ session }: { session: LoadedSession }) { +function ConversationPane({ session, channels }: { session: LoadedSession; channels: string[] }) { const organization = useOrganization(); const project = useProject(); const environment = useEnvironment(); const { value, replace } = useSearchParams(); const isRaw = value("raw") === "1"; + const channelParam = value("channel"); + const activeChannel = channelParam && channels.includes(channelParam) ? channelParam : undefined; const sessionId = session.agentView.sessionId; const encodedSession = encodeURIComponent(sessionId); const sessionResourceBase = `/resources/orgs/${organization.slug}/projects/${project.slug}/env/${environment.slug}/sessions/${encodedSession}/realtime/v1`; - const setView = useCallback((raw: boolean) => replace({ raw: raw ? "1" : undefined }), [replace]); + const setView = useCallback( + (raw: boolean) => replace({ raw: raw ? "1" : undefined, channel: undefined }), + [replace] + ); + const selectChannel = useCallback( + (channel: string) => replace({ channel, raw: undefined }), + [replace] + ); + + const utilityBarProps = { + channels, + activeChannel, + onSelectChannel: selectChannel, + }; + + if (activeChannel) { + const channelBase = `${sessionResourceBase}/channels/${encodeURIComponent(activeChannel)}`; + return ( +
+ +
+ ); + } return (
@@ -198,10 +260,11 @@ function ConversationPane({ session }: { session: LoadedSession }) { outResourcePath={`${sessionResourceBase}/out`} isRaw={isRaw} onChangeView={setView} + {...utilityBarProps} /> ) : ( <> - +
@@ -214,29 +277,45 @@ function ConversationPane({ session }: { session: LoadedSession }) { function ConversationUtilityBar({ isRaw, onChangeView, + channels = [], + activeChannel, + onSelectChannel, right, }: { isRaw: boolean; onChangeView: (raw: boolean) => void; + channels?: string[]; + activeChannel?: string; + onSelectChannel?: (channel: string) => void; right?: React.ReactNode; }) { return (
onChangeView(false)} > Rendered onChangeView(true)} > Raw + {channels.map((channel) => ( + onSelectChannel?.(channel)} + > + {channel} + + ))} {right}
@@ -266,11 +345,17 @@ function RawConversationView({ outResourcePath, isRaw, onChangeView, + channels, + activeChannel, + onSelectChannel, }: { inResourcePath: string; outResourcePath: string; isRaw: boolean; onChangeView: (raw: boolean) => void; + channels?: string[]; + activeChannel?: string; + onSelectChannel?: (channel: string) => void; }) { const { chunks: inChunks, @@ -496,7 +581,14 @@ function RawConversationView({ return ( <> - +
600) { + return new Response("Invalid timeout", { status: 400 }); + } + } + + const addressingKey = canonicalSessionAddressingKey(session, sessionParam); + + return realtimeStream.streamResponseFromSessionStream( + request, + addressingKey, + io, + getRequestAbortSignal(), + { lastEventId, timeoutInSeconds }, + channel + ); +} diff --git a/apps/webapp/app/services/realtime/s2realtimeStreams.server.ts b/apps/webapp/app/services/realtime/s2realtimeStreams.server.ts index 58c30ce5973..86acc15e80b 100644 --- a/apps/webapp/app/services/realtime/s2realtimeStreams.server.ts +++ b/apps/webapp/app/services/realtime/s2realtimeStreams.server.ts @@ -273,6 +273,58 @@ export class S2RealtimeStreams implements StreamResponder, StreamIngestor { return this.#readRecordsByName(this.toSessionStreamName(friendlyId, io, channel), afterSeqNum); } + async listSessionChannels(friendlyId: string): Promise { + const prefix = `${this.streamPrefix}/sessions/${friendlyId}/channels/`; + const names = await this.#s2ListStreamNames(prefix); + const channels = new Set(); + for (const name of names) { + const rest = name.slice(prefix.length); + const channel = rest.split("/")[0]; + if (channel) channels.add(channel); + } + return [...channels]; + } + + async #s2ListStreamNames(prefix: string): Promise { + const names: string[] = []; + let startAfter: string | undefined; + + for (let page = 0; page < 100; page++) { + const qs = new URLSearchParams(); + qs.set("prefix", prefix); + if (startAfter) qs.set("start_after", startAfter); + + const res = await fetch(`${this.baseUrl}/streams?${qs}`, { + method: "GET", + headers: { + Authorization: `Bearer ${this.token}`, + Accept: "application/json", + "S2-Basin": this.basin, + }, + }); + + if (!res.ok) { + if (res.status === 404) return names; + const text = await res.text().catch(() => ""); + throw new Error(`S2 listStreams failed: ${res.status} ${res.statusText} ${text}`); + } + + const body = (await res.json()) as { + has_more?: boolean; + streams?: Array<{ name: string; deleted_at?: string | null }>; + }; + const streams = body.streams ?? []; + for (const stream of streams) { + if (stream.deleted_at) continue; + names.push(stream.name); + } + if (!body.has_more || streams.length === 0) break; + startAfter = streams[streams.length - 1]!.name; + } + + return names; + } + async #readRecordsByName(s2Stream: string, afterSeqNum?: number): Promise { const startSeq = afterSeqNum != null ? afterSeqNum + 1 : 0; From 80a701be99fcd2b671870b300ae911681305ad16 Mon Sep 17 00:00:00 2001 From: Eric Allam Date: Sat, 29 Aug 2026 10:16:04 +0100 Subject: [PATCH 22/25] fix(webapp): harden session channel routes from review Reject channel initialize on an expired session (matching the append route), sanitize a client-forged webhook action source on channel .in appends, resolve the channel records route replica-first with a writer fallback so a fresh session isn't a spurious 404, and URL-encode the session id and channel in the span inspector's channel stream path. --- ....v1.sessions.$session.channels.$channel.$io.append.ts | 6 +++++- ...v1.sessions.$session.channels.$channel.$io.records.ts | 9 ++------- ...ealtime.v1.sessions.$session.channels.$channel.$io.ts | 4 ++++ .../route.tsx | 6 ++++-- 4 files changed, 15 insertions(+), 10 deletions(-) diff --git a/apps/webapp/app/routes/realtime.v1.sessions.$session.channels.$channel.$io.append.ts b/apps/webapp/app/routes/realtime.v1.sessions.$session.channels.$channel.$io.append.ts index 7372c644713..6cb0acd813b 100644 --- a/apps/webapp/app/routes/realtime.v1.sessions.$session.channels.$channel.$io.append.ts +++ b/apps/webapp/app/routes/realtime.v1.sessions.$session.channels.$channel.$io.append.ts @@ -4,6 +4,7 @@ import { nanoid } from "nanoid"; import { z } from "zod"; import { logger } from "~/services/logger.server"; import { S2RealtimeStreams } from "~/services/realtime/s2realtimeStreams.server"; +import { stripClientWebhookActionSource } from "~/services/realtime/sanitizeSessionInput.server"; import { SESSION_CHANNEL_NAME_REGEX, sessionChannelResources, @@ -83,7 +84,10 @@ const { action, loader } = createActionApiRoute( const addressingKey = canonicalSessionAddressingKey(session, params.session); const claimKey = `${addressingKey}:channels:${params.channel}`; - const part = await request.text(); + let part = await request.text(); + if (params.io === "in") { + part = stripClientWebhookActionSource(part); + } const clientPartId = request.headers.get("X-Part-Id"); const partId = clientPartId ?? nanoid(7); diff --git a/apps/webapp/app/routes/realtime.v1.sessions.$session.channels.$channel.$io.records.ts b/apps/webapp/app/routes/realtime.v1.sessions.$session.channels.$channel.$io.records.ts index 15bbde33b15..148de201145 100644 --- a/apps/webapp/app/routes/realtime.v1.sessions.$session.channels.$channel.$io.records.ts +++ b/apps/webapp/app/routes/realtime.v1.sessions.$session.channels.$channel.$io.records.ts @@ -1,6 +1,5 @@ import { json } from "@remix-run/server-runtime"; import { z } from "zod"; -import { $replica } from "~/db.server"; import { S2RealtimeStreams } from "~/services/realtime/s2realtimeStreams.server"; import { SESSION_CHANNEL_NAME_REGEX, @@ -9,7 +8,7 @@ import { import { canonicalSessionAddressingKey, isSessionFriendlyIdForm, - resolveSessionByIdOrExternalId, + resolveSessionWithWriterFallback, } from "~/services/realtime/sessions.server"; import { getRealtimeStreamInstance } from "~/services/realtime/v1StreamsGlobal.server"; import { anyResource, createLoaderApiRoute } from "~/services/routeBuilders/apiBuilder.server"; @@ -31,11 +30,7 @@ export const loader = createLoaderApiRoute( allowJWT: true, corsStrategy: "all", findResource: async (params, auth) => { - const row = await resolveSessionByIdOrExternalId( - $replica, - auth.environment.id, - params.session - ); + const row = await resolveSessionWithWriterFallback(auth.environment.id, params.session); if (!row && isSessionFriendlyIdForm(params.session)) { return undefined; } diff --git a/apps/webapp/app/routes/realtime.v1.sessions.$session.channels.$channel.$io.ts b/apps/webapp/app/routes/realtime.v1.sessions.$session.channels.$channel.$io.ts index 36aa6feb16b..0a740020a60 100644 --- a/apps/webapp/app/routes/realtime.v1.sessions.$session.channels.$channel.$io.ts +++ b/apps/webapp/app/routes/realtime.v1.sessions.$session.channels.$channel.$io.ts @@ -56,6 +56,10 @@ const { action } = createActionApiRoute( return new Response("Cannot initialize a channel on a closed session", { status: 400 }); } + if (maybeSession?.expiresAt && maybeSession.expiresAt.getTime() < Date.now()) { + return new Response("Cannot initialize a channel on an expired session", { status: 400 }); + } + const realtimeStream = getRealtimeStreamInstance(authentication.environment, "v2", { session: maybeSession, organization: maybeSession ? null : authentication.environment.organization, diff --git a/apps/webapp/app/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.runs.$runParam.spans.$spanParam/route.tsx b/apps/webapp/app/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.runs.$runParam.spans.$spanParam/route.tsx index 3fb61ea6d96..0d357c6086a 100644 --- a/apps/webapp/app/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.runs.$runParam.spans.$spanParam/route.tsx +++ b/apps/webapp/app/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.runs.$runParam.spans.$spanParam/route.tsx @@ -1805,8 +1805,10 @@ function SpanEntity({ span }: { span: Span }) { } case "session-stream": { const { runId, sessionId, channel, io } = span.entity.object; - const base = `/resources/orgs/${organization.slug}/projects/${project.slug}/env/${environment.slug}/runs/${runId}/realtime/v1/sessions/${sessionId}`; - const resourcePath = channel ? `${base}/channels/${channel}/${io}` : `${base}/${io}`; + const base = `/resources/orgs/${organization.slug}/projects/${project.slug}/env/${environment.slug}/runs/${runId}/realtime/v1/sessions/${encodeURIComponent(sessionId)}`; + const resourcePath = channel + ? `${base}/channels/${encodeURIComponent(channel)}/${io}` + : `${base}/${io}`; const displayName = channel ? `${channel}.${io}` : `${sessionId}.${io}`; return ( Date: Sat, 29 Aug 2026 13:13:48 +0100 Subject: [PATCH 23/25] fix(webapp): render channel spans via the session-scoped route Point the span inspector's channel viewer at the session-scoped channel route instead of the run-scoped one. The run-scoped route required a SessionRun link, which chat.agent sessions have but sessions.open() from an ordinary task does not, so ordinary-task channel spans 404'd. The session-scoped route authorizes by session-in-env (the access a dashboard user already has), so it works for every session. Drops the now-unused run-scoped channel route. --- .../app/presenters/v3/SpanPresenter.server.ts | 4 - ...ssions.$sessionId.channels.$channel.$io.ts | 98 ------------------- .../route.tsx | 4 +- 3 files changed, 2 insertions(+), 104 deletions(-) delete mode 100644 apps/webapp/app/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.runs.$runParam.realtime.v1.sessions.$sessionId.channels.$channel.$io.ts diff --git a/apps/webapp/app/presenters/v3/SpanPresenter.server.ts b/apps/webapp/app/presenters/v3/SpanPresenter.server.ts index d30a1cfdb81..d17271bdde6 100644 --- a/apps/webapp/app/presenters/v3/SpanPresenter.server.ts +++ b/apps/webapp/app/presenters/v3/SpanPresenter.server.ts @@ -213,7 +213,6 @@ export class SpanPresenter extends BasePresenter { const span = await this.#getSpan({ eventStore, spanId, - runFriendlyId, traceId, environmentId: parentRun.runtimeEnvironmentId, projectId: parentRun.projectId, @@ -641,7 +640,6 @@ export class SpanPresenter extends BasePresenter { eventRepository, traceId, spanId, - runFriendlyId, environmentId, projectId, createdAt, @@ -650,7 +648,6 @@ export class SpanPresenter extends BasePresenter { eventRepository: IEventRepository; traceId: string; spanId: string; - runFriendlyId: string; environmentId: string; projectId: string; eventStore: TaskEventStoreTable; @@ -874,7 +871,6 @@ export class SpanPresenter extends BasePresenter { entity: { type: "session-stream" as const, object: { - runId: runFriendlyId, sessionId, channel: channel.length > 0 ? channel : undefined, io, diff --git a/apps/webapp/app/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.runs.$runParam.realtime.v1.sessions.$sessionId.channels.$channel.$io.ts b/apps/webapp/app/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.runs.$runParam.realtime.v1.sessions.$sessionId.channels.$channel.$io.ts deleted file mode 100644 index ef9da6f94b0..00000000000 --- a/apps/webapp/app/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.runs.$runParam.realtime.v1.sessions.$sessionId.channels.$channel.$io.ts +++ /dev/null @@ -1,98 +0,0 @@ -import { type LoaderFunctionArgs } from "@remix-run/server-runtime"; -import { z } from "zod"; -import { $replica, prisma } from "~/db.server"; -import { runStore } from "~/v3/runStore.server"; -import { findProjectBySlug } from "~/models/project.server"; -import { findEnvironmentBySlug } from "~/models/runtimeEnvironment.server"; -import { getRequestAbortSignal } from "~/services/httpAsyncStorage.server"; -import { S2RealtimeStreams } from "~/services/realtime/s2realtimeStreams.server"; -import { SESSION_CHANNEL_NAME_REGEX } from "~/services/realtime/sessionChannels.server"; -import { - canonicalSessionAddressingKey, - resolveSessionWithWriterFallback, -} from "~/services/realtime/sessions.server"; -import { getRealtimeStreamInstance } from "~/services/realtime/v1StreamsGlobal.server"; -import { requireUserId } from "~/services/session.server"; -import { EnvironmentParamSchema } from "~/utils/pathBuilder"; - -const ParamsSchema = z.object({ - runParam: z.string(), - sessionId: z.string(), - channel: z.string().regex(SESSION_CHANNEL_NAME_REGEX), - io: z.enum(["out", "in"]), -}); - -export async function loader({ request, params }: LoaderFunctionArgs) { - const userId = await requireUserId(request); - const { organizationSlug, projectParam, envParam } = EnvironmentParamSchema.parse(params); - const { runParam, sessionId, channel, io } = ParamsSchema.parse(params); - - const project = await findProjectBySlug(organizationSlug, projectParam, userId); - if (!project) { - return new Response("Project not found", { status: 404 }); - } - - const environment = await findEnvironmentBySlug(project.id, envParam, userId); - if (!environment) { - return new Response("Environment not found", { status: 404 }); - } - - const runWhere = { - friendlyId: runParam, - runtimeEnvironmentId: environment.id, - }; - const runArgs = { - select: { id: true, friendlyId: true }, - }; - const run = - (await runStore.findRun(runWhere, runArgs, $replica)) ?? - (await runStore.findRunOnPrimary(runWhere, runArgs)); - - if (!run) { - return new Response("Run not found", { status: 404 }); - } - - const session = await resolveSessionWithWriterFallback(environment.id, sessionId); - - if (!session) { - return new Response("Session not found", { status: 404 }); - } - - const linkWhere = { runId: run.id, sessionId: session.id }; - const linkedSessionRun = - (await $replica.sessionRun.findFirst({ where: linkWhere, select: { id: true } })) ?? - (await prisma.sessionRun.findFirst({ where: linkWhere, select: { id: true } })); - - if (!linkedSessionRun) { - return new Response("Session not found for run", { status: 404 }); - } - - const realtimeStream = getRealtimeStreamInstance(environment, "v2", { session }); - - if (!(realtimeStream instanceof S2RealtimeStreams)) { - return new Response("Session channels require the S2 realtime backend", { - status: 501, - }); - } - - const lastEventId = request.headers.get("Last-Event-ID") || undefined; - const timeoutInSecondsRaw = request.headers.get("Timeout-Seconds"); - let timeoutInSeconds: number | undefined; - if (timeoutInSecondsRaw !== null) { - timeoutInSeconds = Number(timeoutInSecondsRaw); - if (!Number.isInteger(timeoutInSeconds) || timeoutInSeconds < 1 || timeoutInSeconds > 600) { - return new Response("Invalid timeout", { status: 400 }); - } - } - - const addressingKey = canonicalSessionAddressingKey(session, sessionId); - - return realtimeStream.streamResponseFromSessionStream( - request, - addressingKey, - io, - getRequestAbortSignal(), - { lastEventId, timeoutInSeconds }, - channel - ); -} diff --git a/apps/webapp/app/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.runs.$runParam.spans.$spanParam/route.tsx b/apps/webapp/app/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.runs.$runParam.spans.$spanParam/route.tsx index 0d357c6086a..02571272fec 100644 --- a/apps/webapp/app/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.runs.$runParam.spans.$spanParam/route.tsx +++ b/apps/webapp/app/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.runs.$runParam.spans.$spanParam/route.tsx @@ -1804,8 +1804,8 @@ function SpanEntity({ span }: { span: Span }) { ); } case "session-stream": { - const { runId, sessionId, channel, io } = span.entity.object; - const base = `/resources/orgs/${organization.slug}/projects/${project.slug}/env/${environment.slug}/runs/${runId}/realtime/v1/sessions/${encodeURIComponent(sessionId)}`; + const { sessionId, channel, io } = span.entity.object; + const base = `/resources/orgs/${organization.slug}/projects/${project.slug}/env/${environment.slug}/sessions/${encodeURIComponent(sessionId)}/realtime/v1`; const resourcePath = channel ? `${base}/channels/${encodeURIComponent(channel)}/${io}` : `${base}/${io}`; From 74a5ee13ba59fc1a7fb46848562d829bc9ec1703 Mon Sep 17 00:00:00 2001 From: Eric Allam Date: Sat, 29 Aug 2026 13:28:15 +0100 Subject: [PATCH 24/25] fix(webapp): channel .in is generic data; session routes tolerate replica lag Drop stripClientWebhookActionSource from the channel .in append: a named side channel carries arbitrary application data, not the chat action protocol, so rewriting a record that happens to hold actionSource would corrupt user data for no benefit (the sanitization stays on the reserved .in where it has protocol meaning). Resolve the dashboard session and session-channel routes replica-first with a writer fallback so a span or transcript opened on a just-created session isn't a spurious 404. --- ...ime.v1.sessions.$session.channels.$channel.$io.append.ts | 6 +----- ....env.$envParam.sessions.$sessionParam.realtime.v1.$io.ts | 5 ++--- ...sions.$sessionParam.realtime.v1.channels.$channel.$io.ts | 5 ++--- 3 files changed, 5 insertions(+), 11 deletions(-) diff --git a/apps/webapp/app/routes/realtime.v1.sessions.$session.channels.$channel.$io.append.ts b/apps/webapp/app/routes/realtime.v1.sessions.$session.channels.$channel.$io.append.ts index 6cb0acd813b..7372c644713 100644 --- a/apps/webapp/app/routes/realtime.v1.sessions.$session.channels.$channel.$io.append.ts +++ b/apps/webapp/app/routes/realtime.v1.sessions.$session.channels.$channel.$io.append.ts @@ -4,7 +4,6 @@ import { nanoid } from "nanoid"; import { z } from "zod"; import { logger } from "~/services/logger.server"; import { S2RealtimeStreams } from "~/services/realtime/s2realtimeStreams.server"; -import { stripClientWebhookActionSource } from "~/services/realtime/sanitizeSessionInput.server"; import { SESSION_CHANNEL_NAME_REGEX, sessionChannelResources, @@ -84,10 +83,7 @@ const { action, loader } = createActionApiRoute( const addressingKey = canonicalSessionAddressingKey(session, params.session); const claimKey = `${addressingKey}:channels:${params.channel}`; - let part = await request.text(); - if (params.io === "in") { - part = stripClientWebhookActionSource(part); - } + const part = await request.text(); const clientPartId = request.headers.get("X-Part-Id"); const partId = clientPartId ?? nanoid(7); diff --git a/apps/webapp/app/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.sessions.$sessionParam.realtime.v1.$io.ts b/apps/webapp/app/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.sessions.$sessionParam.realtime.v1.$io.ts index 4bc6bb0f61b..ed1eec79643 100644 --- a/apps/webapp/app/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.sessions.$sessionParam.realtime.v1.$io.ts +++ b/apps/webapp/app/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.sessions.$sessionParam.realtime.v1.$io.ts @@ -1,13 +1,12 @@ import { type LoaderFunctionArgs } from "@remix-run/server-runtime"; import { z } from "zod"; -import { $replica } from "~/db.server"; import { findProjectBySlug } from "~/models/project.server"; import { findEnvironmentBySlug } from "~/models/runtimeEnvironment.server"; import { getRequestAbortSignal } from "~/services/httpAsyncStorage.server"; import { S2RealtimeStreams } from "~/services/realtime/s2realtimeStreams.server"; import { canonicalSessionAddressingKey, - resolveSessionByIdOrExternalId, + resolveSessionWithWriterFallback, } from "~/services/realtime/sessions.server"; import { getRealtimeStreamInstance } from "~/services/realtime/v1StreamsGlobal.server"; import { requireUserId } from "~/services/session.server"; @@ -45,7 +44,7 @@ export async function loader({ request, params }: LoaderFunctionArgs) { return new Response("Environment not found", { status: 404 }); } - const session = await resolveSessionByIdOrExternalId($replica, environment.id, sessionParam); + const session = await resolveSessionWithWriterFallback(environment.id, sessionParam); if (!session) { return new Response("Session not found", { status: 404 }); } diff --git a/apps/webapp/app/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.sessions.$sessionParam.realtime.v1.channels.$channel.$io.ts b/apps/webapp/app/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.sessions.$sessionParam.realtime.v1.channels.$channel.$io.ts index 2d1a1493181..ceda204554e 100644 --- a/apps/webapp/app/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.sessions.$sessionParam.realtime.v1.channels.$channel.$io.ts +++ b/apps/webapp/app/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.sessions.$sessionParam.realtime.v1.channels.$channel.$io.ts @@ -1,6 +1,5 @@ import { type LoaderFunctionArgs } from "@remix-run/server-runtime"; import { z } from "zod"; -import { $replica } from "~/db.server"; import { findProjectBySlug } from "~/models/project.server"; import { findEnvironmentBySlug } from "~/models/runtimeEnvironment.server"; import { getRequestAbortSignal } from "~/services/httpAsyncStorage.server"; @@ -8,7 +7,7 @@ import { S2RealtimeStreams } from "~/services/realtime/s2realtimeStreams.server" import { SESSION_CHANNEL_NAME_REGEX } from "~/services/realtime/sessionChannels.server"; import { canonicalSessionAddressingKey, - resolveSessionByIdOrExternalId, + resolveSessionWithWriterFallback, } from "~/services/realtime/sessions.server"; import { getRealtimeStreamInstance } from "~/services/realtime/v1StreamsGlobal.server"; import { requireUserId } from "~/services/session.server"; @@ -35,7 +34,7 @@ export async function loader({ request, params }: LoaderFunctionArgs) { return new Response("Environment not found", { status: 404 }); } - const session = await resolveSessionByIdOrExternalId($replica, environment.id, sessionParam); + const session = await resolveSessionWithWriterFallback(environment.id, sessionParam); if (!session) { return new Response("Session not found", { status: 404 }); } From 3bfe22e02f057c15eb7e669dd5e3d6e9a0b56078 Mon Sep 17 00:00:00 2001 From: Eric Allam Date: Sat, 29 Aug 2026 14:36:10 +0100 Subject: [PATCH 25/25] fix(webapp): reject a session externalId that collides with the channel scope fold A session externalId is used verbatim as an RBAC resource key, and the channel scope folds into that key as `${key}:channels:${channel}`. An externalId containing `:channels:` could therefore equal a channel-scoped token's folded id, letting that token match a different same-env session's bare key. Reject the substring at session creation so a bare session key can never look like a folded channel key. Single colons in an externalId stay valid. Docs: prefer channel-scoped tokens for least privilege on both read and write. --- apps/webapp/app/routes/api.v1.sessions.ts | 11 ++++++ .../realtime/sessionChannels.server.test.ts | 35 +++++++++++++++++++ .../realtime/sessionChannels.server.ts | 13 +++++++ apps/webapp/vitest.config.ts | 1 + docs/ai-chat/side-channels.mdx | 20 +++++++++++ 5 files changed, 80 insertions(+) create mode 100644 apps/webapp/app/services/realtime/sessionChannels.server.test.ts diff --git a/apps/webapp/app/routes/api.v1.sessions.ts b/apps/webapp/app/routes/api.v1.sessions.ts index c7cf2b27a0f..f6ff3b28d59 100644 --- a/apps/webapp/app/routes/api.v1.sessions.ts +++ b/apps/webapp/app/routes/api.v1.sessions.ts @@ -12,6 +12,10 @@ import { $replica, prisma, type PrismaClient } from "~/db.server"; import { clickhouseFactory } from "~/services/clickhouse/clickhouseFactoryInstance.server"; import { logger } from "~/services/logger.server"; import { mintSessionToken } from "~/services/realtime/mintSessionToken.server"; +import { + isSafeSessionExternalId, + SESSION_CHANNEL_SCOPE_INFIX, +} from "~/services/realtime/sessionChannels.server"; import { ensureRunForSession, type SessionTriggerConfig, @@ -168,6 +172,13 @@ const { action } = createActionApiRoute( }, async ({ authentication, body }) => { try { + if (body.externalId && !isSafeSessionExternalId(body.externalId)) { + return json( + { error: `externalId cannot contain "${SESSION_CHANNEL_SCOPE_INFIX}"` }, + { status: 422 } + ); + } + // Idempotent on (env, externalId): two concurrent POSTs converge to the same row, and // `triggerConfig` is refreshed on the cached path so a redeployed config reaches the next run. const { session, isCached } = await findOrCreateSession({ diff --git a/apps/webapp/app/services/realtime/sessionChannels.server.test.ts b/apps/webapp/app/services/realtime/sessionChannels.server.test.ts new file mode 100644 index 00000000000..f5606964616 --- /dev/null +++ b/apps/webapp/app/services/realtime/sessionChannels.server.test.ts @@ -0,0 +1,35 @@ +import { describe, expect, it } from "vitest"; +import { + isSafeSessionExternalId, + SESSION_CHANNEL_SCOPE_INFIX, + sessionChannelResources, +} from "./sessionChannels.server"; + +describe("isSafeSessionExternalId", () => { + it("rejects an externalId that collides with the channel-scope fold", () => { + expect(isSafeSessionExternalId(`session_abc${SESSION_CHANNEL_SCOPE_INFIX}screencast`)).toBe( + false + ); + expect(isSafeSessionExternalId(":channels:")).toBe(false); + expect(isSafeSessionExternalId("a:channels:b:channels:c")).toBe(false); + }); + + it("allows normal externalIds, including single colons that are not the fold infix", () => { + expect(isSafeSessionExternalId("chat-3c3a1756-a49a-4c78-891a-51f78596c984")).toBe(true); + expect(isSafeSessionExternalId("user:123")).toBe(true); + expect(isSafeSessionExternalId("org:abc:chat:1")).toBe(true); + expect(isSafeSessionExternalId("channels")).toBe(true); + expect(isSafeSessionExternalId("plain")).toBe(true); + }); + + it("keeps a channel-scoped token's folded id from equaling any allowed session's bare key", () => { + const channel = "screencast"; + const foldedIds = sessionChannelResources(channel, ["session_abc"]) + .map((r) => r.id) + .filter((id) => id.includes(SESSION_CHANNEL_SCOPE_INFIX)); + + for (const foldedId of foldedIds) { + expect(isSafeSessionExternalId(foldedId)).toBe(false); + } + }); +}); diff --git a/apps/webapp/app/services/realtime/sessionChannels.server.ts b/apps/webapp/app/services/realtime/sessionChannels.server.ts index 4c444be6643..ee98ffce0a3 100644 --- a/apps/webapp/app/services/realtime/sessionChannels.server.ts +++ b/apps/webapp/app/services/realtime/sessionChannels.server.ts @@ -8,6 +8,19 @@ import type { RbacResource } from "@trigger.dev/rbac"; */ export const SESSION_CHANNEL_NAME_REGEX = /^[A-Za-z0-9._-]{1,128}$/; +/** + * The infix the channel-scope fold uses in the RBAC resource id + * (`${key}:channels:${channel}`). A session externalId is used verbatim as a + * resource key, so an externalId containing this infix could equal a + * channel-scoped token's folded id and collide with it. Reject it at session + * creation so a bare session key can never look like a folded channel key. + */ +export const SESSION_CHANNEL_SCOPE_INFIX = ":channels:"; + +export function isSafeSessionExternalId(externalId: string): boolean { + return !externalId.includes(SESSION_CHANNEL_SCOPE_INFIX); +} + /** * Build the authorization resource set for a named channel. For each candidate * session key (URL form, friendlyId, externalId) we authorize BOTH the diff --git a/apps/webapp/vitest.config.ts b/apps/webapp/vitest.config.ts index dabe517bf4f..3880e304573 100644 --- a/apps/webapp/vitest.config.ts +++ b/apps/webapp/vitest.config.ts @@ -17,6 +17,7 @@ export default defineConfig({ "app/v3/services/bulk/**/*.test.ts", "app/runEngine/concerns/**/*.test.ts", "app/runEngine/services/**/*.test.ts", + "app/services/realtime/**/*.test.ts", "app/utils/**/*.test.ts", "app/components/code/**/*.test.ts", "app/components/runs/**/*.test.ts", diff --git a/docs/ai-chat/side-channels.mdx b/docs/ai-chat/side-channels.mdx index a86675beb8b..d46a8947e2a 100644 --- a/docs/ai-chat/side-channels.mdx +++ b/docs/ai-chat/side-channels.mdx @@ -140,6 +140,26 @@ A side channel's streams are bounded by the same retention as the rest of your r A side channel is covered by the session's public access token: a token scoped to `read:sessions:{id}` / `write:sessions:{id}` grants every channel of that session. Mint a narrower token scoped to a single channel with `read:sessions:{id}:channels:{name}`. Writing a channel's `.out` requires secret-key auth (only the agent run), so a browser cannot forge frames; `.in` is writable with the session token. See [Realtime auth](/realtime/auth). +### Scope tokens to the channel, not the whole session + +Two properties of the session token are worth designing around when a browser only needs one channel: + +- **A session-wide token grants every channel, including ones added later.** `read:sessions:{id}` reads the reserved chat transcript and all named channels. If a client should see only the screencast frames and not the chat, give it `read:sessions:{id}:channels:screencast` instead. The channel-scoped token reads only that channel: it cannot read another channel or the reserved transcript. +- **A session write token can write the reserved `.in` too, not just a channel's.** `write:sessions:{id}` can send a chat message on the reserved `.in`, so a client meant only to send control input on one channel should hold `write:sessions:{id}:channels:{name}`, which confines it to that channel's `.in`. + +```ts Mint a channel-scoped token (your backend) +import { auth } from "@trigger.dev/sdk"; + +const token = await auth.createPublicToken({ + scopes: { read: { sessions: `${sessionId}:channels:screencast` } }, +}); +``` + + + A session's `externalId` cannot contain `:channels:`, since that is the delimiter the channel scope + uses. `sessions.start` rejects it. Any other string, including single colons, is fine. + + ## Next steps