- Sessions list mirrors the Runs list (ClickHouse-backed, filterable, cursor-paginated, derived ACTIVE/CLOSED/EXPIRED status).
- Session detail page: split-pane Conversation + Inspector with Overview/Runs/Metadata tabs, breadcrumb status combo, Close session action via a Remix resource route, dashboard-cookie-authed SSE for input/output streams.
- AgentView decoupled from a specific run — now subscribes via session-scoped SSE, so the same component renders on both run and session pages with identical streaming behavior.
- Run inspector adds a Session row (gated on AGENT-tagged runs) linking back to the owning session, mirroring the existing Batch row pattern.
- stress-emit chat.agent task added to the ai-chat reference for stress-testing the conversation UI.
Update the sendAction docstrings on TriggerChatTransport (browser) and
AgentChat (server) to reflect TRI-9118: actions fire hydrateMessages
and onAction only — no run(), no turn lifecycle hooks. The returned
stream is empty for void onAction returns and carries the model
response when onAction returns a StreamTextResult.
Wire a typed action on the main `aiChat` agent in
references/ai-chat — `actionSchema` accepts `{ type: "undo" }` and
`onAction` calls `chat.history.slice(0, -2)` to drop the last
user/assistant exchange. Adds an Undo button to the chat input row
that calls `transport.sendAction(chatId, { type: "undo" })` and
optimistically updates local `useChat` state via `setMessages`.
Exercises the new TRI-9118 action semantics end-to-end through the
demo UI: clicking Undo emits a `chat action` span (not `chat turn`),
fires only `onAction()`, no `run()` / `streamText` / turn lifecycle
hooks. The next message turn sees the truncated server-side history.
Action turns previously fell through to the regular turn machinery,
calling onTurnStart, run(), onTurnComplete, etc. — meaning every action
fired an LLM call by default. Customers worked around this with a
chat.store-based skipModelCall flag pattern (Graham at Arena).
Now actions fire hydrateMessages and onAction only. No onTurnStart,
prepareMessages, onBeforeTurnComplete, onTurnComplete; no run()
invocation; no turn-counter increment. The trace span is named
"chat action" instead of "chat turn N".
onAction widens to accept the same return shapes as run(): void
(side-effect-only, default), StreamTextResult (auto-piped as the
response), string, or UIMessage. Customers who want a model response
from an action return streamText(...) directly from onAction.
If an action arrives but no onAction handler is configured, console.warn
fires once and the action is ignored (vs. silently triggering run()
on a stale wire payload).
Closes TRI-9118.
BREAKING: customers who relied on actions auto-invoking run() must
move that logic into onAction. See the changeset for the migration
snippet.
Adds `taskContext.setConversationId()` to the core API so the chat.task
and chat.agent run boots can flag a chat run with the OTel GenAI
`gen_ai.conversation.id` semantic attribute. The TaskContextSpanProcessor
stamps it on every span at start and TaskContextMetricExporter copies it
into every metric data point — `ctx.*` is filtered by the OTLP ingest,
but `gen_ai.*` survives to the stored attributes column without a schema
migration. Lets dashboard span/metric views correlate by chat conversation
across multiple runs.
Closes TRI-9082.
`SSEStreamSubscription.connectStream` invokes `onError` twice for 401/403
responses: first in the `!response.ok` branch where the auth ApiError is
constructed (so consumers see the original failure status/headers), then
again in the catch block's `isTriggerRealtimeAuthError` arm before
terminating the stream. Drop the second call — the early one already
notified the consumer; the catch block's job is just to route the error
to `controller.error` so retry doesn't fire.
Spotted by Devin on PR 3173.
`StandardSessionStreamManager#ensureTailConnected` re-subscribes the SSE
tail in `.finally` whenever handlers or once-waiters remain on the key.
That's the right move for unexpected tail crashes, but wrong when
`session.in.wait()` calls `disconnectStream` to suspend the run via a
waitpoint: the run-level `stopInput.on(...)` registered at the top of
the `chat.agent` loop keeps the handlers set non-empty, so during the
suspend window the tail silently resurrects, the next user message
arrives at S2, the tail dispatches it, `stopSub`'s "kind === stop"
filter rejects it, the data falls into the buffer, the waitpoint
*also* delivers the same record, the SDK resumes, and on the next
turn's `messagesInput.on(...)` registration the buffer drain re-fires
the handler — `pendingMessages` ends up holding a duplicate of the
just-consumed message and the loop runs an extra LLM turn with
identical content.
Track explicit teardown via `explicitlyDisconnected: Set<string>`.
`disconnectStream` adds the key, `.finally` bails when set, `on()` /
`once()` clear it so future re-attaches reconnect normally. Honors
`wait()`'s expectation that explicit disconnect ⇒ no records buffered
or delivered until a fresh `on()`/`once()`, while preserving
auto-reconnect for legitimate failures (network drops, etc.).
Verified end-to-end against a `chat.agent` reproduction that
previously fired three turns per submitted message after suspend; with
the fix exactly one turn per message, single LLM call, single
persisted assistant reply.
Trivial: `wait()` extracts `nextSeq` to a local for readability.
ApiRunListPresenter was returning `run.taskKind` raw from ClickHouse
where the column defaults to `""` for pre-migration rows, while the
dashboard's NextRunListPresenter normalizes to `"STANDARD"`. API
consumers and the dashboard now agree.
Playground action's two `as unknown as AuthenticatedEnvironment`
casts were redundant — `findEnvironmentBySlug` already returns
`Promise<AuthenticatedEnvironment | null>`. Drop the casts (and the
now-unused import) so a future change to the function's return shape
actually surfaces as a type error instead of crashing at runtime.
Two unrelated fixes that both block the ai-chat feature branch.
apps/webapp queues concern — locked + specified-queue branch was
silently dropping `taskKind`. The TTL-skip optimization on the
backgroundWorkerTask lookup also skipped the only place we read
`triggerSource`, so AGENT and SCHEDULED runs triggered with both
`lockToVersion` and a queue override were annotated as STANDARD and
disappeared from the run-list "Source" filter (and replicated to
ClickHouse with `task_kind = 'STANDARD'`). The lookup now always
runs and includes `triggerSource` in the same select; ttl is still
gated on the override being absent. Mirrors the sibling locked-with-
default-queue branch (line ~162) and the non-locked branch's
`getTaskQueueInfo`.
trigger-sdk test harness — `mockChatAgent` was leaving an
`ApiClientMissingError` unhandled-rejection trail when an agent's
suspend path tripped (the `chat.handover` idle-timeout test reliably
hit it). The harness reused the real `SessionInputChannel`, whose
`wait()` calls `apiClientManager.clientOrThrow()` — fine in
production, fatal in a test process with no `TRIGGER_SECRET_KEY`.
Added a `TestSessionInputChannel` subclass that overrides only
`wait()` and resolves `{ok:false}` when the harness's run signal
aborts; `on`/`once`/`peek`/`send` continue to flow through the real
`sessionStreams` global. The harness threads its `runSignal.signal`
in via a lazy getter so the channel reads it after the controller is
constructed.
All 97 sdk tests pass; webapp typecheck is clean.
Mirror the stamping + read-precedence work from TRI-9073 in the
ai-chat-feature-branch-only routes:
- Playground action: stamp `streamBasinName` from
`environment.organization.streamBasinName` on Session.upsert.
- Playground SSE / append routes: pass `{ session }` to
`getRealtimeStreamInstance` so basin resolves via session row.
- Dashboard run-stream / run-input / run-session SSE routes (the
dashboard-auth counterparts to the public /realtime/v1/* routes):
same plumbing.
These files only exist on this branch (they were added by the
chat-agent / Sessions PRs), so the plumbing rides along here rather
than on the basin migration branch.
Refs TRI-9073.
Mirrors the 'start' case (lines 104-108) — uncaught JSON.parse on a
malformed messages form field surfaced as an unhandled 500 instead of
a clean 400. Addresses Devin review on PR #3173.
Adds a /api/chat route handler exporting chat.headStart, splits the
tool definitions across two modules so heavy executes never reach the
browser bundle, and exposes a sidebar toggle for paired TTFC tests.
- src/lib/chat-tools-schemas.ts (new): schema-only tool definitions —
imported by both the route handler and the agent task. No `execute`,
no heavy deps. Bundle stays small.
- src/trigger/chat-tools.ts (renamed): re-exports the schemas with
agent-side `execute` fns added (E2B sandbox, turndown, deepResearch
subtask, etc.). Only the trigger task imports this.
- src/app/api/chat/route.ts (new): exports POST = chat.headStart, runs
step 1 streamText with claude-sonnet-4-6 to match the agent's default.
- ChatSettingsContext + sidebar gain a "Use handover (1st turn)"
toggle; chat-view threads it into the transport's `headStart` URL.
- Smoke result: ~53% TTFC reduction on first turn (1561ms vs 3358ms),
with persistence + tool execution behaving identically.
Adds an opt-in fast path that runs step 1 streamText in the warm
customer process (Next.js, Hono, Workers, Express, etc.) while the
trigger agent run boots in parallel. Pure-text turns finish on the
handler side; tool-call turns hand ownership to the agent at the
tool-call boundary via a `kind: "handover"` chunk on session.in.
- New @trigger.dev/sdk/chat-server subpath with chat.headStart,
chat.openSession (escape hatch), and chat.toNodeListener (Express /
Fastify / Koa bridge from Web Fetch handler to (req, res)).
- Wire-format: ChatInputChunk gains kind: "handover" with isFinal flag
and partialAssistantMessage; trigger payload kind: "handover-prepare"
for the boot-and-wait variant.
- Run-loop: handover-prepare branch waits on session.in, then either
skips userRun (isFinal: true → pure-text) or seeds accumulators and
resumes step 2+ from tool-output-available (isFinal: false).
- Browser: TriggerChatTransport gains an optional `headStart` URL.
First-turn POSTs go there; turn 2+ bypasses and writes session.in.
- Tests: chat-server.test.ts (handover dispatch, isFinal routing) and
chatHandover.test.ts (run-loop branching, hook ordering, idle-timeout
exit, schema-only-on-handler / executes-on-agent tool round).
Replace the legacy 5-attempt retry cap on SSEStreamSubscription with
indefinite retry on a bounded jittered backoff. Adds a force-reconnect
path so the chat transport can recover from silent-dead-socket cases
on mobile (background-kill, bfcache restore) without waiting for the
next backoff slot.
SSEStreamSubscription:
- maxRetries default Infinity (was 5), retryDelayMs 100ms (was 1s),
new maxRetryDelayMs cap (5s), retryJitter 50%
- retryNow(): wake an in-flight backoff
- forceReconnect(): drop current connection AND wake backoff
- fetchTimeoutMs (30s default): aborts stuck connect attempts that
block forever on dead sockets
- stallTimeoutMs (opt-in): force reconnect on silent reader
- nonRetryableStatuses (default [404, 410]): short-circuit retry
for stream-gone / session-closed
- Fixed listener leak where each retry accumulated an abort listener
on the user signal because finally only ran once the recursion
unwound. Cleanup now runs per-attempt via cleanupAttempt() in both
the catch (before recursion) and finally paths.
TriggerChatTransport (browser):
- online -> forceReconnect (existing socket may be stale)
- pageshow.persisted -> forceReconnect (Safari bfcache restore)
- visibilitychange -> visible only:
* hidden >= 30s -> forceReconnect
* hidden < 30s -> retryNow (cheap wake)
- stallTimeoutMs: 60s (sized over typical agent thinking pauses)
Tests: 13 vitest cases covering retry-past-legacy-cap, backoff cap,
jitter variance, retryNow short-circuit, abort-during-backoff,
forceReconnect during fetch and during read (verifies Last-Event-ID
resume on the resumed request), fetchTimeout, stallTimeout, 404/410
short-circuit, custom nonRetryableStatuses, 503 still retries.
Refs TRI-8903.
- inline prototype-pollution guards at JSON Patch assignment sites in chat-client.ts so CodeQL can statically verify them (Set.has() check upstream wasn't being traced)
- wrap JSON.parse(payloadStr) in playground action's start handler to return 400 on malformed JSON instead of 500
The previous pass rolled 26 changesets into 8 but the consolidated
descriptions read like docs (full API surface dumps, multiple sections,
docs-style headers). Rewrote each so they fit a release-notes bullet
list — short, what-shipped framing, with one or two snippets where they
help, no exhaustive type / option enumeration.
Two fixes from Devin's review on PR #3173.
## SessionTriggerConfig is missing 3 fields the playground UI shows
The playground sidebar (`PlaygroundSidebar`) renders working controls for
`maxDuration`, `version`, and `region`. The action received the form fields,
but `SessionTriggerConfig` didn't accept them so they were `void`-suppressed
and silently dropped. Runs ignored the user's max-duration cap, the version
pin didn't apply, and region selection had no effect.
- `packages/core/src/v3/schemas/api.ts` — add three optional fields to
`SessionTriggerConfig`: `maxDuration` (positive int, seconds),
`lockToVersion` (string), `region` (string). All three forward to the
matching field on `TaskRunOptions`.
- `apps/webapp/app/services/realtime/sessionRunManager.server.ts` — extend
`triggerSessionRun`'s `body.options` to thread the three fields through
to `TriggerTaskService` when present.
- `apps/webapp/app/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.playground.action.tsx`
— fold the three form fields into `triggerConfig`; remove the `void`
suppressions.
## Playground transport's clientData becomes stale after edits
The route constructs `TriggerChatTransport` directly via `useRef` (to avoid
the React-version mismatch the hook had). The hook normally calls
`setClientData` whenever `clientData` changes, but this manual construction
bypassed that — so `clientData` was captured at construction and never
updated. Per-turn `metadata` merges (`this.defaultMetadata` in
`packages/trigger-sdk/src/v3/chat.ts`) used the stale initial value for
the whole conversation. `startSession` was already reading from the live
ref so session creation was unaffected; this only fixed the per-turn path.
- `apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.playground.$agentParam/route.tsx`
— add a `useEffect` that calls `transport.setClientData(...)` whenever
`clientDataJson` changes.
Changeset (patch, @trigger.dev/core) for the schema additions; server-
changes file for the webapp-only behaviour fix.
- typesVersions: add `v3/chat-client` mapping. The export was declared in
`tshy.exports` and the conditional export block but missing from
`typesVersions` — `attw --pack` flagged "@trigger.dev/core/v3/chat-client"
as `node10: 💀 Resolution failed`.
- chat.store JSON Patch: add an `assertSafeKey` guard at the assignment
sites in `removeAt` / `insertAt`. parseJsonPointer already rejects
`__proto__` / `constructor` / `prototype`, but CodeQL's prototype-pollution
analysis doesn't trace through the parser boundary — the local check at
the assignment keeps the static analysis happy and is also a real
defense-in-depth backstop against any future caller that bypasses
parseJsonPointer.
- typesVersions: add `ai/skills-runtime` mapping (was missing → check-exports
failed with NoResolution on `@trigger.dev/sdk/ai/skills-runtime`).
- chat.store JSON Patch: reject `__proto__`, `constructor`, `prototype`
segments at parseJsonPointer. Closes the two CodeQL prototype-pollution
alerts on chat-client.ts:108 / :120 — a malicious patch like
`{ op: "replace", path: "/__proto__/x", value: 1 }` would otherwise
walk into Object.prototype via `parent[lastToken] = value`. Throws a
clear error on the whole patch instead.
The realtime stream caps each record at ~1 MiB. Today the chat.agent path
through StreamsWriterV2 surfaces a generic S2Error from deep in the
batching layer when a chunk exceeds the cap, with no chunk-type context
and no guidance for callers.
Add a pre-write byte check in StreamsWriterV2.initializeServerStream that
fires before the chunk hits the underlying batcher, and a typed
ChatChunkTooLargeError carrying the chunk's discriminant (type/kind),
serialized size, and cap. Also exports an isChatChunkTooLargeError guard
from the SDK so callers can branch cleanly.
Threshold is 1 MiB minus 1 KiB to leave headroom for the JSON record
envelope. The error message links to the new docs pattern (Pattern:
ID-reference for large tool outputs / out-of-band streams.writer for
run-scoped data).
Server-to-agent flows (`AgentChat` SDK class + cli-v3 MCP `start_agent_chat`) were building `triggerConfig.basePayload` without the `trigger: "preload"` and `messages: []` fields the agent runtime branches on. Result: the auto-triggered first run had `payload.trigger === undefined`, neither `onPreload` nor `onChatStart` fired, and `onTurnStart`'s DB-write blew up with PrismaClient "No record found" because no Chat row had been created.
Browser-mediated flows already had this right (`chat.createStartSessionAction` in `ai.ts:6951`); the server-side path now mirrors that shape.
- packages/trigger-sdk/src/v3/chat-client.ts — `AgentChat.ensureStarted` adds the two fields to `basePayload`. `chat-client-test`'s `pong` orchestrator now returns the assistant text instead of an empty string.
- packages/cli-v3/src/mcp/tools/agentChat.ts — same fix on `start_agent_chat`'s `createSession` call. Also drops the redundant separate `apiClient.triggerTask(...)` call: `POST /api/v1/sessions` now auto-triggers the first run and returns its runId, so a second trigger from the MCP would have produced a competing run on the same session. Use `session.runId` from the create response. The `preload` input flag becomes a no-op signal (response message wording only) since session-create always triggers a run now.
Verified end-to-end against local:
- `chat-client-test` orchestrator returns `{ text: "pong" }`
- MCP `start_agent_chat` → `send_agent_message` x2 → `close_agent_chat` succeeds, both turns reuse the same runId
Migration 029 added `task_kind` to `task_runs_v2`, and TASK_RUN_COLUMNS was updated, but the four test-data arrays in src/taskRuns.test.ts were not. ClickHouse rejects the inserts with "Cannot parse input: expected ',' before: ']'" because the array length is one short of the column count. All 7 internal/clickhouse unit-test shards on PR #3173 fail on this.
Pre-existing bug (predates my Sessions work) but blocking CI; verified the fix locally — `vitest run src/taskRuns.test.ts` now passes 4/4.
CreateSessionRequestBody now requires `taskIdentifier` and `triggerConfig` because Sessions are task-bound (the server reuses the config for every run scheduled by the session — initial + continuations). The MCP `agentChat` tool was still passing only `{ type, externalId }` from the pre-Sessions-as-run-manager API. Add `taskIdentifier: input.agentId` and a minimal `triggerConfig` with `basePayload: { chatId, ...clientData }` and the `chat:{chatId}` auto-tag.
Unblocks typecheck on PR #3173 (and Windows CLI v3 e2e, which builds cli-v3 in pre-test).
UX cleanup discovered during the Sessions e2e sweep. Three changes, one commit because they all live in the chat input row / debug panel area:
- Explicit "Preload" button next to "Send" that only renders when the chat has no messages and no session yet. Clicking calls transport.preload(chatId), which mints the session and triggers the first run with trigger:"preload". Self-hides once session is truthy. Replaces the inert "Preload new chats" sidebar checkbox (the visible `+ New Chat` button only navigated and never called transport.preload — preloadEnabled was wired through the context but read by nobody, since ChatApp.tsx is no longer the mounted chat sidebar). Drops the dead preloadEnabled state + checkbox from chat-settings-context, chat-sidebar, chat-sidebar-wrapper, and the chat-app.tsx legacy code path.
- Debug panel "Runs → View in dashboard" row, gated on dashboardUrl + a new NEXT_PUBLIC_TRIGGER_PROJECT_DASHBOARD_PATH env var. Resolves to the runs-list page filtered by chat:<chatId> tag — so opening the link drops you straight into the run list for the active chat. Threads the new prop through chat-view → chat → DebugPanel.
- window.__chat.sendAction(action) bridge wrapper that delegates to transport.sendAction(chatId, action). Lets smoke tests drive aiChatHydrated's actionSchema (undo/rollback/remove/replace) without reaching into React internals.
The reference's Chat / ChatSession Postgres tables are shared between local and test cloud targets. A row created with one webapp's PAT and lastEventId is poison if you switch the .env to the other target and reuse the same chatId — the transport gets a 401 or resumes from a sequence that doesn't exist on the other backend.
Adds:
- prisma/reset-chats.sql: TRUNCATE Chat, ChatSession (User survives — it's upserted by onPreload/onChatStart anyway).
- package.json db:reset:chats script wrapping prisma db execute --file.
Run `pnpm run db:reset:chats` between target switches and at the top of every smoke test. Codified in the ai-chat-e2e skill as a required prereq.
The reference's onTurnStart was using chat.defer for the messages write, which is fire-and-forget. If a user refreshed the page mid-stream, getChatMessages returned [] (the deferred write hadn't landed yet), useChat hydrated with empty initialMessages, and the resumed SSE stream pushed the assistant into an empty array — the user's message vanished from the rendered conversation forever.
Switch to await prisma.chat.update(...) so the write is durable before chat.agent begins streaming. Verified end-to-end against test cloud: mid-stream refresh now yields [user, assistant] with no duplication.
Aligns with the Warning added to docs/ai-chat/patterns/database-persistence.mdx in the docs branch.
- chat.createStartSessionAction now adds 'chat:{chatId}' as the first tag on the triggered run, matching the browser-mediated transport.doStart path. Customer-provided tags merge after, capped at 5. Without this, runs created via server actions were untagged, breaking the dashboard chat-id filter.
- references/ai-chat onTurnComplete persists Chat.messages and ChatSession.lastEventId in a single prisma.$transaction. Two parallel reads on the next page load (Promise.all([getChatMessages, getSessionForChat])) can otherwise observe messages post-write but lastEventId pre-write. The transport then resumes from the stale cursor and replays this turn's chunks on top of the already-persisted assistant message, duplicating the render. Applies to both the main chat.agent and the hydrated variant.
Persistent listeners registered via `session.in.on(...)` (e.g. chat.agent's
`stopInput.on` for the stop signal) must not 'consume' chunks. They filter
by `kind` and ignore non-matching chunks, so previously `#dispatch` was
silently dropping any chunk that arrived before a once-waiter had registered.
This race surfaced on test cloud (network round-trip > sync subscribe-time)
but not locally (zero-latency). Symptom: chat.agent's first user message
landed in S2 before `messagesInput.waitWithIdleTimeout` registered its
waiter, the tail received it, `#dispatch` saw the `stopInput` handler and
returned without buffering, the message was gone, the waitWithIdleTimeout
fell through to a durable waitpoint, and the race-check skipped seq 0
(since the tail's onPart had advanced `lastSeqNum` to 0).
Fix: when no once-waiter exists, invoke handlers AND buffer the chunk.
Handlers observe; they don't consume.
chat.agent now runs on top of the Session-as-run-manager primitive.
Public surface (`chat.agent({...})`, `useTriggerChatTransport`,
`chat.store` / `chat.defer` / `chat.history`, `AgentChat`) is unchanged;
the wiring underneath moves from per-run streams to the durable Session
row that owns its own runs.
Transport (TriggerChatTransport):
- Drop `getStartToken`. Replace with
`startSession({chatId, taskId, clientData}) => {publicAccessToken}` —
wraps a server action that calls `chat.createStartSessionAction`.
Idempotent on `(env, externalId)`.
- `clientData` (typed via `withClientData`) is threaded through
`startSession`'s params, so the first run's `basePayload.metadata`
matches per-turn `metadata`. Live-updated via `setClientData` when
the hook's `clientData` option changes.
- Drop transport-level `triggerConfig` / `triggerOptions` /
`idleTimeoutInSeconds`. All trigger config lives server-side in the
customer's `chat.createStartSessionAction(taskId, options)`.
- `transport.preload(chatId)` and lazy first `sendMessage` both route
through `startSession`, deduped via the in-flight pendingStarts map.
- `ChatSession` persistable shape drops `runId`; just `{lastEventId}`.
chat.agent runtime:
- New `chat.createStartSessionAction(taskId, options?)` — server-side
wrapper that calls `sessions.start` with `basePayload.{messages:[],
trigger: "preload"}` defaults plus the customer's overrides. Returns
`{sessionId, runId, publicAccessToken}`.
- `chat.requestUpgrade` calls `apiClient.endAndContinueSession` before
emitting the `trigger:upgrade-required` chunk. Server orchestrates
the swap; browser keeps streaming across the run handoff.
Webapp dashboard:
- Playground: `startSession` + `accessToken` both wired through the
Remix action (idempotent server-side start path). Preload button
now works. New session proxy routes for HEAD/GET on `/out` and POST
on `/in/append`; old run-stream proxies deleted.
- Run inspector Agent tab: SSE proxy now uses the canonical addressing
key (externalId if set, else friendlyId), matching what the agent
writes via `session.out`. Fixes the case where the Agent tab read
from a different S2 stream than the agent wrote to.
References (ai-chat):
- `chat-view` useEffect dance gone (just hydrates `initialSession`).
- `chat-app` `transport.preload(id)` routes through `startSession`.
- New `upgrade-test` agent + sidebar option for exercising
`chat.requestUpgrade` end-to-end.
- `ChatSession` schema simplified: drop `runId` / `sessionId`, keep
`publicAccessToken` + `lastEventId`.
- `chat-client-test` fixed for the new transport shape.
- Hello-world smoke stubs gutted to TODO placeholders — sessions
are now task-bound, so standalone-session smokes need rewriting.
Companion to the SDK opt-in. Webapp routes read X-Peek-Settled from the
request and skip the tail peek when it isn't set, so active
send-a-message paths can't race a stale trigger:turn-complete. Docs
note the opt-in semantics; .server-changes records the change for the
deploy log.
The webapp's peek-tail-settled shortcut on /realtime/v1/sessions/:id/out
previously fired on every io=out subscription. That race-tripped active
send-a-message paths: the SSE peek would see the prior turn's
trigger:turn-complete record before the newly-triggered run wrote its
first chunk, return wait=0 + X-Session-Settled:true, and close the
stream before any of the new turn's records landed.
Make the peek opt-in via an X-Peek-Settled: 1 request header. Only
TriggerChatTransport.reconnectToStream sets it (true reload-resume case
where settling early is fine); sendMessages and the rest leave it off
and stay on the normal long-poll. On the server side,
streamResponseFromSessionStream gates the peek on options.peekSettled
and skips it otherwise.
- apps/webapp: read X-Peek-Settled from the request, thread to
streamResponseFromSessionStream
- packages/trigger-sdk/chat.ts: peekSettled option on
subscribeToSessionStream + reconnectToStream sets it; sendMessages
does not
- docs/ai-chat/client-protocol.mdx + docs/sessions/reference.mdx:
document the opt-in semantics
- .server-changes/session-out-settled-signal.md: record the change
Pulls PENDING_MESSAGE_INJECTED_TYPE, ChatTaskWirePayload, and the
client-data inference helpers out of ai.ts (~7000 lines, statically
imports node:* via the skills runtime) into a new ai-shared.ts that
stays free of node-only imports. chat.ts and chat-react.ts now reach
for these via ai-shared so browser bundlers don't trace ai.ts's entire
module graph (Turbopack rejected the node: builtins outright).
Three dashboard-scoped stream routes were passing request.signal into
realtimeStream.streamResponse. That signal is broken under
Remix+Express (see apps/webapp/CLAUDE.md, nodejs/node#55428 — the chain
is severed when Remix internally clones the Request), so when a user
closes their dashboard tab the signal never fires. The underlying
RedisRealtimeStreams.streamResponse loops while(!signal.aborted) over
XREAD BLOCK and only exits on its 15s inactivity timeout; the S2 path
keeps the upstream fetch open for up to its 60s wait window.
Thread getRequestAbortSignal() through:
- resources/orgs/.../runs/$runParam/realtime/v1/streams/$runId/$streamId
- resources/orgs/.../runs/$runParam/realtime/v1/streams/$runId/input/$streamId
- resources/orgs/.../playground/realtime/v1/streams/$runId/$streamId
Each picks up the Express res.on('close')-backed signal that fires
reliably when the downstream client disconnects.
TriggerChatTransport.reconnectToStream previously returned null any time
state.isStreaming was falsy, which included undefined. That meant a
caller who dropped isStreaming from their ChatSession persistence (a
reasonable simplification now that the server can tell the client when
a session is settled via X-Session-Settled on the session.out SSE)
would get null on every reconnect and the UI would never resume
streaming.
Tighten the check to state.isStreaming === false so only an explicit
false triggers the fast-path skip. Undefined now falls through to open
the SSE and let the server decide — on a settled session the server
already closes the connection in ~1s via wait=0, so there is no 60s
hang to worry about.
Backward compatible: callers who still persist and hydrate isStreaming
(true/false) keep today's behavior exactly; callers who drop the flag
now get the server-authoritative path.
Final Phase F cleanup — `CHAT_STREAM_KEY`, `CHAT_MESSAGES_STREAM_ID`,
and `CHAT_STOP_STREAM_ID` were meaningful only when chat.agent I/O
lived on run-scoped Redis streams. The Session migration moved all
chat I/O onto the backing Session's `.in` / `.out` channels, so these
constants stopped describing how anything is addressed months ago and
have been dead-weight re-exports since.
Dropped from the public surface:
- `@trigger.dev/core/v3/chat-client` no longer exports the three
constants. The file keeps `ChatStoreChunk` + `applyChatStorePatch`
(the chat.store primitive's shared types).
- `@trigger.dev/sdk/ai` no longer re-exports them via the
`CHAT_STREAM_KEY` / `CHAT_MESSAGES_STREAM_ID` / `CHAT_STOP_STREAM_ID`
aliases introduced by the migration commit.
- Deletes `packages/trigger-sdk/src/v3/chat-constants.ts` (the shim
that bridged core's definitions to the SDK's public surface).
What stayed the same:
- `chat.stream.id` / `chat.messages.id` / `chat.stopSignal.id` still
contain the literal strings `"chat"` / `"chat-messages"` /
`"chat-stop"` — inlined as opaque breadcrumbs rather than
user-consumable constants. Telemetry attrs keep the same values,
so dashboards/spans don't shift.
- All runtime behavior is untouched. The `chatStream` / `messagesInput` /
`stopInput` facades still delegate through the Session handle
exactly as before; only the constant symbols are gone.
Migration note for external callers:
Anyone still importing the old constants should migrate to the
session primitives:
- `streams.writer(CHAT_STREAM_KEY, …)` → `sessions.open(sessionId).out.writer(…)`
- `streams.input(CHAT_MESSAGES_STREAM_ID)` → `sessions.open(sessionId).in.on(…)`
(filtered by `chunk.kind === "message"`)
- `streams.input(CHAT_STOP_STREAM_ID)` → `sessions.open(sessionId).in.on(…)`
(filtered by `chunk.kind === "stop"`)
Validated
- 86/86 SDK tests green.
- Webapp typecheck clean (core types used in SpanPresenter + AgentView
are untouched).
- ai-chat UI smoke passes end-to-end: new chat → send "Say hi in
three words." → first assistant text in 4.9s → sessionId + runId +
lastEventId all set.
Fixes the last set of issues that were blocking TriggerChatTransport
from running end-to-end against the ai-chat reference. Smoke now
passes: new chat → send → streamed assistant reply in ~4s → second
turn reuses the same session + run, lastEventId advances 10 → 21.
SDK (@trigger.dev/sdk)
- RenewRunAccessTokenParams carries the durable sessionId alongside
chatId + runId. Server-side renew handlers MUST mint the renewed
PAT with read:sessions:{sessionId} + write:sessions:{sessionId}
scopes (in addition to the existing run scopes) — without them,
the first append after expiry 401s on session.in/append and sends
the transport into a renew loop. transport.renewRunPatForSession
looks up the cached sessionId off `this.sessions` so existing
renew callers just need to spread the new field through.
- transport.preload(chatId) on the triggerTask callback path no
longer calls apiClient.createSession from the browser. Matches
sendMessages: when triggerTaskFn is configured the server action
(chat.createTriggerAction) creates the Session with its secret
key and returns sessionId alongside the run PAT. Browser
deployments using the callback flow therefore never need
write:sessions on any browser-facing token.
- chat.test.ts renew-spy assertions updated to match the new
{chatId, runId, sessionId} shape — 86/86 tests still green.
Webapp
- POST /api/v1/sessions gets allowJWT: true + corsStrategy: "all".
Pre-fix, the route rejected any CORS-preflighted browser call,
which broke the transport's direct accessToken fallback path
(sessions.create from the browser).
- POST /realtime/v1/sessions/:session/:io/append now exports both
{ action, loader }. The route builder installs the OPTIONS
preflight handler on the loader; without a loader export, the
preflight returned 400 ("No loader for route") and Chrome
surfaced the follow-up POST as net::ERR_FAILED. Same pattern
already in use on /api/v1/tasks/:id/trigger.
references/ai-chat
- Switch both chat-app.tsx and chat-view.tsx from
accessToken: getChatToken to triggerTask: triggerChat. This path
has the server action create the Session server-side with the
secret key, so the browser never hits POST /api/v1/sessions and
the returned PAT already carries the session scopes needed for
session.in/out.
- renewRunAccessTokenForChat(chatId, runId, sessionId?) now mints
tokens that include read:sessions:{sessionId} +
write:sessions:{sessionId} alongside the run scopes. Both call
sites thread the sessionId from the SDK's renew callback params.
- Drop executeJs / runInSecureSandbox / runInPRReviewSandbox to
decouple ai-chat trigger dev from the isolated-vm native binary
(its darwin-arm64 prebuild is broken against node 20.20.0 on
the current toolchain). Deletes src/lib/secure-sandbox.ts and
src/lib/pr-review-sandbox.ts, removes the executeJs tool from
chatTools, the secure-exec-bridge esbuild plugin from
trigger.config.ts (and its companion node-stdlib-browser-stub),
and the `secure-exec` dependency from package.json. E2B-backed
executeCode stays. If a future session needs the in-process V8
sandbox back, reintroduce through a different module (or pin a
prebuilt binary) to avoid this failure mode.
Smoke drove via the window.__chat bridge from Chrome DevTools MCP —
no click-based interaction needed.
Two coupled changes that together unblock end-to-end browser smoke
testing of TriggerChatTransport in the ai-chat reference and, more
broadly, let Next.js + Webpack client bundles pull types from
@trigger.dev/sdk/ai without hitting node: imports.
@trigger.dev/sdk
- New subpath @trigger.dev/sdk/ai/skills-runtime
(src/v3/agentSkillsRuntime.ts) owns the node-only skill tool
impls: runBashInSkill (node:child_process) + readFileInSkill
(node:fs/promises, node:path, with path-traversal guard).
- ai.ts drops the top-level node:child_process / node:fs/promises /
node:path imports. The auto-injected loadSkill / readFile / bash
tools in createAgentSkillTools() load the runtime via a
computed-string dynamic import (let path = "./agentSkillsRuntime.js";
await import(path)) — webpack can't statically trace the expression
so it drops the dependency from the client graph. Worker runtimes
resolve the relative import normally, so bash + readFile keep
working end-to-end on the server.
- Why it matters: even type-only imports from @trigger.dev/sdk/ai
(for example CompactionChunkData or the full tool-set type chain
that derives ChatUiMessage via InferUITools) trigger webpack to
trace ai.js. Pre-split, that trace hit node:child_process and
failed the client build with UnhandledSchemeError. With the split,
ai.ts's top-level graph is pure — no node: at the top — so type
consumers compile cleanly.
references/ai-chat
- components/chat.tsx extends the window.__chat test bridge with
session-era state (session / sessionId / lastEventId) and
generic waiters (waitForStatus + waitForMessage +
waitForFirstAssistantText) with configurable timeouts and clean
rejection. A driver (Chrome DevTools MCP, Playwright, etc.) can
now exercise the chat end-to-end through eval'd JS:
await window.__chat.send("hi");
const t = await window.__chat.waitForFirstAssistantText();
No more click-driven smokes. Existing steerOnToolCall +
steerAfterDelay / queueAfterDelay / promote helpers stay.
- Inlines a local structural CompactionChunkData type so
chat.tsx doesn't pull from @trigger.dev/sdk/ai for a single type
assertion. Defensive — the subpath split fixes the underlying
build issue, this just keeps the chat.tsx module graph minimal.
- Fixes a stale ChatSessionState shape in the DebugPanel session
prop type (sessionId is now optional, runId optional).
Known limitation (not in this commit)
Running the live UI smoke still requires a working chat-agent
backend for ai-chat, which depends on isolated-vm having a
prebuilt darwin-arm64 binary for node 20.20.0. On this machine
`pnpm rebuild isolated-vm` fails (node-gyp toolchain issue),
which is orthogonal to the session migration. Bridge
infrastructure is validated (all keys mount on window.__chat;
SDK tests 86/86 pass); exercising the send->stream->stop flow
end-to-end against the ai-chat agent is blocked on the native
build.
Migrates the dashboard's Agent tab (span inspector) onto the backing
Session's .out / .in channels so it stays in sync with
TriggerChatTransport, the server-side AgentChat, and the MCP chat
tools after the chat.agent -> Sessions migration.
Webapp
- SpanPresenter.server.ts extracts agentSession from the run payload:
prefers the explicit sessionId that TriggerChatTransport and
chat.createTriggerAction now thread through; falls back to chatId
for pre-Sessions agent runs (the session resource route accepts
either form via resolveSessionByIdOrExternalId).
- Span route (runs.$runParam.spans.$spanParam) threads agentSession
through AgentViewAuth. agentView is only minted when we have an
identifiable session — runs without one render a loading spinner
without subscribing.
- New dashboard resource route
resources.orgs.../runs.$runParam/realtime/v1/sessions/$sessionId/$io
proxies S2RealtimeStreams.streamResponseFromSessionStream under
dashboard session auth. The run param binds the resource hierarchy
(keeps callers from subscribing to arbitrary sessions); the session
identity is verified against the environment. GET-only — appends go
through the public session API, not the dashboard.
- AgentView.tsx:
- AgentViewAuth grows `sessionId: string`; `useAgentRunMessages`
threads it into the effect dep array and URL construction.
- Subscription URLs collapse from two run-scoped paths
(.../streams/{runId}/chat + .../streams/{runId}/input/chat-messages)
to one session base (.../sessions/{sessionId}/{out|in}).
- Local CHAT_STREAM_KEY / CHAT_MESSAGES_STREAM_ID constants dropped.
- `.in` parser switches from raw ChatTaskWirePayload to ChatInputChunk
tagged union: only kind: "message" chunks surface user messages
(pulled from chunk.payload.messages); kind: "stop" is ignored.
- `.out` parsing is unchanged — session v2 SSE already delivers
parsed UIMessageChunk objects via record.body.data.
SDK type fixes (byproducts)
- TriggerChatTransportOptions.sessions.sessionId is now optional so
pre-Sessions localStorage state (chatId -> {runId, token, lastEventId})
hydrates without migration. The runtime already `continue`s when
sessionId is missing and lets ensureSession upsert on next send;
the type just catches up.
- chat.test.ts session-change accumulator shape widened to match the
new runtime state (adds optional runId / sessionId fields).
Smoke
Opened a completed test-agent run (sessionId threaded via prior smoke
test) in the dashboard. Agent tab rendered:
- user message from initialMessages seed
- assistant reply streamed over session.out
Both SSE endpoints returned 200; no console errors. Full SDK test
suite still passes (86/86).
Rewires the three MCP agent-chat tools onto the Session primitive so
they stay in sync with TriggerChatTransport and the server-side
AgentChat after the chat.agent -> Sessions migration.
Tools affected: start_agent_chat, send_agent_message, close_agent_chat.
All live in packages/cli-v3/src/mcp/tools/agentChat.ts.
Changes
- Drop imports of CHAT_STREAM_KEY / CHAT_MESSAGES_STREAM_ID /
CHAT_STOP_STREAM_ID from @trigger.dev/core/v3/chat-client. Add a
local ChatInputChunk type + serializeInputChunk helper that mirrors
the transport's wire format (JSON.stringify({ kind, payload })).
- start_agent_chat: call apiClient.createSession({ type:
"chat.agent", externalId: chatId }) before triggering. The call is
idempotent on externalId so two MCP clients targeting the same
chatId converge. Thread sessionId into the trigger payload so the
agent's sessions.open(payload.sessionId) finds the backing session.
- send_agent_message: replace
sendInputStream(runId, CHAT_MESSAGES_STREAM_ID, payload) with
appendToSessionStream(sessionId, "in",
serializeInputChunk({ kind: "message", payload })). Fall-back path
on send failure re-triggers on the same session (reuse sessionId,
swap runId) instead of creating a new chat.
- close_agent_chat: send { kind: "message", payload: { trigger:
"close", ... } } via appendToSessionStream so the agent's turn loop
exits cleanly — matches the transport's close semantics.
- collectAgentResponse: subscribe URL moves from
/realtime/v1/streams/{runId}/chat to
/realtime/v1/sessions/{sessionId}/out. Session SSE uses v2/batch
format which already delivers parsed UIMessageChunk objects via
record.body.data, so the chunk-switch logic is unchanged.
trigger:upgrade-required path keeps the same session and triggers
a new run — previously it reused the old /streams/{newRunId}/chat
URL, now the URL is stable across runs on the same session.
- Scopes: write:inputStreams -> read:sessions + write:sessions. The
former was the transport's old input-stream write capability; the
session endpoints are the new surface.
- ChatSession state grows a sessionId field (friendlyId session_*).
runId stays but is now a live-run hint rather than durable identity.
Known limitation: the MCP server binary was spawned by Claude Code
at session start from the pre-migration bundle and stays in memory
for the lifetime of the Claude session — runtime verification has to
wait for the next session restart. Build passes; dist bundle
contains the new createSession / appendToSessionStream /
realtime/v1/sessions / Session ID references.
On-ramp doc for future Claude sessions and customer-facing docs.
Captures the state of the chat.agent system after the Session
migration (phases A-E + test infra) so the next session doesn't
have to reconstruct it from git log + code:
- Why the migration (run-scoped streams -> Session primitive).
- Session primitive crash course (externalId idempotency,
SessionHandle.in / .out, SSE resume, S2 direct writes).
- Chat mapping: one Session per chat, externalId = chatId,
ChatInputChunk tagged union, session-scoped PATs.
- ASCII flow diagrams for first message / subsequent turns / stop /
upgrade-required.
- Module layout (SDK / core / webapp), token mint sites (3 of them),
key invariants, public API surface (unchanged / grown / added).
- Known follow-ups (Phase F deferred: MCP agentChat tool, AgentView
dashboard component, ai-chat Next.js UI smoke, constant deletion).
- Smoke test sequences + git trail so a future reader can bisect.
Lives under .claude/architecture/ (repo-local notes directory, not
shipped to customers).
Unblocks the unit tests after the chat.agent -> Sessions migration
(phases B/C/D). Before: 43 passed / 43 failed (35 in chat.test.ts + 10
mockChatAgent + 2 skillsRuntime). After: 86 passed / 0 failed.
Core (@trigger.dev/core/v3/test)
- TestSessionStreamManager: in-memory SessionStreamManager keyed on
(sessionId, io) mirroring TestInputStreamManager. Dispatch rules
match production with one test-only tweak — when a record arrives
and only handlers are registered (no .once waiter), it's buffered
for the next once() instead of discarded. Production doesn't need
this because the SSE tail naturally serializes records after the
agent's turn-loop has re-registered a waiter; tests send
synchronously right after turn-complete, so without the buffer
the next waitWithIdleTimeout loses the message.
- runInMockTaskContext installs the manager via
sessionStreams.setGlobalManager, exposes drivers.sessions.in.send
/ .close, and tears down on exit.
SDK (@trigger.dev/sdk/v3/test)
- __setSessionOpenImplForTests hook in sessions.ts lets the harness
override sessions.open(id) with an in-memory SessionHandle.
SessionHandle constructor now accepts { in?, out? } overrides.
- TestSessionOutputChannel extends SessionOutputChannel and
intercepts pipe / writer / append into a shared TestSessionOutState
(chunks + listener registry). Never constructs SessionStreamInstance
so it avoids initializeSessionStream / StreamsWriterV2 entirely.
- mockChatAgent rewritten: drops CHAT_MESSAGES_STREAM_ID /
CHAT_STOP_STREAM_ID / the "chat" output stream key. sendMessage /
sendRegenerate / sendAction push ChatInputChunk { kind: "message",
payload } via drivers.sessions.in.send. sendStop pushes
{ kind: "stop" }. Turn-complete detection moves from
drivers.outputs.onWrite to a TestSessionOutputChannel listener.
chat.test.ts
- New URL-predicate helpers at the top (isSessionCreateUrl,
isTriggerTaskUrl, isSessionOutSubscribeUrl,
isSessionStreamAppendUrl) + defaultSessionCreateResponse /
defaultAppendResponse so every global.fetch mock speaks the
same vocabulary.
- Bulk-updated all 25 mock blocks: added session-create handler
(transport's accessToken path now lazily upserts via POST
/api/v1/sessions before trigger), swapped /realtime/v1/streams/
for /realtime/v1/sessions/ URL matchers, and replaced
(streams/ && /input/) append-URL matchers with
isSessionStreamAppendUrl.
- Three tests updated for new semantics: onSessionChange fires
twice on first message (ensureSession -> sessionId only, then
triggerNewRun -> adds runId + isStreaming). Async-token call
count goes 1 -> 2 on first message because ensureSession and
trigger both resolve the token with purpose: "trigger".
- "minimal wire payloads" test's body parsing updated — session.in
append body is a raw JSON.stringify({ kind, payload }) string,
not a { data } wrapper.
- Replaced the vestigial "custom streamKey URL" test with a
"subscribes to the backing Session's .out" assertion. streamKey
option is a no-op under sessions; removal can land in a follow-up.
- One test (stream closes without control chunk) legitimately
needs 9s for SSE-close fallback — bumped its timeout to 15s.
pipeChat (the internal that auto-pipes a chat.agent's returned
streamText result to the chat output) was still calling
streams.pipe(CHAT_STREAM_KEY, stream) — a run-scoped run-streams
path. After the session migration, the module-level facades
(chatStream, messagesInput, stopInput) routed correctly, but
pipeChat bypassed the facade and went straight to the old
run-scoped pipe. Result: the turn-complete control chunk reached
the session.out subscriber (written via chatStream.writer in
writeTurnCompleteChunk) but every streamed UIMessageChunk from the
LLM's turn was written to the dead run-scoped stream and never
surfaced on session.out.
Swap the pipe target to chatStream.pipe (the session-routed
facade). The target / streamKey options still type-check for
API parity but are no longer meaningful — sessions are the
address, and sub-agents that need to write to a parent chat open
the parent's Session explicitly. Smoke now catches all 14 chunks
(start / start-step / text-start / 7x text-delta / text-end /
finish-step / finish / trigger:turn-complete) with ids 0 through
13 from session.out, match: true.
Also adds references/hello-world/src/trigger/chatAgentSmoke.ts —
end-to-end validation:
- sessions.create with externalId = chatId
- trigger test-agent with {chatId, sessionId, messages, …}
- handle.out.read(...) SSE subscribe, capture chunks by id+type
- sessions.close on completion
Triggered from the dashboard or MCP as chat-agent-smoke. Requires
OPENAI_API_KEY in the dev env (the test-agent uses
openai:gpt-4o-mini).
Rewires the server-side AgentChat class in chat-client.ts onto the
Session primitive, matching the browser transport's shape.
- ChatSession persistence and SessionState internal state now key on
sessionId (friendlyId). runId is optional, just a 'live run' hint.
- triggerNewRun upserts the backing Session via sessions.create
(idempotent on externalId = chatId) before triggering so sessionId
rides along in payload.
- sendRaw, steer, sendAction, close, stop all go through
appendToSessionStream(sessionId, 'in',
serializeInputChunk({kind: …})). Stop becomes {kind: 'stop'};
messages become {kind: 'message', payload}; actions and close go
through the message payload with trigger='action' / 'close'.
- Subscribe moves from /realtime/v1/streams/{runId}/chat to
/realtime/v1/sessions/{sessionId}/out. Records arrive as JSON
strings; the loop parses them back into objects before the
trigger:turn-complete / trigger:upgrade-required dispatch.
- Upgrade-required path keeps the same Session, swaps runId only.
- Drops the CHAT_STREAM_KEY / CHAT_MESSAGES_STREAM_ID /
CHAT_STOP_STREAM_ID imports from chat-constants.js — chat-client.ts
no longer references the legacy stream keys (the constants file
itself will be deleted in Phase F along with the three references
still in ai.ts's re-exports and chat-constants.ts itself).
Server-side auth uses apiClientManager.accessToken (the env secret
key), which has full scopes — no token-scoping changes needed here.
The browser transport's token-scope updates (Phase E) already cover
the client side.
Rewires chat.agent's internal I/O and TriggerChatTransport's send +
subscribe paths onto the Session primitive. Minimum token-scope work
included so the transport's session endpoints actually authenticate.
Phase B — chat.agent internals (ai.ts)
- New ChatInputChunk tagged union (`kind: "message" | "stop"`) —
replaces the two-stream split (chat-messages + chat-stop) with a
single Session `.in` channel.
- New chatSessionHandleKey locals slot populated at run start from
`payload.sessionId ?? payload.chatId`. Every module-level helper
now resolves to the per-run session handle.
- Module-level `chatStream`, `messagesInput`, `stopInput` become thin
facades over the session. `chatStream` mirrors
`RealtimeDefinedStream<UIMessageChunk>` and delegates to
`handle.out`. `messagesInput` / `stopInput` mirror
`RealtimeDefinedInputStream<…>` and filter `.in` by kind — the two
internal `.on()`/`.waitWithIdleTimeout()` callers and the
`chat.messages` / `chat.createStopSignal` public exposures keep
their existing shapes.
- Every `streams.writer(CHAT_STREAM_KEY, …)` callsite swaps to
`chatStream.writer(…)` so all chat output flows through
`session.out` → `SessionStreamInstance` → direct-to-S2.
- Threaded `sessionId` through `ChatTaskWirePayload` /
`ChatTaskPayload` / `ChatTaskRunPayload` so advanced users can
`sessions.open(sessionId)` directly from `run()`.
Phase C — TriggerChatTransport (chat.ts)
- `ChatSessionState` keys durable identity on `sessionId` (friendlyId);
`runId` becomes an optional hint about whether a run is live.
- `ensureSession(chatId)` lazily upserts the Session via
`apiClient.createSession({type: "chat.agent", externalId: chatId})`
on the direct `accessToken` path. Idempotent — two tabs on the
same chat converge.
- `sendMessages`, `sendPendingMessage`, `stopGeneration`,
`sendAction` all go through `appendToSessionStream(sessionId, "in",
serializeInputChunk({kind: …}))` — one endpoint, one tag per
record.
- SSE subscribe URL moves from `/realtime/v1/streams/{runId}/chat` to
`/realtime/v1/sessions/{sessionId}/out`. The old run-scoped
`subscribeToStream` is replaced by `subscribeToSessionStream`.
Incoming chunks come back as JSON strings on the session channel
(server wraps records as `{data, id}` on S2), so the subscribe
loop parses them back into objects to keep the rest of the control
flow (turn-complete / upgrade-required / skipToTurnComplete)
unchanged.
- Upgrade-required re-trigger keeps the same Session and swaps only
the runId + token.
- `getSession` / `setSession` / `setOnSessionChange` / persistence
shape all grow a `sessionId` field (runId now optional).
Phase E — minimum token scopes
- `chat.createTriggerAction` (server side) now creates the Session
before triggering so it can (a) thread `sessionId` into the run
payload and (b) mint a token with both run and session scopes.
Returns `sessionId` in its result so the transport can skip its
own `sessions.create` call on the server-side-trigger path.
- `TriggerChatTaskResult` gains optional `sessionId`.
- The two in-run PAT refresh sites (preloadAccessToken,
turnAccessToken) add `read:sessions:{sessionId}` +
`write:sessions:{sessionId}` alongside the existing run scopes.
Known follow-ups (deferred to later passes)
- Phase D: `AgentChat` / `ChatStream` in chat-client.ts still uses
the old `/realtime/v1/streams/{runId}/chat` path. Used by server-
side task-to-task compositions, not the browser transport.
- Phase F: delete CHAT_STREAM_KEY, CHAT_MESSAGES_STREAM_ID,
CHAT_STOP_STREAM_ID from chat-constants.ts + ai-chat smoke verify.
Build the client-side half of the Session channel extensions that the
sessions PR shipped on the server. Pairs with POST
/api/v1/runs/:runFriendlyId/session-streams/wait and the
append-fires-waitpoints wiring on the session append handler.
Extend SessionHandle with two asymmetric channels mirroring the
run-scoped streams primitives:
- .in (SessionInputChannel) mirrors streams.input. on / once / peek /
wait / waitWithIdleTimeout for the task to consume; send for
external clients to produce. .wait / .waitWithIdleTimeout suspend
the run on a session-stream waitpoint; it resumes when a record
lands on .in, same semantics as streams.input.wait on a run-scoped
input stream.
- .out (SessionOutputChannel) mirrors streams.define. append / pipe /
writer for the task to produce records — all three route through
SessionStreamInstance -> StreamsWriterV2 for uniform parsed-object
serialization on the subscribe side. read returns an SSE subscription
for external consumers.
The two channels are disjoint classes with zero overlapping methods.
SessionHandle is { id, in, out } so directional tags stay at every
call site. No public initialize() — S2 credentials are an internal
detail of pipe / writer.
Core
- StandardSessionStreamManager + sessionStreams global: SSE-backed
tail with once/on/peek buffering, auto-reconnect, lastSeqNum
resume. Keyed on {sessionId, io}. Registered in dev- and managed-
run workers; taskExecutor clears handlers at run end alongside
input streams.
- SessionStreamInstance: S2-only parallel of StreamInstance. Fetches
session S2 creds via initializeSessionStream and pipes through
StreamsWriterV2.
- ApiClient.createSessionStreamWaitpoint — calls the new server route.
Reference
- references/hello-world/src/trigger/sessionsSmoke.ts now exercises
.out.writer alongside .out.append.
- references/hello-world/src/trigger/sessionsWaitSmoke.ts (new) —
end-to-end waitpoint validation. Orchestrator suspends on
.in.waitWithIdleTimeout; a delayed sender task fires the waitpoint
via .in.send; orchestrator resumes with the payload. match: true.
Client-side pair to the Session primitive server PR (TRI-8627).
Run-scoped streams.pipe / streams.input are untouched.
@trigger.dev/core ApiClient
- createSession / retrieveSession / updateSession / closeSession —
zodfetch against /api/v1/sessions control plane
- listSessions — CursorPagePromise<SessionItem>, follows the runs/waitpoints
convention (page[size], page[after], page[before] + filter[*])
- initializeSessionStream — PUT /realtime/v1/sessions/:session/:io,
returns S2 creds in headers (feeds StreamsWriterV2 directly)
- appendToSessionStream — POST …/append
- subscribeToSessionStream — reuses SSEStreamSubscription for SSE
subscribes (auto-retry, Last-Event-ID resume, abort propagation), so
session subscribers get the exact same semantics as runs.fetchStream.
Returns AsyncIterableStream<T>.
@trigger.dev/sdk sessions namespace
- sessions.create / retrieve / update / close / list — wraps the ApiClient
with the standard tracer + accessoryAttributes + mergeRequestOptions.
Returns ApiPromise / CursorPagePromise.
- sessions.open(id) returns a SessionHandle with .out and .in
SessionChannels. Each channel exposes append / send / subscribe /
initialize. The handle is polymorphic on friendlyId or externalId.
- auth.ts adds the `sessions` permission on PublicTokenPermissionProperties
so auth.createPublicToken({ read: { sessions: ["session_abc"] } }) works.
Reference
- references/hello-world/src/trigger/sessionsSmoke.ts — idempotent
Trigger.dev task that exercises every code path (control-plane CRUD,
polymorphic lookup, list with tag/type/status/externalId filters, cursor
pagination, out.initialize + append + subscribe SSE round-trip, in.send,
close + idempotent re-close). Trigger via
mcp__trigger__trigger_task(taskId: "sessions-smoke").
Verified live against the local webapp (project hello-world): 10/10
steps pass end-to-end, S2 round-trip returns appended chunks through the
shared SSEStreamSubscription pipeline.