Skip to content

feat(webapp,core,sdk,cli): bound session.out via per-turn trim#3644

Open
ericallam wants to merge 1 commit into
mainfrom
feat/bound-session-out
Open

feat(webapp,core,sdk,cli): bound session.out via per-turn trim#3644
ericallam wants to merge 1 commit into
mainfrom
feat/bound-session-out

Conversation

@ericallam
Copy link
Copy Markdown
Member

Summary

Long-running chat agents were filling session.out forever — every chat.agent turn appended to the same S2 stream with no trim, and the Sessions dashboard re-streamed the entire history from seq_num=0 on every page load. After this change the agent appends an S2 trim command record after each trigger:turn-complete, pointing back at the previous turn-complete's seq_num. session.out stays roughly one turn long at steady state, regardless of session age.

trigger:turn-complete and trigger:upgrade-required move from chunk.type-shaped data records into header-form control records under a uniform trigger-control namespace. Built-in transports (TriggerChatTransport, AgentChat, the dashboard's AgentView) handle the new shape transparently. Custom transports need a one-line filter on the trigger-control header — see the rewritten "Records on session.out" section in the client-protocol docs.

The Sessions detail page in the dashboard fetches the agent's per-turn S3 snapshot via a presigned URL and seeds the transcript view, then SSE-tails from the snapshot's lastOutEventId. Bandwidth and time-to-first-render scale with unread turns instead of session lifetime.

Resume contract is now explicit: single-turn-boundary resume always works (the prior turn-complete is still on the stream), the S2 trim is eventually consistent over 10-60s, and multi-turn-away resume falls back to a snapshot reload.

Test plan

  • pnpm run build --filter @trigger.dev/core --filter @trigger.dev/sdk
  • pnpm run typecheck --filter webapp
  • pnpm exec vitest --run in packages/core and packages/trigger-sdk (523 + 176 tests)
  • ai-chat-e2e smoke sweep — T01–T18 plus the new T-DASH-01..05 dashboard suite. Trim chain verified by reading session.out directly: ~13 records after 5 turns (was 53 pre-trim).

After each `trigger:turn-complete`, the agent appends an S2 `trim`
command back to the previous turn-complete's seq_num. `session.out`
stays roughly one turn long at steady state, regardless of how long
the chat has been running.

`trigger:turn-complete` and `trigger:upgrade-required` move from
`chunk.type`-shaped data records into header-form control records
under a `trigger-control` namespace. Built-in transports
(`TriggerChatTransport`, `AgentChat`, dashboard `AgentView`) handle
this transparently; custom transports need a one-line filter on the
`trigger-control` header.

The Sessions detail page in the dashboard fetches the per-turn S3
snapshot via a presigned URL and seeds the transcript view, then
SSE-tails from the snapshot's `lastOutEventId`. Bandwidth and
time-to-first-render scale with unread turns, not session lifetime.
@changeset-bot
Copy link
Copy Markdown

changeset-bot Bot commented May 16, 2026

⚠️ No Changeset found

Latest commit: b3aa85b

Merging this PR will not cause a version bump for any packages. If these changes should not result in a new version, you're good to go. If these changes should result in a version bump, you need to add a changeset.

Click here to learn what changesets are, and how to add one.

Click here if you're a maintainer who wants to add a changeset to this PR

@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai Bot commented May 16, 2026

Review Change Stack

Walkthrough

This PR implements session streaming control records and snapshot hydration. It introduces a new wire protocol for identifying control events (turn-complete, upgrade-required) via S2 record headers instead of chunk type inspection, updates SSE batch parsing to preserve per-record headers, and adds dashboard snapshot presigning/loading with SSE resumption. The S2 stream backend now defensively parses batch records and determines settlement by detecting control headers. New one-shot APIs enable writing control records to session streams. The SDK runtime modules refactor to route control events via callbacks instead of chunk parsing. Chat snapshot persistence enables fast dashboard recovery from stored message state.

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~60 minutes

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 63.64% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title 'feat(webapp,core,sdk,cli): bound session.out via per-turn trim' is specific, concise, and accurately reflects the main change in the PR—bounding the session.out stream by adding per-turn trim logic across multiple packages.
Description check ✅ Passed The PR description is comprehensive and well-structured, covering the summary of changes, the test plan with specific commands, and explaining the architectural improvements around session stream trimming and control record handling.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/bound-session-out

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@ericallam ericallam marked this pull request as ready for review May 16, 2026 22:25
Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 7

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
packages/trigger-sdk/src/v3/chat-server.ts (1)

559-604: ⚠️ Potential issue | 🟠 Major | 🏗️ Heavy lift

Don't let onControl outrun the stitched handover stream.

agentStream never yields control records, so the pure-text handover path can hang here forever: onControl flips turnComplete, but Line 597 only wakes up on another data chunk. In the common isFinal: true case there may be no post-handover data at all. Even when data is buffered, enqueueing the synthetic trigger:turn-complete inside onControl can put the marker ahead of the last queued chunk(s), or break before they are drained.

Please drive this phase from a single ordered source (onPart/raw SSE ordering) or defer emitting/completing until you've drained everything queued before the control record.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/trigger-sdk/src/v3/chat-server.ts` around lines 559 - 604, The
current code enqueues the synthetic turn-complete inside onControl which races
with buffered data from agentStream; instead, record the turn-complete event but
do not emit it from onControl: capture latestEventId in onPart (already done)
and in onControl set a new variable turnCompleteEventId = latestEventId and
turnComplete=true (do not controller.enqueue there), then inside the for-await
loop for agentStream enqueue each chunk as now and after enqueuing check if
chunk.id === turnCompleteEventId to then controller.enqueue the synthetic {
type: "trigger:turn-complete" } and break; finally, after the loop if
turnComplete is true but the synthetic marker hasn’t been emitted, enqueue it
once more so the handover never hangs (references: agentStream, onPart,
onControl, latestEventId, turnCompleteEventId, turnComplete,
controller.enqueue).
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@apps/webapp/app/services/realtime/s2realtimeStreams.server.ts`:
- Around line 261-293: Harden parsing by validating each parsed.records entry
with a zod schema before accessing fields: create a zod schema for the incoming
record shape (body?: string, seq_num?: number, timestamp?: number, headers?:
Array<[string,string]>), use it in parseSSEBatchRecords() to parse/guard each
record and skip any that fail validation so one bad entry doesn't drop the whole
batch (only push records that pass and map to {data, id, seqNum, headers} after
safe JSON parse of record.body); likewise update peekIsSettled() to defensively
check/validate each element of parsed.records (or reuse the same zod schema)
before reading record.headers/seq_num to avoid null/undefined access; follow the
repo guideline by using zod from packages/core or apps/webapp for these
validations.
- Around line 594-599: The S2 access token returned by initializeSessionStream
currently grants the "trim" op to clients; restrict client-exposed tokens to
only ["append","create-stream"] and keep "trim" available only to server-side
logic by producing two scope variants (server token with "trim" for
sessionStreamOneshot.ts and ai.ts, and a client token without "trim" for
responses emitted in realtime.v1.sessions.$session.$io.ts and
realtime.v1.streams.$runId.$target.$streamId.ts), or add an explicit scopes
parameter to initializeSessionStream and call it with the limited scopes for
client responses while using the full-scope call internally where trims are
performed.

In `@packages/cli-v3/src/mcp/tools/agentChat.ts`:
- Around line 437-439: Remove the redundant explicit reader.releaseLock() call
on the upgrade/recursion path inside collectAgentResponse; the reader is already
cleaned up by the function's finally block, so delete the reader.releaseLock()
just before the recursive return (the return collectAgentResponse(session, depth
+ 1) line) and rely on the existing finally cleanup for the reader to avoid
duplicate/tempting manual cleanup.

In `@packages/core/src/v3/apiClient/runStream.ts`:
- Around line 179-186: The doc comment on the SSEStreamPart struct is reversed:
update the comment for SSEStreamPart to state that trigger-control records are
identified by a "trigger-control" header, while an empty-name first header
denotes S2 command records (which are already filtered before enqueue), and
clarify that headers are undefined for v1 streams and empty array when no
headers; keep the note about first-header empty-name being the Trigger control
protocol marker corrected to match the wire contract.
- Around line 385-426: Wrap per-record processing with a Zod validation step:
define a zod schema for each element (fields: body: string, seq_num: number,
timestamp: number, headers?: Array<[string,string]>), run
schema.safeParse(record) and if it fails skip the element (do not dereference
record.seq_num or record.timestamp). Only set this.lastEventId when seq_num is
present/valid from the parsed record; preserve the existing behavior of skipping
S2 command records (headers[0][0] === "") and duplicate IDs via seenIds, and
enqueue using the validated parsed values when parsedBody is present before
calling chunkController.enqueue. Ensure safeParseJSON usage and parsedBody logic
remain unchanged but operate on validated record bodies.

In `@packages/trigger-sdk/src/v3/sessions.ts`:
- Around line 481-500: SessionOutputChannel.read() currently only returns parsed
chunks and onPart without headers, so consumers can't see control records like
"turn-complete" or "upgrade-required"; update SessionOutputChannel.read (and any
onPart/onEvent callbacks it exposes) to surface control records and their
headers, and ensure when subscribing you pass an onControl handler into
ApiClient.subscribeToSessionStream() so control frames aren't dropped;
specifically wire the onControl callback through the code path that calls
ApiClient.subscribeToSessionStream() and propagate control metadata (headers and
subtype) into the SessionOutputChannel.read/onPart API so callers can filter by
header or observe control events.

---

Outside diff comments:
In `@packages/trigger-sdk/src/v3/chat-server.ts`:
- Around line 559-604: The current code enqueues the synthetic turn-complete
inside onControl which races with buffered data from agentStream; instead,
record the turn-complete event but do not emit it from onControl: capture
latestEventId in onPart (already done) and in onControl set a new variable
turnCompleteEventId = latestEventId and turnComplete=true (do not
controller.enqueue there), then inside the for-await loop for agentStream
enqueue each chunk as now and after enqueuing check if chunk.id ===
turnCompleteEventId to then controller.enqueue the synthetic { type:
"trigger:turn-complete" } and break; finally, after the loop if turnComplete is
true but the synthetic marker hasn’t been emitted, enqueue it once more so the
handover never hangs (references: agentStream, onPart, onControl, latestEventId,
turnCompleteEventId, turnComplete, controller.enqueue).
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro

Run ID: cc299022-9f31-46c9-8b2e-38a04b6a60d7

📥 Commits

Reviewing files that changed from the base of the PR and between a8280f1 and b3aa85b.

📒 Files selected for processing (23)
  • apps/webapp/app/components/runs/v3/agent/AgentView.tsx
  • apps/webapp/app/presenters/v3/SessionPresenter.server.ts
  • apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.sessions.$sessionParam/route.tsx
  • apps/webapp/app/services/realtime/s2realtimeStreams.server.ts
  • apps/webapp/app/services/realtime/types.ts
  • packages/cli-v3/src/mcp/tools/agentChat.ts
  • packages/core/src/v3/apiClient/index.ts
  • packages/core/src/v3/apiClient/runStream.test.ts
  • packages/core/src/v3/apiClient/runStream.ts
  • packages/core/src/v3/realtime-streams-api.ts
  • packages/core/src/v3/realtimeStreams/index.ts
  • packages/core/src/v3/realtimeStreams/sessionStreamOneshot.ts
  • packages/core/src/v3/session-streams-api.ts
  • packages/core/src/v3/sessionStreams/chatSnapshot.ts
  • packages/core/src/v3/sessionStreams/manager.ts
  • packages/core/src/v3/sessionStreams/wireProtocol.ts
  • packages/trigger-sdk/src/v3/ai.ts
  • packages/trigger-sdk/src/v3/chat-client.ts
  • packages/trigger-sdk/src/v3/chat-server.ts
  • packages/trigger-sdk/src/v3/chat.test.ts
  • packages/trigger-sdk/src/v3/chat.ts
  • packages/trigger-sdk/src/v3/sessions.ts
  • packages/trigger-sdk/src/v3/test/test-session-handle.ts
📜 Review details
⏰ Context from checks skipped due to timeout of 90000ms. You can increase the timeout in your CodeRabbit configuration to a maximum of 15 minutes (900000ms). (28)
  • GitHub Check: webapp / 🧪 Unit Tests: Webapp (7, 8)
  • GitHub Check: webapp / 🧪 Unit Tests: Webapp (3, 8)
  • GitHub Check: webapp / 🧪 Unit Tests: Webapp (1, 8)
  • GitHub Check: webapp / 🧪 Unit Tests: Webapp (8, 8)
  • GitHub Check: webapp / 🧪 Unit Tests: Webapp (5, 8)
  • GitHub Check: webapp / 🧪 Unit Tests: Webapp (4, 8)
  • GitHub Check: webapp / 🧪 Unit Tests: Webapp (2, 8)
  • GitHub Check: webapp / 🧪 Unit Tests: Webapp (6, 8)
  • GitHub Check: sdk-compat / Node.js 22.12 (ubuntu-latest)
  • GitHub Check: internal / 🧪 Unit Tests: Internal (7, 8)
  • GitHub Check: sdk-compat / Node.js 20.20 (ubuntu-latest)
  • GitHub Check: internal / 🧪 Unit Tests: Internal (5, 8)
  • GitHub Check: internal / 🧪 Unit Tests: Internal (8, 8)
  • GitHub Check: internal / 🧪 Unit Tests: Internal (6, 8)
  • GitHub Check: internal / 🧪 Unit Tests: Internal (3, 8)
  • GitHub Check: internal / 🧪 Unit Tests: Internal (4, 8)
  • GitHub Check: internal / 🧪 Unit Tests: Internal (1, 8)
  • GitHub Check: internal / 🧪 Unit Tests: Internal (2, 8)
  • GitHub Check: sdk-compat / Bun Runtime
  • GitHub Check: e2e / 🧪 CLI v3 tests (windows-latest - pnpm)
  • GitHub Check: e2e / 🧪 CLI v3 tests (windows-latest - npm)
  • GitHub Check: e2e / 🧪 CLI v3 tests (ubuntu-latest - pnpm)
  • GitHub Check: e2e / 🧪 CLI v3 tests (ubuntu-latest - npm)
  • GitHub Check: sdk-compat / Cloudflare Workers
  • GitHub Check: sdk-compat / Deno Runtime
  • GitHub Check: typecheck / typecheck
  • GitHub Check: packages / 🧪 Unit Tests: Packages (1, 1)
  • GitHub Check: e2e-webapp / 🧪 E2E Tests: Webapp
🧰 Additional context used
📓 Path-based instructions (17)
**/*.{ts,tsx}

📄 CodeRabbit inference engine (.github/copilot-instructions.md)

**/*.{ts,tsx}: Use types over interfaces for TypeScript
Avoid using enums; prefer string unions or const objects instead

**/*.{ts,tsx}: Import from @trigger.dev/core subpaths only, never from the root. Subpath imports must be used to maintain proper module boundaries.
When writing Trigger.dev tasks, always import from @trigger.dev/sdk. Never use @trigger.dev/sdk/v3 or deprecated client.defineJob.
Prisma is version 6.14.0. Use the Prisma client from internal-packages/database for all database operations.
For ClickHouse client, schema migrations, and analytics queries, use internal-packages/clickhouse.

Files:

  • packages/core/src/v3/realtimeStreams/index.ts
  • packages/core/src/v3/realtime-streams-api.ts
  • packages/core/src/v3/sessionStreams/chatSnapshot.ts
  • packages/core/src/v3/session-streams-api.ts
  • packages/trigger-sdk/src/v3/test/test-session-handle.ts
  • apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.sessions.$sessionParam/route.tsx
  • apps/webapp/app/services/realtime/types.ts
  • packages/core/src/v3/apiClient/runStream.test.ts
  • packages/trigger-sdk/src/v3/chat.test.ts
  • packages/core/src/v3/apiClient/runStream.ts
  • packages/core/src/v3/sessionStreams/wireProtocol.ts
  • packages/trigger-sdk/src/v3/chat-client.ts
  • packages/core/src/v3/sessionStreams/manager.ts
  • packages/trigger-sdk/src/v3/chat-server.ts
  • apps/webapp/app/presenters/v3/SessionPresenter.server.ts
  • packages/trigger-sdk/src/v3/sessions.ts
  • packages/cli-v3/src/mcp/tools/agentChat.ts
  • apps/webapp/app/services/realtime/s2realtimeStreams.server.ts
  • packages/core/src/v3/apiClient/index.ts
  • packages/trigger-sdk/src/v3/chat.ts
  • apps/webapp/app/components/runs/v3/agent/AgentView.tsx
  • packages/core/src/v3/realtimeStreams/sessionStreamOneshot.ts
  • packages/trigger-sdk/src/v3/ai.ts
{packages/core,apps/webapp}/**/*.{ts,tsx}

📄 CodeRabbit inference engine (.github/copilot-instructions.md)

Use zod for validation in packages/core and apps/webapp

Files:

  • packages/core/src/v3/realtimeStreams/index.ts
  • packages/core/src/v3/realtime-streams-api.ts
  • packages/core/src/v3/sessionStreams/chatSnapshot.ts
  • packages/core/src/v3/session-streams-api.ts
  • apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.sessions.$sessionParam/route.tsx
  • apps/webapp/app/services/realtime/types.ts
  • packages/core/src/v3/apiClient/runStream.test.ts
  • packages/core/src/v3/apiClient/runStream.ts
  • packages/core/src/v3/sessionStreams/wireProtocol.ts
  • packages/core/src/v3/sessionStreams/manager.ts
  • apps/webapp/app/presenters/v3/SessionPresenter.server.ts
  • apps/webapp/app/services/realtime/s2realtimeStreams.server.ts
  • packages/core/src/v3/apiClient/index.ts
  • apps/webapp/app/components/runs/v3/agent/AgentView.tsx
  • packages/core/src/v3/realtimeStreams/sessionStreamOneshot.ts
**/*.{ts,tsx,js,jsx}

📄 CodeRabbit inference engine (.github/copilot-instructions.md)

Use function declarations instead of default exports

Add crumbs as you write code — not just when debugging. Mark lines with // @Crumbs or wrap blocks in `// `#region` `@crumbs. They stay on the branch throughout development and are stripped by agentcrumbs strip before merge.

Files:

  • packages/core/src/v3/realtimeStreams/index.ts
  • packages/core/src/v3/realtime-streams-api.ts
  • packages/core/src/v3/sessionStreams/chatSnapshot.ts
  • packages/core/src/v3/session-streams-api.ts
  • packages/trigger-sdk/src/v3/test/test-session-handle.ts
  • apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.sessions.$sessionParam/route.tsx
  • apps/webapp/app/services/realtime/types.ts
  • packages/core/src/v3/apiClient/runStream.test.ts
  • packages/trigger-sdk/src/v3/chat.test.ts
  • packages/core/src/v3/apiClient/runStream.ts
  • packages/core/src/v3/sessionStreams/wireProtocol.ts
  • packages/trigger-sdk/src/v3/chat-client.ts
  • packages/core/src/v3/sessionStreams/manager.ts
  • packages/trigger-sdk/src/v3/chat-server.ts
  • apps/webapp/app/presenters/v3/SessionPresenter.server.ts
  • packages/trigger-sdk/src/v3/sessions.ts
  • packages/cli-v3/src/mcp/tools/agentChat.ts
  • apps/webapp/app/services/realtime/s2realtimeStreams.server.ts
  • packages/core/src/v3/apiClient/index.ts
  • packages/trigger-sdk/src/v3/chat.ts
  • apps/webapp/app/components/runs/v3/agent/AgentView.tsx
  • packages/core/src/v3/realtimeStreams/sessionStreamOneshot.ts
  • packages/trigger-sdk/src/v3/ai.ts
**/*.ts

📄 CodeRabbit inference engine (.cursor/rules/otel-metrics.mdc)

**/*.ts: When creating or editing OTEL metrics (counters, histograms, gauges), ensure metric attributes have low cardinality by using only enums, booleans, bounded error codes, or bounded shard IDs
Do not use high-cardinality attributes in OTEL metrics such as UUIDs/IDs (envId, userId, runId, projectId, organizationId), unbounded integers (itemCount, batchSize, retryCount), timestamps (createdAt, startTime), or free-form strings (errorMessage, taskName, queueName)
When exporting OTEL metrics via OTLP to Prometheus, be aware that the exporter automatically adds unit suffixes to metric names (e.g., 'my_duration_ms' becomes 'my_duration_ms_milliseconds', 'my_counter' becomes 'my_counter_total'). Account for these transformations when writing Grafana dashboards or Prometheus queries

Files:

  • packages/core/src/v3/realtimeStreams/index.ts
  • packages/core/src/v3/realtime-streams-api.ts
  • packages/core/src/v3/sessionStreams/chatSnapshot.ts
  • packages/core/src/v3/session-streams-api.ts
  • packages/trigger-sdk/src/v3/test/test-session-handle.ts
  • apps/webapp/app/services/realtime/types.ts
  • packages/core/src/v3/apiClient/runStream.test.ts
  • packages/trigger-sdk/src/v3/chat.test.ts
  • packages/core/src/v3/apiClient/runStream.ts
  • packages/core/src/v3/sessionStreams/wireProtocol.ts
  • packages/trigger-sdk/src/v3/chat-client.ts
  • packages/core/src/v3/sessionStreams/manager.ts
  • packages/trigger-sdk/src/v3/chat-server.ts
  • apps/webapp/app/presenters/v3/SessionPresenter.server.ts
  • packages/trigger-sdk/src/v3/sessions.ts
  • packages/cli-v3/src/mcp/tools/agentChat.ts
  • apps/webapp/app/services/realtime/s2realtimeStreams.server.ts
  • packages/core/src/v3/apiClient/index.ts
  • packages/trigger-sdk/src/v3/chat.ts
  • packages/core/src/v3/realtimeStreams/sessionStreamOneshot.ts
  • packages/trigger-sdk/src/v3/ai.ts
packages/core/**/*.{ts,tsx,js,jsx}

📄 CodeRabbit inference engine (packages/core/CLAUDE.md)

Never import the root package (@trigger.dev/core). Always use subpath imports such as @trigger.dev/core/v3, @trigger.dev/core/v3/utils, @trigger.dev/core/logger, or @trigger.dev/core/schemas

Files:

  • packages/core/src/v3/realtimeStreams/index.ts
  • packages/core/src/v3/realtime-streams-api.ts
  • packages/core/src/v3/sessionStreams/chatSnapshot.ts
  • packages/core/src/v3/session-streams-api.ts
  • packages/core/src/v3/apiClient/runStream.test.ts
  • packages/core/src/v3/apiClient/runStream.ts
  • packages/core/src/v3/sessionStreams/wireProtocol.ts
  • packages/core/src/v3/sessionStreams/manager.ts
  • packages/core/src/v3/apiClient/index.ts
  • packages/core/src/v3/realtimeStreams/sessionStreamOneshot.ts
**/*.{ts,tsx,js,jsx,json,md,css,scss}

📄 CodeRabbit inference engine (AGENTS.md)

Code formatting is enforced using Prettier. Run pnpm run format before committing

Files:

  • packages/core/src/v3/realtimeStreams/index.ts
  • packages/core/src/v3/realtime-streams-api.ts
  • packages/core/src/v3/sessionStreams/chatSnapshot.ts
  • packages/core/src/v3/session-streams-api.ts
  • packages/trigger-sdk/src/v3/test/test-session-handle.ts
  • apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.sessions.$sessionParam/route.tsx
  • apps/webapp/app/services/realtime/types.ts
  • packages/core/src/v3/apiClient/runStream.test.ts
  • packages/trigger-sdk/src/v3/chat.test.ts
  • packages/core/src/v3/apiClient/runStream.ts
  • packages/core/src/v3/sessionStreams/wireProtocol.ts
  • packages/trigger-sdk/src/v3/chat-client.ts
  • packages/core/src/v3/sessionStreams/manager.ts
  • packages/trigger-sdk/src/v3/chat-server.ts
  • apps/webapp/app/presenters/v3/SessionPresenter.server.ts
  • packages/trigger-sdk/src/v3/sessions.ts
  • packages/cli-v3/src/mcp/tools/agentChat.ts
  • apps/webapp/app/services/realtime/s2realtimeStreams.server.ts
  • packages/core/src/v3/apiClient/index.ts
  • packages/trigger-sdk/src/v3/chat.ts
  • apps/webapp/app/components/runs/v3/agent/AgentView.tsx
  • packages/core/src/v3/realtimeStreams/sessionStreamOneshot.ts
  • packages/trigger-sdk/src/v3/ai.ts
packages/**/*.{ts,tsx,js,jsx}

📄 CodeRabbit inference engine (CLAUDE.md)

When modifying any public package (packages/* or integrations/*), add a changeset using pnpm run changeset:add. Default to patch for bug fixes and minor changes; confirm with maintainers before selecting minor; never select major without explicit approval.

Files:

  • packages/core/src/v3/realtimeStreams/index.ts
  • packages/core/src/v3/realtime-streams-api.ts
  • packages/core/src/v3/sessionStreams/chatSnapshot.ts
  • packages/core/src/v3/session-streams-api.ts
  • packages/trigger-sdk/src/v3/test/test-session-handle.ts
  • packages/core/src/v3/apiClient/runStream.test.ts
  • packages/trigger-sdk/src/v3/chat.test.ts
  • packages/core/src/v3/apiClient/runStream.ts
  • packages/core/src/v3/sessionStreams/wireProtocol.ts
  • packages/trigger-sdk/src/v3/chat-client.ts
  • packages/core/src/v3/sessionStreams/manager.ts
  • packages/trigger-sdk/src/v3/chat-server.ts
  • packages/trigger-sdk/src/v3/sessions.ts
  • packages/cli-v3/src/mcp/tools/agentChat.ts
  • packages/core/src/v3/apiClient/index.ts
  • packages/trigger-sdk/src/v3/chat.ts
  • packages/core/src/v3/realtimeStreams/sessionStreamOneshot.ts
  • packages/trigger-sdk/src/v3/ai.ts
packages/trigger-sdk/**/*.{ts,tsx}

📄 CodeRabbit inference engine (.github/copilot-instructions.md)

In the Trigger.dev SDK (packages/trigger-sdk), prefer isomorphic code like fetch and ReadableStream instead of Node.js-specific code

Files:

  • packages/trigger-sdk/src/v3/test/test-session-handle.ts
  • packages/trigger-sdk/src/v3/chat.test.ts
  • packages/trigger-sdk/src/v3/chat-client.ts
  • packages/trigger-sdk/src/v3/chat-server.ts
  • packages/trigger-sdk/src/v3/sessions.ts
  • packages/trigger-sdk/src/v3/chat.ts
  • packages/trigger-sdk/src/v3/ai.ts
packages/trigger-sdk/**/*.{ts,tsx,js,jsx}

📄 CodeRabbit inference engine (packages/trigger-sdk/CLAUDE.md)

Always import from @trigger.dev/sdk. Never use @trigger.dev/sdk/v3 (deprecated path alias)

Files:

  • packages/trigger-sdk/src/v3/test/test-session-handle.ts
  • packages/trigger-sdk/src/v3/chat.test.ts
  • packages/trigger-sdk/src/v3/chat-client.ts
  • packages/trigger-sdk/src/v3/chat-server.ts
  • packages/trigger-sdk/src/v3/sessions.ts
  • packages/trigger-sdk/src/v3/chat.ts
  • packages/trigger-sdk/src/v3/ai.ts
apps/webapp/**/*.{ts,tsx}

📄 CodeRabbit inference engine (.cursor/rules/webapp.mdc)

apps/webapp/**/*.{ts,tsx}: Access environment variables through the env export of env.server.ts instead of directly accessing process.env
Use subpath exports from @trigger.dev/core package instead of importing from the root @trigger.dev/core path

Use named constants for sentinel/placeholder values (e.g. const UNSET_VALUE = '__unset__') instead of raw string literals scattered across comparisons

Files:

  • apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.sessions.$sessionParam/route.tsx
  • apps/webapp/app/services/realtime/types.ts
  • apps/webapp/app/presenters/v3/SessionPresenter.server.ts
  • apps/webapp/app/services/realtime/s2realtimeStreams.server.ts
  • apps/webapp/app/components/runs/v3/agent/AgentView.tsx
apps/webapp/**/*.{tsx,jsx}

📄 CodeRabbit inference engine (apps/webapp/CLAUDE.md)

Only use useCallback/useMemo for context provider values, expensive derived data that is a dependency elsewhere, or stable refs required by a dependency array. Don't wrap ordinary event handlers or trivial computations

Files:

  • apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.sessions.$sessionParam/route.tsx
  • apps/webapp/app/components/runs/v3/agent/AgentView.tsx
apps/**/*.{ts,tsx,js,jsx}

📄 CodeRabbit inference engine (CLAUDE.md)

When modifying only server components (apps/webapp/, apps/supervisor/, etc.) with no package changes, add a .server-changes/ file instead of a changeset. See .server-changes/README.md for format and documentation.

Files:

  • apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.sessions.$sessionParam/route.tsx
  • apps/webapp/app/services/realtime/types.ts
  • apps/webapp/app/presenters/v3/SessionPresenter.server.ts
  • apps/webapp/app/services/realtime/s2realtimeStreams.server.ts
  • apps/webapp/app/components/runs/v3/agent/AgentView.tsx
**/*.{test,spec}.{ts,tsx}

📄 CodeRabbit inference engine (.github/copilot-instructions.md)

Use vitest for all tests in the Trigger.dev repository

Files:

  • packages/core/src/v3/apiClient/runStream.test.ts
  • packages/trigger-sdk/src/v3/chat.test.ts
**/*.test.{ts,tsx,js}

📄 CodeRabbit inference engine (AGENTS.md)

**/*.test.{ts,tsx,js}: Use vitest for unit testing and run tests with pnpm run test
Test files should live beside the files under test with descriptive describe and it blocks
Tests should avoid mocks or stubs and use helpers from @internal/testcontainers when Redis or Postgres are needed

Files:

  • packages/core/src/v3/apiClient/runStream.test.ts
  • packages/trigger-sdk/src/v3/chat.test.ts
**/*.test.{ts,tsx,js,jsx}

📄 CodeRabbit inference engine (CLAUDE.md)

**/*.test.{ts,tsx,js,jsx}: Use vitest exclusively for testing and never mock anything - use testcontainers instead.
Place test files next to source files (e.g., MyService.ts -> MyService.test.ts).

Files:

  • packages/core/src/v3/apiClient/runStream.test.ts
  • packages/trigger-sdk/src/v3/chat.test.ts
apps/webapp/**/*.server.ts

📄 CodeRabbit inference engine (apps/webapp/CLAUDE.md)

apps/webapp/**/*.server.ts: Never use request.signal for detecting client disconnects. Use getRequestAbortSignal() from app/services/httpAsyncStorage.server.ts instead, which is wired directly to Express res.on('close') and fires reliably
Access environment variables via env export from app/env.server.ts. Never use process.env directly
Always use findFirst instead of findUnique in Prisma queries. findUnique has an implicit DataLoader that batches concurrent calls and has active bugs even in Prisma 6.x (uppercase UUIDs returning null, composite key SQL correctness issues, 5-10x worse performance). findFirst is never batched and avoids this entire class of issues

Files:

  • apps/webapp/app/presenters/v3/SessionPresenter.server.ts
  • apps/webapp/app/services/realtime/s2realtimeStreams.server.ts
packages/cli-v3/src/mcp/**/*

📄 CodeRabbit inference engine (packages/cli-v3/CLAUDE.md)

Provide an MCP server implementation in src/mcp/ for AI-assisted task development

Files:

  • packages/cli-v3/src/mcp/tools/agentChat.ts
🧠 Learnings (14)
📚 Learning: 2026-03-22T13:26:12.060Z
Learnt from: ericallam
Repo: triggerdotdev/trigger.dev PR: 3244
File: apps/webapp/app/components/code/TextEditor.tsx:81-86
Timestamp: 2026-03-22T13:26:12.060Z
Learning: In the triggerdotdev/trigger.dev codebase, do not flag `navigator.clipboard.writeText(...)` calls for `missing-await`/`unhandled-promise` issues. These clipboard writes are intentionally invoked without `await` and without `catch` handlers across the project; keep that behavior consistent when reviewing TypeScript/TSX files (e.g., usages like in `apps/webapp/app/components/code/TextEditor.tsx`).

Applied to files:

  • packages/core/src/v3/realtimeStreams/index.ts
  • packages/core/src/v3/realtime-streams-api.ts
  • packages/core/src/v3/sessionStreams/chatSnapshot.ts
  • packages/core/src/v3/session-streams-api.ts
  • packages/trigger-sdk/src/v3/test/test-session-handle.ts
  • apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.sessions.$sessionParam/route.tsx
  • apps/webapp/app/services/realtime/types.ts
  • packages/core/src/v3/apiClient/runStream.test.ts
  • packages/trigger-sdk/src/v3/chat.test.ts
  • packages/core/src/v3/apiClient/runStream.ts
  • packages/core/src/v3/sessionStreams/wireProtocol.ts
  • packages/trigger-sdk/src/v3/chat-client.ts
  • packages/core/src/v3/sessionStreams/manager.ts
  • packages/trigger-sdk/src/v3/chat-server.ts
  • apps/webapp/app/presenters/v3/SessionPresenter.server.ts
  • packages/trigger-sdk/src/v3/sessions.ts
  • packages/cli-v3/src/mcp/tools/agentChat.ts
  • apps/webapp/app/services/realtime/s2realtimeStreams.server.ts
  • packages/core/src/v3/apiClient/index.ts
  • packages/trigger-sdk/src/v3/chat.ts
  • apps/webapp/app/components/runs/v3/agent/AgentView.tsx
  • packages/core/src/v3/realtimeStreams/sessionStreamOneshot.ts
  • packages/trigger-sdk/src/v3/ai.ts
📚 Learning: 2026-03-22T19:24:14.403Z
Learnt from: matt-aitken
Repo: triggerdotdev/trigger.dev PR: 3187
File: apps/webapp/app/v3/services/alerts/deliverErrorGroupAlert.server.ts:200-204
Timestamp: 2026-03-22T19:24:14.403Z
Learning: In the triggerdotdev/trigger.dev codebase, webhook URLs are not expected to contain embedded credentials/secrets (e.g., fields like `ProjectAlertWebhookProperties` should only hold credential-free webhook endpoints). During code review, if you see logging or inclusion of raw webhook URLs in error messages, do not automatically treat it as a credential-leak/secrets-in-logs issue by default—first verify the URL does not contain embedded credentials (for example, no username/password in the URL, no obvious secret/token query params or fragments). If the URL is credential-free per this project’s conventions, allow the logging.

Applied to files:

  • packages/core/src/v3/realtimeStreams/index.ts
  • packages/core/src/v3/realtime-streams-api.ts
  • packages/core/src/v3/sessionStreams/chatSnapshot.ts
  • packages/core/src/v3/session-streams-api.ts
  • packages/trigger-sdk/src/v3/test/test-session-handle.ts
  • apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.sessions.$sessionParam/route.tsx
  • apps/webapp/app/services/realtime/types.ts
  • packages/core/src/v3/apiClient/runStream.test.ts
  • packages/trigger-sdk/src/v3/chat.test.ts
  • packages/core/src/v3/apiClient/runStream.ts
  • packages/core/src/v3/sessionStreams/wireProtocol.ts
  • packages/trigger-sdk/src/v3/chat-client.ts
  • packages/core/src/v3/sessionStreams/manager.ts
  • packages/trigger-sdk/src/v3/chat-server.ts
  • apps/webapp/app/presenters/v3/SessionPresenter.server.ts
  • packages/trigger-sdk/src/v3/sessions.ts
  • packages/cli-v3/src/mcp/tools/agentChat.ts
  • apps/webapp/app/services/realtime/s2realtimeStreams.server.ts
  • packages/core/src/v3/apiClient/index.ts
  • packages/trigger-sdk/src/v3/chat.ts
  • apps/webapp/app/components/runs/v3/agent/AgentView.tsx
  • packages/core/src/v3/realtimeStreams/sessionStreamOneshot.ts
  • packages/trigger-sdk/src/v3/ai.ts
📚 Learning: 2026-03-31T21:37:27.212Z
Learnt from: isshaddad
Repo: triggerdotdev/trigger.dev PR: 3283
File: docs/migration-n8n.mdx:19-21
Timestamp: 2026-03-31T21:37:27.212Z
Learning: When reviewing code in `packages/trigger-sdk/src/v3`, treat `tasks.triggerAndWait()` and `tasks.batchTriggerAndWait()` as real exported APIs. They are defined in `shared.ts` and re-exported via the `tasks` object in `tasks.ts`, and they take the task ID string as their first argument (not a task instance). This is distinct from the instance methods `yourTask.triggerAndWait()` and `yourTask.batchTriggerAndWait()`. Do not flag calls to `tasks.triggerAndWait()` or `tasks.batchTriggerAndWait()` as non-existent or incorrectly invoked.

Applied to files:

  • packages/trigger-sdk/src/v3/test/test-session-handle.ts
  • packages/trigger-sdk/src/v3/chat.test.ts
  • packages/trigger-sdk/src/v3/chat-client.ts
  • packages/trigger-sdk/src/v3/chat-server.ts
  • packages/trigger-sdk/src/v3/sessions.ts
  • packages/trigger-sdk/src/v3/chat.ts
  • packages/trigger-sdk/src/v3/ai.ts
📚 Learning: 2026-02-03T18:27:40.429Z
Learnt from: 0ski
Repo: triggerdotdev/trigger.dev PR: 2994
File: apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.environment-variables/route.tsx:553-555
Timestamp: 2026-02-03T18:27:40.429Z
Learning: In apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.environment-variables/route.tsx, the menu buttons (e.g., Edit with PencilSquareIcon) in the TableCellMenu are intentionally icon-only with no text labels as a compact UI pattern. This is a deliberate design choice for this route; preserve the icon-only behavior for consistency in this file.

Applied to files:

  • apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.sessions.$sessionParam/route.tsx
📚 Learning: 2026-02-11T16:37:32.429Z
Learnt from: matt-aitken
Repo: triggerdotdev/trigger.dev PR: 3019
File: apps/webapp/app/components/primitives/charts/Card.tsx:26-30
Timestamp: 2026-02-11T16:37:32.429Z
Learning: In projects using react-grid-layout, avoid relying on drag-handle class to imply draggability. Ensure drag-handle elements only affect dragging when the parent grid item is configured draggable in the layout; conditionally apply cursor styles based on the draggable prop. This improves correctness and accessibility.

Applied to files:

  • apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.sessions.$sessionParam/route.tsx
  • apps/webapp/app/components/runs/v3/agent/AgentView.tsx
📚 Learning: 2026-04-02T19:18:26.255Z
Learnt from: samejr
Repo: triggerdotdev/trigger.dev PR: 3319
File: apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.bulk-actions/route.tsx:179-189
Timestamp: 2026-04-02T19:18:26.255Z
Learning: In this repo’s route components that render the Inspector `ResizablePanelGroup` panels, it’s acceptable to pass `collapsed={!isShowingInspector}` together with a no-op `onCollapseChange={() => {}}` when panel visibility is intentionally controlled only by route parameters (e.g., `*Param` search/route params) rather than user drag/collapse interactions. Do not flag an empty/no-op `onCollapseChange` as “missing wiring” in these cases; only flag it when collapse state is expected to change based on user interaction.

Applied to files:

  • apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.sessions.$sessionParam/route.tsx
📚 Learning: 2026-05-12T21:04:00.184Z
Learnt from: ericallam
Repo: triggerdotdev/trigger.dev PR: 3542
File: apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.sessions._index/route.tsx:40-42
Timestamp: 2026-05-12T21:04:00.184Z
Learning: In triggerdotdev/trigger.dev route loader implementations (Remix `route.tsx` files under `apps/webapp/app/routes/**`), follow the existing convention for missing/unauthorized environment lookups: when `findEnvironmentBySlug` (or the equivalent env resolver) returns a falsy value, handle it by throwing `new Error("Environment not found")` rather than returning a `404` `Response` (i.e., do not flag this as “missing 404 response”). Changing the error-to-404 convention is a cross-cutting refactor and should be left out of individual PRs unless the PR explicitly addresses that broader migration.

Applied to files:

  • apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.sessions.$sessionParam/route.tsx
📚 Learning: 2026-05-08T21:00:20.973Z
Learnt from: samejr
Repo: triggerdotdev/trigger.dev PR: 3538
File: apps/webapp/app/components/primitives/Resizable.tsx:60-78
Timestamp: 2026-05-08T21:00:20.973Z
Learning: In the triggerdotdev/trigger.dev codebase, treat Zod as a boundary validation tool (API handlers, request/response validation, and storage/DB read/write validation), not as inline render-time validation inside React components/primitive UI code. For render-time guards, prefer small manual type-narrowing checks (e.g., a short predicate like ~10–20 lines) over importing Zod into UI primitives, to avoid per-render schema-parse overhead and unnecessary abstraction. Use the manual guard approach unless you truly need schema validation at a boundary; only then introduce Zod.

Applied to files:

  • apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.sessions.$sessionParam/route.tsx
  • apps/webapp/app/components/runs/v3/agent/AgentView.tsx
📚 Learning: 2026-05-12T21:04:05.815Z
Learnt from: ericallam
Repo: triggerdotdev/trigger.dev PR: 3542
File: apps/webapp/app/components/sessions/v1/SessionStatus.tsx:1-3
Timestamp: 2026-05-12T21:04:05.815Z
Learning: In this Remix + TypeScript codebase, do not flag a server/client boundary violation when a file imports only types from a module matching `*.server`.

Specifically, it’s safe to import types using `import type { Foo } from "*.server"` or `import { type Foo } from "*.server"` because TypeScript erases type-only imports at compile time and they emit no JavaScript, so they won’t cross the Remix server/client bundle boundary.

Only raise the boundary concern for value imports (e.g., `import { Foo }` without `type`, or `import Foo`), since those produce JavaScript output.

Applied to files:

  • apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.sessions.$sessionParam/route.tsx
  • apps/webapp/app/services/realtime/types.ts
  • apps/webapp/app/presenters/v3/SessionPresenter.server.ts
  • apps/webapp/app/services/realtime/s2realtimeStreams.server.ts
  • apps/webapp/app/components/runs/v3/agent/AgentView.tsx
📚 Learning: 2026-02-06T19:53:38.843Z
Learnt from: 0ski
Repo: triggerdotdev/trigger.dev PR: 2994
File: apps/webapp/app/presenters/v3/DeploymentListPresenter.server.ts:233-237
Timestamp: 2026-02-06T19:53:38.843Z
Learning: When constructing Vercel dashboard URLs from deployment IDs, always strip the dpl_ prefix from the ID. Implement this by transforming the ID with .replace(/^dpl_/, "") before concatenating into the URL: https://vercel.com/${teamSlug}/${projectName}/${cleanedDeploymentId}. Consider centralizing this logic in a small helper (e.g., getVercelDeploymentId(id) or a URL builder) and add tests to verify both prefixed and non-prefixed inputs.

Applied to files:

  • apps/webapp/app/presenters/v3/SessionPresenter.server.ts
📚 Learning: 2026-05-05T09:38:02.512Z
Learnt from: d-cs
Repo: triggerdotdev/trigger.dev PR: 3523
File: apps/webapp/app/routes/api.v3.batches.ts:178-181
Timestamp: 2026-05-05T09:38:02.512Z
Learning: When reviewing code that catches `ServiceValidationError` in `*.server.ts` files, do not blindly forward `error.status` to HTTP responses, because SVEs may be thrown with non-default statuses (e.g., 400/500) and forwarding them can cause client-visible behavioral regressions (e.g., surfacing 500s to clients). Prefer a safe default response status of `error.status ?? 422`, but only after confirming via the reachable call graph that the caught `ServiceValidationError` instances are expected to carry those non-default statuses; otherwise, normalize to `422` to avoid unexpected client-visible 5xx behavior.

Applied to files:

  • apps/webapp/app/presenters/v3/SessionPresenter.server.ts
  • apps/webapp/app/services/realtime/s2realtimeStreams.server.ts
📚 Learning: 2026-03-26T09:02:07.973Z
Learnt from: myftija
Repo: triggerdotdev/trigger.dev PR: 3274
File: apps/webapp/app/services/runsReplicationService.server.ts:922-924
Timestamp: 2026-03-26T09:02:07.973Z
Learning: When parsing Trigger.dev task run annotations in server-side services, keep `TaskRun.annotations` strictly conforming to the `RunAnnotations` schema from `trigger.dev/core/v3`. If the code already uses `RunAnnotations.safeParse` (e.g., in a `#parseAnnotations` helper), treat that as intentional/necessary for atomic, schema-accurate annotation handling. Do not recommend relaxing the annotation payload schema or using a permissive “passthrough” parse path, since the annotations are expected to be written atomically in one operation and should not contain partial/legacy payloads that would require a looser parser.

Applied to files:

  • apps/webapp/app/services/realtime/s2realtimeStreams.server.ts
📚 Learning: 2026-03-22T13:32:44.229Z
Learnt from: ericallam
Repo: triggerdotdev/trigger.dev PR: 3244
File: apps/webapp/app/components/metrics/ProvidersFilter.tsx:74-96
Timestamp: 2026-03-22T13:32:44.229Z
Learning: When reviewing components under `apps/webapp/app/components/runs/v3/`, avoid flagging “broken/unconnected search state” in filters that use `FilterMenuProvider` wrapping Ariakit’s `ComboboxProvider` and expose `(search, setSearch)` (render props). In this intentional pattern, the `searchValue` render-prop value should be treated as reactive (it re-renders on every keystroke), passed into the dropdown child, and used in `useMemo` to filter options. Do not require additional wiring beyond this established render-prop/ComboboxProvider integration.

Applied to files:

  • apps/webapp/app/components/runs/v3/agent/AgentView.tsx
📚 Learning: 2026-04-16T14:21:15.229Z
Learnt from: ericallam
Repo: triggerdotdev/trigger.dev PR: 3368
File: apps/webapp/app/components/logs/LogsTaskFilter.tsx:135-163
Timestamp: 2026-04-16T14:21:15.229Z
Learning: When rendering lists of task registry items in apps/webapp (e.g., <SelectItem /> rows) and using `key={item.slug}`, do not flag it as potentially non-unique. In trigger.dev’s `TaskIdentifier` table, the DB constraint `@unique([runtimeEnvironmentId, slug])` guarantees `slug` is unique within a given runtime environment, so `item.slug` is safe as the React key as long as the list is derived from that registry/constraint (and not from a legacy query that could produce duplicate slugs).

Applied to files:

  • apps/webapp/app/components/runs/v3/agent/AgentView.tsx
🔇 Additional comments (12)
apps/webapp/app/presenters/v3/SessionPresenter.server.ts (1)

1-205: LGTM!

apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.sessions.$sessionParam/route.tsx (1)

78-84: LGTM!

apps/webapp/app/components/runs/v3/agent/AgentView.tsx (3)

1-95: LGTM!

Also applies to: 229-245, 306-391, 505-556


133-150: LGTM!


404-410: LGTM!

packages/trigger-sdk/src/v3/ai.ts (1)

8761-8797: ⚡ Quick win

No action required—the code correctly uses the session external ID.

getChatIdFromContext() returns locals.get(chatSessionHandleKey)?.id, which is the session externalId (passed through sessions.open(payload.chatId) and stored in SessionHandle.id). The documentation at line 8789 is accurate: this function resolves the chatId used as the session externalId. endAndContinueSession(chatId, ...) receives the correct externalId, so the seamless upgrade path will work as intended. No upgrade in the session identification logic is needed.

			> Likely an incorrect or invalid review comment.
packages/core/src/v3/realtime-streams-api.ts (1)

10-15: LGTM!

packages/core/src/v3/realtimeStreams/index.ts (1)

14-19: LGTM!

packages/core/src/v3/session-streams-api.ts (1)

8-9: LGTM!

packages/core/src/v3/apiClient/runStream.test.ts (1)

446-598: LGTM!

packages/trigger-sdk/src/v3/chat.test.ts (1)

22-60: LGTM!

Also applies to: 165-171

packages/trigger-sdk/src/v3/test/test-session-handle.ts (1)

246-277: LGTM!

Comment on lines 261 to 293
const parsed = JSON.parse(data) as {
records: Array<{ body: string; seq_num: number; timestamp: number }>;
records: Array<{
body: string;
seq_num: number;
timestamp: number;
headers?: Array<[string, string]>;
}>;
};

for (const record of parsed.records) {
const parsedBody = JSON.parse(record.body) as { data: string; id: string };
// S2 command records (trim/fence) have a single header with
// empty name. Skip — callers want only data + Trigger control
// records.
if (record.headers?.[0]?.[0] === "") {
continue;
}

// Data records carry a JSON envelope; Trigger control records
// have an empty body and route via headers. Tolerate non-JSON
// bodies so a control record (or a malformed data record)
// doesn't take the whole batch down with it.
let parsedBody: { data: string; id: string } | undefined;
try {
parsedBody = JSON.parse(record.body) as { data: string; id: string };
} catch {
parsedBody = undefined;
}
records.push({
data: parsedBody.data,
id: parsedBody.id,
data: parsedBody?.data ?? "",
id: parsedBody?.id ?? "",
seqNum: record.seq_num,
headers: record.headers,
});
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Harden both S2 record parsers against malformed records[] entries.

These paths still trust each element once records is an array. In #peekIsSettled(), records: [null] will throw on record.headers; in parseSSEBatchRecords(), one bad entry drops the whole batch. Validate each record before reading body/seq_num/headers and skip invalid ones so reconnect/reload can fall back cleanly.

As per coding guidelines, {packages/core,apps/webapp}/**/*.{ts,tsx}: Use zod for validation in packages/core and apps/webapp.

Also applies to: 428-460

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/webapp/app/services/realtime/s2realtimeStreams.server.ts` around lines
261 - 293, Harden parsing by validating each parsed.records entry with a zod
schema before accessing fields: create a zod schema for the incoming record
shape (body?: string, seq_num?: number, timestamp?: number, headers?:
Array<[string,string]>), use it in parseSSEBatchRecords() to parse/guard each
record and skip any that fail validation so one bad entry doesn't drop the whole
batch (only push records that pass and map to {data, id, seqNum, headers} after
safe JSON parse of record.body); likewise update peekIsSettled() to defensively
check/validate each element of parsed.records (or reuse the same zod schema)
before reading record.headers/seq_num to avoid null/undefined access; follow the
repo guideline by using zod from packages/core or apps/webapp for these
validations.

Comment on lines +594 to +599
// S2 treats `trim` as a separate op from `append` even though
// trim records are appended like any other record. Verified
// empirically: without `"trim"` here, `AppendRecord.trim()`
// writes 403 with "Operation not permitted". `chat.agent`'s
// per-turn trim chain depends on this.
ops: ["append", "create-stream", "trim"],
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | 🏗️ Heavy lift

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

printf '%s\n' '--- Token exposure from stream initialization ---'
rg -n -C3 'X-S2-Access-Token|X-S2-Stream-Name|initializeSessionStream|initializeStream\(' apps/webapp packages

printf '\n%s\n' '--- Session trim write sites in the runtime ---'
rg -n -C3 '\btrim\b|turn-complete|trigger-control' \
  packages/core/src/v3/realtimeStreams/sessionStreamOneshot.ts \
  packages/trigger-sdk/src/v3/sessions.ts \
  packages/trigger-sdk/src/v3/ai.ts \
  packages/trigger-sdk/src/v3/chat.ts \
  packages/trigger-sdk/src/v3/chat-client.ts \
  packages/trigger-sdk/src/v3/chat-server.ts

Repository: triggerdotdev/trigger.dev

Length of output: 44309


Split server/client token scopes — client should not receive trim capability.

initializeSessionStream() returns an S2 access token via X-S2-Access-Token response header that includes "trim" operation permission. This token is exposed to browser clients via JSON response from the route handlers (realtime.v1.sessions.$session.$io.ts and realtime.v1.streams.$runId.$target.$streamId.ts). However, trim operations are exclusively server-side: all trim writes occur in sessionStreamOneshot.ts (turn completion control records) and ai.ts (turn cleanup), never in client code. The client should receive a token with only read permissions ("append", "create-stream") to limit destructive capability scope.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/webapp/app/services/realtime/s2realtimeStreams.server.ts` around lines
594 - 599, The S2 access token returned by initializeSessionStream currently
grants the "trim" op to clients; restrict client-exposed tokens to only
["append","create-stream"] and keep "trim" available only to server-side logic
by producing two scope variants (server token with "trim" for
sessionStreamOneshot.ts and ai.ts, and a client token without "trim" for
responses emitted in realtime.v1.sessions.$session.$io.ts and
realtime.v1.streams.$runId.$target.$streamId.ts), or add an explicit scopes
parameter to initializeSessionStream and call it with the limited scopes for
client responses while using the full-scope call internally where trims are
performed.

Comment on lines +437 to +439
reader.releaseLock();
// Recurse — subscribe to the new run's stream (same session.out URL)
return collectAgentResponse(session, depth + 1);
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Verify the double-release pattern in collectAgentResponse
rg -n -C3 'UPGRADE_REQUIRED|reader\.releaseLock\(\)|finally' packages/cli-v3/src/mcp/tools/agentChat.ts

Repository: triggerdotdev/trigger.dev

Length of output: 964


🏁 Script executed:

#!/bin/bash
# Get the full collectAgentResponse function to understand the complete try-finally structure
sed -n '350,490p' packages/cli-v3/src/mcp/tools/agentChat.ts | cat -n

Repository: triggerdotdev/trigger.dev

Length of output: 6225


🌐 Web query:

ReadableStreamDefaultReader releaseLock() double call throws error Web Streams API

💡 Result:

Calling releaseLock on a ReadableStreamDefaultReader multiple times does not inherently throw an error, provided the reader is still valid and the lock is successfully released on the first call [1][2][3]. However, it is important to understand the following behaviors regarding this method: 1. State after release: Once releaseLock is called, the reader is no longer associated with the stream [1][3]. Subsequent calls to releaseLock on the same reader instance will still execute, but they will have no further effect on the stream's lock state [1][3]. 2. Pending read requests: If you call releaseLock while there is a pending read request (i.e., the promise returned by read has not yet settled), the pending promise will be immediately rejected with a TypeError [2][3][4]. 3. Invalid source objects: A TypeError will be thrown if releaseLock is called on an object that is not a valid ReadableStreamDefaultReader [2][3][4]. In summary, while the method itself is idempotent in terms of its effect on the stream's lock, you must ensure that you are not calling it while a read request is pending, as that will trigger a rejection of the pending read promise [2][3].

Citations:


🏁 Script executed:

#!/bin/bash
# Search for SSEStreamSubscription definition and how it returns the reader
rg -n -B5 -A20 'class SSEStreamSubscription|getReader\(\)' packages/cli-v3/src/mcp/tools/agentChat.ts | head -100

Repository: triggerdotdev/trigger.dev

Length of output: 885


🏁 Script executed:

#!/bin/bash
# Find SSEStreamSubscription class definition
rg -n 'class SSEStreamSubscription' packages/cli-v3/src/mcp/tools/agentChat.ts -A 30

Repository: triggerdotdev/trigger.dev

Length of output: 51


🏁 Script executed:

#!/bin/bash
# Find where SSEStreamSubscription is defined and imported
rg -n 'SSEStreamSubscription|import.*SSEStreamSubscription' packages/cli-v3/src/mcp/tools/agentChat.ts

Repository: triggerdotdev/trigger.dev

Length of output: 162


🏁 Script executed:

#!/bin/bash
# Find the import statement for SSEStreamSubscription
sed -n '1,20p' packages/cli-v3/src/mcp/tools/agentChat.ts

Repository: triggerdotdev/trigger.dev

Length of output: 583


🏁 Script executed:

#!/bin/bash
# Find SSEStreamSubscription definition in the core package
find . -path "*/packages/*/src/**/*" -name "*.ts" -o -name "*.tsx" | xargs grep -l "class SSEStreamSubscription" 2>/dev/null | head -5

Repository: triggerdotdev/trigger.dev

Length of output: 115


🏁 Script executed:

#!/bin/bash
# Check SSEStreamSubscription implementation to see what getReader returns
rg -n 'class SSEStreamSubscription|subscribe\(\)|getReader' packages/core/src/v3/apiClient/runStream.ts -A 5 | head -80

Repository: triggerdotdev/trigger.dev

Length of output: 2126


Remove explicit releaseLock() before return on the upgrade path.

The explicit release at line 437 is redundant—the finally block at line 485 already handles cleanup. While the second call is harmless per the Web Streams API (releaseLock is idempotent), removing the explicit release simplifies intent: let the finally block manage all reader cleanup.

Proposed fix
-        reader.releaseLock();
         // Recurse — subscribe to the new run's stream (same session.out URL)
         return collectAgentResponse(session, depth + 1);
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/cli-v3/src/mcp/tools/agentChat.ts` around lines 437 - 439, Remove
the redundant explicit reader.releaseLock() call on the upgrade/recursion path
inside collectAgentResponse; the reader is already cleaned up by the function's
finally block, so delete the reader.releaseLock() just before the recursive
return (the return collectAgentResponse(session, depth + 1) line) and rely on
the existing finally cleanup for the reader to avoid duplicate/tempting manual
cleanup.

Comment on lines +179 to +186
/**
* S2 record headers, when the underlying transport is the v2 batch shape
* (Session streams). Undefined for v1 streams. Empty array when the record
* had no headers. First-header empty-name is a Trigger control protocol
* marker (see `trigger-control` records on `session.out`); empty-name
* records that S2 itself interprets as command records (trim/fence) are
* filtered out before reaching this struct.
*/
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Fix the control-header doc on SSEStreamPart.

trigger-control records are identified by a "trigger-control" header. An empty-name first header denotes S2 command records, and those are already filtered before enqueue, so this comment currently describes the wire contract backwards.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/core/src/v3/apiClient/runStream.ts` around lines 179 - 186, The doc
comment on the SSEStreamPart struct is reversed: update the comment for
SSEStreamPart to state that trigger-control records are identified by a
"trigger-control" header, while an empty-name first header denotes S2 command
records (which are already filtered before enqueue), and clarify that headers
are undefined for v1 streams and empty array when no headers; keep the note
about first-header empty-name being the Trigger control protocol marker
corrected to match the wire contract.

Comment on lines 385 to +426
const data = safeParseJSON(chunk.data) as {
records: Array<{ body: string; seq_num: number; timestamp: number }>;
records: Array<{
body: string;
seq_num: number;
timestamp: number;
headers?: Array<[string, string]>;
}>;
};
if (!data || !Array.isArray(data.records)) return;

for (const record of data.records) {
// Always advance the resume cursor — even for records we
// skip — so a future Last-Event-ID reconnect lands past
// them.
this.lastEventId = record.seq_num.toString();
const parsedBody = safeParseJSON(record.body) as { data: unknown; id: string };
if (seenIds.has(parsedBody.id)) continue;
seenIds.add(parsedBody.id);

// S2 command records (trim, fence) have a single header
// with an empty name. They are S2-interpreted directives
// that consume a seq_num but are not application data.
// Skip enqueue; consumers shouldn't see them.
if (record.headers?.[0]?.[0] === "") {
continue;
}

// Data record (and Trigger control records — see
// `trigger-control` header in `client-protocol.mdx`).
// Control records have an empty body; data records have a
// JSON envelope. `safeParseJSON("")` returns undefined,
// which is what we want for control records — downstream
// consumers route by `headers` and ignore `chunk`.
const parsedBody = safeParseJSON(record.body) as
| { data: unknown; id: string }
| undefined;
if (parsedBody?.id) {
if (seenIds.has(parsedBody.id)) continue;
seenIds.add(parsedBody.id);
}
chunkController.enqueue({
id: record.seq_num.toString(),
chunk: parsedBody.data,
chunk: parsedBody?.data,
timestamp: record.timestamp,
headers: record.headers ?? [],
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Validate each v2 batch record before dereferencing it.

Array.isArray(data.records) only protects the container. A malformed element like {} or null will still throw on record.seq_num.toString() here and tear down the subscription instead of being skipped.

As per coding guidelines, {packages/core,apps/webapp}/**/*.{ts,tsx}: Use zod for validation in packages/core and apps/webapp.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/core/src/v3/apiClient/runStream.ts` around lines 385 - 426, Wrap
per-record processing with a Zod validation step: define a zod schema for each
element (fields: body: string, seq_num: number, timestamp: number, headers?:
Array<[string,string]>), run schema.safeParse(record) and if it fails skip the
element (do not dereference record.seq_num or record.timestamp). Only set
this.lastEventId when seq_num is present/valid from the parsed record; preserve
the existing behavior of skipping S2 command records (headers[0][0] === "") and
duplicate IDs via seenIds, and enqueue using the validated parsed values when
parsedBody is present before calling chunkController.enqueue. Ensure
safeParseJSON usage and parsedBody logic remain unchanged but operate on
validated record bodies.

Comment on lines 8698 to +8736
async function writeTurnCompleteChunk(
chatId?: string,
_chatId?: string,
publicAccessToken?: string
): Promise<StreamWriteResult> {
const { waitUntilComplete } = chatStream.writer({
spanName: "turn complete",
collapsed: true,
execute: ({ write }) => {
// Transport-intercepted control chunk — not a valid UIMessageChunk
// type but travels on the same session.out stream.
write({
type: "trigger:turn-complete",
...(publicAccessToken ? { publicAccessToken } : {}),
} as unknown as UIMessageChunk);
},
});
return await waitUntilComplete();
const session = getChatSession();

// 1. Write the turn-complete control record. The ack's `lastEventId` is
// this record's seq_num — that's the trim target for the NEXT turn.
const extraHeaders: ReadonlyArray<readonly [string, string]> = publicAccessToken
? [["public-access-token", publicAccessToken]]
: [];
const result = await session.out.writeControl(
TRIGGER_CONTROL_SUBTYPE.TURN_COMPLETE,
extraHeaders
);
const T_N = result.lastEventId ? Number.parseInt(result.lastEventId, 10) : undefined;

// 2. Trim back to the previous turn-complete, if we have one. Skipping on
// first-turn-ever (or first turn post-OOM without a snapshot seed) is
// fine — the chain catches up next turn.
const slot = locals.get(lastTurnCompleteSeqNumKey);
const prev = slot?.value;
if (slot && prev !== undefined) {
try {
await session.out.trimTo(prev);
} catch (err) {
logger.warn("chat.agent: trim failed; will retry next turn", {
error: err instanceof Error ? err.message : String(err),
prev,
});
}
}

// 3. Advance the slot so the next turn-complete trims back to this one.
if (slot && T_N !== undefined && Number.isFinite(T_N)) {
slot.value = T_N;
}

return result;
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Don't derive the resume cutoff from local wall-clock time.

This helper only gives downstream code the control record's lastEventId, so the snapshot path currently has to persist lastOutTimestamp with a local Date.now(). That can move the session.in dedup cutoff past a user message that arrived after turn-complete was written but before this worker finished post-turn bookkeeping; if the worker dies in that window, the continuation boot can filter that message out as "old". Please persist the actual control-record timestamp here, or stop trusting the snapshot timestamp and always rescan session.out for the cutoff on boot.

Comment on lines +481 to +500
async writeControl(
subtype: string,
extraHeaders?: ReadonlyArray<readonly [string, string]>
): Promise<StreamWriteResult> {
const apiClient = apiClientManager.clientOrThrow();
return writeSessionControlRecord(apiClient, this.sessionId, "out", subtype, extraHeaders);
}

/**
* Append an S2 `trim` command record to `.out`. Records with seq_num
* less than `earliestSeqNum` are eventually removed from the stream.
*
* Idempotent and monotonic at S2's layer (`max(existing, min(provided,
* current_tail))`) — backward trims are silently no-ops for deletion
* but still consume a seq_num. Used by `chat.agent`'s turn loop to
* keep `session.out` bounded to roughly one turn at steady state.
*/
async trimTo(earliestSeqNum: number): Promise<void> {
const apiClient = apiClientManager.clientOrThrow();
await trimSessionStream(apiClient, this.sessionId, earliestSeqNum);
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Expose control events on SessionOutputChannel.read() too.

These new methods make control records part of the public sessions.open(...).out API, but the paired read surface still only exposes parsed chunks and an onPart shape with no headers. Callers built on SessionOutputChannel.read() cannot observe turn-complete / upgrade-required or implement the header filter this protocol now requires, because ApiClient.subscribeToSessionStream() drops control records unless onControl is wired through.

Suggested follow-up
+import type { StreamWriteResult, ControlEvent, SSEStreamPart } from "`@trigger.dev/core/v3`";
-import type { StreamWriteResult } from "`@trigger.dev/core/v3`";

  async read<T = unknown>(
    options?: SessionSubscribeOptions<T>
  ): Promise<AsyncIterableStream<T>> {
    const apiClient = apiClientManager.clientOrThrow();

    return apiClient.subscribeToSessionStream<T>(this.sessionId, "out", {
      signal: options?.signal,
      timeoutInSeconds: options?.timeoutInSeconds,
      lastEventId:
        options?.lastEventId != null ? String(options.lastEventId) : undefined,
      onPart: options?.onPart,
+      onControl: options?.onControl,
      onComplete: options?.onComplete,
      onError: options?.onError,
    });
  }

 export type SessionSubscribeOptions<T = unknown> = {
   signal?: AbortSignal;
   lastEventId?: string | number;
   /** Timeout in seconds for the underlying long-poll (max 600). */
   timeoutInSeconds?: number;
   /** Called for each SSE event with the full event metadata (id, timestamp). */
-  onPart?: (part: { id: string; chunk: T; timestamp: number }) => void;
+  onPart?: (part: SSEStreamPart<T>) => void;
+  /** Called for header-form control records such as turn-complete / upgrade-required. */
+  onControl?: (event: ControlEvent) => void;
   /** Called when the server signals end-of-stream. */
   onComplete?: () => void;
   /** Called on unrecoverable errors after the retry budget is exhausted. */
   onError?: (error: Error) => void;
 };
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/trigger-sdk/src/v3/sessions.ts` around lines 481 - 500,
SessionOutputChannel.read() currently only returns parsed chunks and onPart
without headers, so consumers can't see control records like "turn-complete" or
"upgrade-required"; update SessionOutputChannel.read (and any onPart/onEvent
callbacks it exposes) to surface control records and their headers, and ensure
when subscribing you pass an onControl handler into
ApiClient.subscribeToSessionStream() so control frames aren't dropped;
specifically wire the onControl callback through the code path that calls
ApiClient.subscribeToSessionStream() and propagate control metadata (headers and
subtype) into the SessionOutputChannel.read/onPart API so callers can filter by
header or observe control events.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant