Skip to content

feat(sdk): chat.agent — runtime + browser transport (2/4)#3543

Open
ericallam wants to merge 1 commit into
mainfrom
feature/chat-agent-runtime
Open

feat(sdk): chat.agent — runtime + browser transport (2/4)#3543
ericallam wants to merge 1 commit into
mainfrom
feature/chat-agent-runtime

Conversation

@ericallam
Copy link
Copy Markdown
Member

@ericallam ericallam commented May 10, 2026

Summary

Adds chat.agent({...}), a durable conversational task runtime, plus the browser-side TriggerChatTransport + AgentChat that drive it from a React or Next.js app. Conversations survive page refreshes, network blips, idle suspend, and process restarts, with built-in tools, HITL approvals, multi-turn state, and stop-mid-stream cancellation. Builds on #3542.

Design

Each /in/append request carries at most one new message. The agent reconstructs prior history at run boot from an object-store snapshot plus a session.out replay tail, so conversation context lives server-side instead of bloating the wire. Awaited snapshot writes after every onTurnComplete keep the chain durable across idle suspend. Registering hydrateMessages short-circuits both paths for customers who own their own conversation store.

Lifecycle hooks — onChatStart, onTurnStart, onTurnComplete, onAction, onValidateMessages, hydrateMessages — cover validation, persistence, and post-turn work. chat.history exposes read primitives (getPendingToolCalls, getResolvedToolCalls, extractNewToolResults, findMessage, all) for HITL flows. chat.local gives per-run typed state with Proxy access and dirty tracking. chat.headStart bridges first-turn TTFC via a customer HTTP handler. oomMachine opts a chat into one-shot OOM-retry on a larger machine.

TriggerChatTransport is a Transport implementation for Vercel's ai-sdk useChat: delta-only wire sends, SSE reconnection with lastEventId resume, stop/abort cleanup, dynamic accessToken refresh, X-Peek-Settled fast-close. AgentChat is the direct programmatic equivalent. A cross-tab coordinator does leader election so multiple open tabs share a single SSE.

import { chat } from "@trigger.dev/sdk/ai";
import { streamText } from "ai";

export const myChat = chat.agent({
  id: "my-chat",
  run: async ({ messages, signal }) =>
    streamText({ model: openai("gpt-4o"), messages, abortSignal: signal }),
});

Test plan

  • Run the chat.agent task end-to-end via useChat against a local webapp
  • Kill the page mid-stream, refresh, verify the stream resumes and history persists
  • Stop a turn mid-stream via stop(), verify clean abort
  • Trigger a tool that requires approval, approve it, verify resume
  • Run a long conversation (50+ turns), verify snapshot writes keep wire payloads small

Stack

Part of a 4-PR stack. Merge bottom-up.

  1. feat: Sessions dashboard, task_kind, and chat-ready hardening (1/4) #3542main — Sessions dashboard + chat-ready hardening
  2. This PR (feat(sdk): chat.agent — runtime + browser transport (2/4) #3543) → feat: Sessions dashboard, task_kind, and chat-ready hardening (1/4) #3542
  3. feat(webapp): agent-view dashboard for chat.agent runs (3/4) #3545feat(sdk): chat.agent — runtime + browser transport (2/4) #3543 — agent-view dashboard
  4. feat: ai-chat reference project + MCP agent-chat tooling (4/4) #3546feat(webapp): agent-view dashboard for chat.agent runs (3/4) #3545 — ai-chat reference + MCP tooling

Originally split across #3543 and #3544. Folded into a single PR because the SDK package.json subpath exports for ./chat and ./chat/react made the runtime and the browser transport install-coupled.


This is part 4 of 5 in a stack made with GitButler:

@changeset-bot
Copy link
Copy Markdown

changeset-bot Bot commented May 10, 2026

🦋 Changeset detected

Latest commit: 535245f

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 31 packages
Name Type
@trigger.dev/sdk Minor
@trigger.dev/core Minor
@trigger.dev/build Minor
trigger.dev Minor
@trigger.dev/python Minor
@internal/sdk-compat-tests Patch
d3-chat Patch
references-d3-openai-agents Patch
references-nextjs-realtime Patch
references-realtime-hooks-test Patch
references-realtime-streams Patch
references-telemetry Patch
@trigger.dev/plugins Minor
@trigger.dev/redis-worker Minor
@trigger.dev/schema-to-json Minor
@internal/cache Patch
@internal/clickhouse Patch
@internal/llm-model-catalog Patch
@trigger.dev/rbac Minor
@internal/redis Patch
@internal/replication Patch
@internal/run-engine Patch
@internal/schedule-engine Patch
@internal/testcontainers Patch
@internal/tracing Patch
@internal/tsql Patch
@internal/zod-worker Patch
@trigger.dev/react-hooks Minor
@trigger.dev/rsc Minor
@trigger.dev/database Minor
@trigger.dev/otlp-importer Minor

Not sure what this means? Click here to learn what changesets are.

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

@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai Bot commented May 10, 2026

Note

Currently processing new changes in this PR. This may take a few minutes, please wait...

⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro

Run ID: d99c1183-aee5-42dd-9ee7-68e6dce3443f

📥 Commits

Reviewing files that changed from the base of the PR and between 979655c and 535245f.

⛔ Files ignored due to path filters (1)
  • pnpm-lock.yaml is excluded by !**/pnpm-lock.yaml
📒 Files selected for processing (72)
  • .changeset/agent-skills.md
  • .changeset/chat-actions-no-turn.md
  • .changeset/chat-agent-delta-wire-snapshots.md
  • .changeset/chat-agent-on-boot-hook.md
  • .changeset/chat-agent.md
  • .changeset/chat-head-start.md
  • .changeset/chat-history-read-primitives.md
  • .changeset/chat-session-attributes.md
  • .changeset/mock-chat-agent-test-harness.md
  • apps/webapp/app/components/code/AIQueryInput.tsx
  • apps/webapp/app/components/code/StreamdownRenderer.tsx
  • apps/webapp/app/components/code/shikiTheme.ts
  • apps/webapp/app/components/runs/v3/PromptSpanDetails.tsx
  • apps/webapp/app/components/runs/v3/ai/AIChatMessages.tsx
  • apps/webapp/app/components/runs/v3/ai/AISpanDetails.tsx
  • apps/webapp/app/components/runs/v3/ai/types.ts
  • apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.test.tasks.$taskParam/AIPayloadTabContent.tsx
  • apps/webapp/package.json
  • apps/webapp/test/chat-snapshot-integration.test.ts
  • apps/webapp/test/replay-after-crash.test.ts
  • package.json
  • packages/build/package.json
  • packages/build/src/extensions/secureExec.ts
  • packages/build/src/internal.ts
  • packages/build/src/internal/additionalFiles.ts
  • packages/build/src/internal/copyFiles.ts
  • packages/core/package.json
  • packages/core/src/v3/chat-client.ts
  • packages/core/src/v3/resource-catalog/catalog.ts
  • packages/core/src/v3/resource-catalog/index.ts
  • packages/core/src/v3/resource-catalog/noopResourceCatalog.ts
  • packages/core/src/v3/resource-catalog/standardResourceCatalog.ts
  • packages/core/src/v3/taskContext/index.test.ts
  • packages/core/src/v3/taskContext/index.ts
  • packages/core/src/v3/taskContext/otelProcessors.ts
  • packages/core/src/v3/test/index.ts
  • packages/core/src/v3/test/mock-task-context.ts
  • packages/core/test/mockTaskContext.test.ts
  • packages/core/test/skillCatalog.test.ts
  • packages/trigger-sdk/package.json
  • packages/trigger-sdk/src/v3/agentSkillsRuntime.ts
  • packages/trigger-sdk/src/v3/ai-shared.ts
  • packages/trigger-sdk/src/v3/ai.ts
  • packages/trigger-sdk/src/v3/auth.ts
  • packages/trigger-sdk/src/v3/chat-client.ts
  • packages/trigger-sdk/src/v3/chat-react.ts
  • packages/trigger-sdk/src/v3/chat-server.test.ts
  • packages/trigger-sdk/src/v3/chat-server.ts
  • packages/trigger-sdk/src/v3/chat-tab-coordinator.test.ts
  • packages/trigger-sdk/src/v3/chat-tab-coordinator.ts
  • packages/trigger-sdk/src/v3/chat.test.ts
  • packages/trigger-sdk/src/v3/chat.ts
  • packages/trigger-sdk/src/v3/deployments.ts
  • packages/trigger-sdk/src/v3/index.ts
  • packages/trigger-sdk/src/v3/runs.ts
  • packages/trigger-sdk/src/v3/sessions.ts
  • packages/trigger-sdk/src/v3/skill.ts
  • packages/trigger-sdk/src/v3/skills.ts
  • packages/trigger-sdk/src/v3/test/index.ts
  • packages/trigger-sdk/src/v3/test/mock-chat-agent.ts
  • packages/trigger-sdk/src/v3/test/setup-catalog.ts
  • packages/trigger-sdk/src/v3/test/test-session-handle.ts
  • packages/trigger-sdk/test/chat-snapshot.test.ts
  • packages/trigger-sdk/test/chatHandover.test.ts
  • packages/trigger-sdk/test/merge-by-id.test.ts
  • packages/trigger-sdk/test/mockChatAgent.test.ts
  • packages/trigger-sdk/test/replay-session-out.test.ts
  • packages/trigger-sdk/test/skill.test.ts
  • packages/trigger-sdk/test/skillsRuntime.test.ts
  • packages/trigger-sdk/test/wire-shape.test.ts
  • patches/streamdown@2.5.0.patch
  • pnpm-workspace.yaml
 ________________________________________________
< Bugs in your code are closer than they appear. >
 ------------------------------------------------
  \
   \   (\__/)
       (•ㅅ•)
       /   づ
✨ 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 feature/chat-agent-runtime

Tip

💬 Introducing Slack Agent: The best way for teams to turn conversations into code.

Slack Agent is built on CodeRabbit's deep understanding of your code, so your team can collaborate across the entire SDLC without losing context.

  • Generate code and open pull requests
  • Plan features and break down work
  • Investigate incidents and troubleshoot customer tickets together
  • Automate recurring tasks and respond to alerts with triggers
  • Summarize progress and report instantly

Built for teams:

  • Shared memory across your entire org—no repeating context
  • Per-thread sandboxes to safely plan and execute work
  • Governance built-in—scoped access, auditability, and budget controls

One agent for your entire SDLC. Right inside Slack.

👉 Get started


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.

Copy link
Copy Markdown
Contributor

@devin-ai-integration devin-ai-integration Bot left a comment

Choose a reason for hiding this comment

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

Devin Review found 4 potential issues.

View 5 additional findings in Devin Review.

Open in Devin Review

*/
handoverResponse(result: StreamTextResult<any, any>): Response;
/** Manually dispatch the `handover` signal on `session.in`. */
handover(args: { partialAssistantMessage: ModelMessage[] }): Promise<void>;
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.

🔴 Public HeadStartSession.handover type omits required isFinal parameter, causing agent to always enter non-final path

The public HeadStartSession type declares handover(args: { partialAssistantMessage: ModelMessage[] }) at packages/trigger-sdk/src/v3/chat-server.ts:133, but the internal function it maps to (chat-server.ts:382-394) requires isFinal: boolean. When a customer calls handle.handover({ partialAssistantMessage: msgs }) through the chat.openSession() escape-hatch API, args.isFinal is undefined. In JSON.stringify at line 393, isFinal: undefined is omitted from the wire payload. The agent receiving this kind: "handover" chunk interprets the missing isFinal as falsy, so it always enters the non-final branch (runs streamText to execute tool-calls) — even when the customer intended a final handover (pure-text, no LLM call). There is no way for a customer using the manual handover() method to signal a final response.

Suggested change
handover(args: { partialAssistantMessage: ModelMessage[] }): Promise<void>;
handover(args: { partialAssistantMessage: ModelMessage[]; isFinal?: boolean; messageId?: string }): Promise<void>;
Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

Comment on lines +602 to +604
void handoverWhenDone(result)
.finally(() => clearTimeout(idleTimer))
.catch(() => {});
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.

🟡 Idle timer leak when handoverWhenDone is called outside handoverResponse

In openHandoverSession, an idleTimer is created at chat-server.ts:321 that aborts the session's AbortController after the timeout. The clearTimeout(idleTimer) call only exists inside handoverResponse at line 603 (chained on handoverWhenDone's .finally()). When a customer uses the lower-level chat.openSession() API and calls handle.tee() + handle.handoverWhenDone() directly — without going through handle.handoverResponse() — the timer is never cleared. After idleTimeoutInSeconds (default 60s), the timer fires and calls abortController.abort(), which may cancel in-progress operations or SSE subscriptions that the customer still needs.

Prompt for agents
The idleTimer created at line 321 is only cleared inside handoverResponse (line 603). The HeadStartSession handle exposes handoverWhenDone as a standalone public method (line 640), but if a customer calls it directly via the chat.openSession() escape hatch, the timer never gets cleared. Move the clearTimeout(idleTimer) into the handoverWhenDone function itself (e.g. in its finally block), so the timer is always cleared regardless of which code path invokes handoverWhenDone.
Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

Comment on lines +664 to 676
async append(value, options) {
// Use a single-write writer so objects are serialized the same way
// as stream.writer() — the raw append API sends BodyInit which
// doesn't serialize objects correctly for SSE consumers.
const { waitUntilComplete } = writer(opts.id, {
...options,
spanName: "streams.append()",
execute: ({ write }) => {
write(value);
},
});
await waitUntilComplete();
},
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.

🚩 streams.define().append() changed from raw append to writer-based serialization

The define<TPart>().append() method at streams.ts:664-676 was changed from delegating to the raw append() function (which sends BodyInit) to using writer() with a single write() call. The comment explains this ensures objects are serialized the same way as stream.writer() — the raw append API sends BodyInit which doesn't serialize objects correctly for SSE consumers. This is a behavioral change to the RealtimeDefinedStream.append() method. Any existing code that relied on the old raw-append behavior (e.g., passing pre-stringified data) will now get double-serialized through the writer path. The change is intentional and an improvement, but callers should be aware of the semantic shift.

Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

"peerDependencies": {
"zod": "^3.0.0 || ^4.0.0",
"ai": "^4.2.0 || ^5.0.0 || ^6.0.0"
"ai": "^5.0.0 || ^6.0.0",
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.

🚩 peerDependency range for 'ai' package narrowed — drops v4 support

In packages/trigger-sdk/package.json, the ai peer dependency range changed from ^4.2.0 || ^5.0.0 || ^6.0.0 to ^5.0.0 || ^6.0.0, dropping v4 support. The changeset mentions this is intentional (new features require AI SDK v5+). This is a breaking change for any existing users on ai@4.x — they'll see peer dependency warnings on install. The devDependency was also bumped from ^6.0.0 to ^6.0.116.

Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

@ericallam ericallam force-pushed the feature/sessions-primitive branch from b84d537 to ed7bf97 Compare May 11, 2026 19:01
@ericallam ericallam force-pushed the feature/chat-agent-runtime branch from dbc0034 to 64929b7 Compare May 11, 2026 19:01
@ericallam ericallam force-pushed the feature/sessions-primitive branch from ed7bf97 to 365e73b Compare May 12, 2026 08:23
@ericallam ericallam force-pushed the feature/chat-agent-runtime branch from 64929b7 to 9d8b67b Compare May 12, 2026 08:23
@ericallam ericallam force-pushed the feature/sessions-primitive branch from 365e73b to b4a0986 Compare May 12, 2026 08:35
@ericallam ericallam force-pushed the feature/chat-agent-runtime branch from 9d8b67b to f83a0e2 Compare May 12, 2026 08:35
@ericallam ericallam force-pushed the feature/sessions-primitive branch from b4a0986 to 1712b59 Compare May 12, 2026 08:40
@ericallam ericallam force-pushed the feature/chat-agent-runtime branch from f83a0e2 to 4cedf7c Compare May 12, 2026 08:40
@ericallam ericallam force-pushed the feature/sessions-primitive branch from 1712b59 to 3721c34 Compare May 12, 2026 08:46
@ericallam ericallam force-pushed the feature/chat-agent-runtime branch from 4cedf7c to 63cb53e Compare May 12, 2026 08:46
@ericallam ericallam force-pushed the feature/sessions-primitive branch from 3721c34 to f240799 Compare May 12, 2026 08:52
@ericallam ericallam force-pushed the feature/chat-agent-runtime branch 11 times, most recently from f694134 to 13447b8 Compare May 12, 2026 13:31
@ericallam ericallam changed the title feat(sdk): chat.agent — durable conversational task runtime (2/5) feat(sdk): chat.agent — runtime + browser transport (2/4) May 12, 2026
@ericallam ericallam force-pushed the feature/chat-agent-runtime branch from 13447b8 to 2e77f5f Compare May 12, 2026 17:47
@ericallam ericallam force-pushed the feature/sessions-primitive branch from 9a09da4 to 84c717c Compare May 12, 2026 19:09
@ericallam ericallam force-pushed the feature/chat-agent-runtime branch 3 times, most recently from 9af14ef to b54c38e Compare May 13, 2026 06:14
@ericallam ericallam force-pushed the feature/sessions-primitive branch from 2218110 to 5359eda Compare May 13, 2026 06:19
@ericallam ericallam force-pushed the feature/chat-agent-runtime branch 3 times, most recently from df753af to 950c0b5 Compare May 13, 2026 14:21
Copy link
Copy Markdown
Contributor

@devin-ai-integration devin-ai-integration Bot left a comment

Choose a reason for hiding this comment

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

Devin Review found 1 new potential issue.

View 13 additional findings in Devin Review.

Open in Devin Review

Comment on lines +178 to +186
async *messages(): AsyncGenerator<UIMessage, void, unknown> {
for await (const message of readUIMessageStream({ stream: this._consumerStream })) {
this.lastAssistantMessage = message;
yield message;
}
if (this.lastAssistantMessage && this.onAssistantMessage) {
this.onAssistantMessage(this.lastAssistantMessage);
}
}
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.

🟡 ChatStream calls onAssistantMessage callback twice when consumed via messages()

When ChatStream is constructed with an onAssistantMessage callback, the constructor tees the stream and starts a background collector IIFE that calls onAssistantMessage when the collector branch drains (chat-client.ts:142-144). If the consumer then iterates via messages(), that method reads the other tee branch and also calls this.onAssistantMessage at the end (chat-client.ts:183-184). Both branches process the same data and both invoke the callback, so onAssistantMessage fires twice with the same lastAssistantMessage. Currently no internal caller passes onAssistantMessage to the constructor (sendMessage and sendAction both call new ChatStream(rawStream) without it), so this only affects direct new ChatStream(stream, callback) + .messages() usage — but ChatStream is a public export.

Prompt for agents
The issue is in ChatStream (chat-client.ts). When onAssistantMessage is provided, the constructor tees the stream and starts a background IIFE on the collector branch that calls onAssistantMessage when done. The messages() method also calls onAssistantMessage after draining the consumer branch. Both fire the callback.

Fix options:
1. In messages(), skip the onAssistantMessage call when the tee collector is active (i.e., when _messageCollector is set). The collector IIFE handles the callback in that case.
2. Alternatively, don't tee at all when messages() is the intended consumption path — detect and short-circuit.

The simplest fix is option 1: in messages() at the end, guard the callback with something like:
  if (this.lastAssistantMessage && this.onAssistantMessage && !this._messageCollector) {
    this.onAssistantMessage(this.lastAssistantMessage);
  }

This way, when the constructor set up the collector IIFE (meaning _messageCollector is truthy), messages() defers to the collector for the callback.
Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

@ericallam ericallam force-pushed the feature/sessions-primitive branch from 37ea386 to 32b0e42 Compare May 14, 2026 09:32
@ericallam ericallam force-pushed the feature/chat-agent-runtime branch from c8b5507 to 8b6fcfd Compare May 14, 2026 09:32
Copy link
Copy Markdown
Contributor

@devin-ai-integration devin-ai-integration Bot left a comment

Choose a reason for hiding this comment

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

Devin Review found 2 new potential issues.

View 17 additional findings in Devin Review.

Open in Devin Review

Comment on lines +110 to +121
child.stdout?.on("data", (chunk: Buffer | string) => {
stdout += chunk.toString();
});
child.stderr?.on("data", (chunk: Buffer | string) => {
stderr += chunk.toString();
});
child.once("close", (code: number | null) => {
resolvePromise({
exitCode: code,
stdout: truncate(stdout, DEFAULT_BASH_OUTPUT_BYTES),
stderr: truncate(stderr, DEFAULT_BASH_OUTPUT_BYTES),
});
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.

🟡 Unbounded stdout/stderr accumulation in runBashInSkill before truncation can cause OOM

The runBashInSkill function accumulates stdout and stderr into strings with no size cap during the child process execution (stdout += chunk.toString()). The truncate() call only fires after the process exits in the close event handler. If an LLM tool call generates a command that produces very large output (e.g., cat /dev/zero | head -c 2000000000), the in-memory string grows unbounded until the process completes or the container OOMs. The DEFAULT_BASH_OUTPUT_BYTES limit of 64 KiB only trims the string after it has already been fully accumulated in memory.

Accumulation without cap vs. post-hoc truncation

Lines 110–114 accumulate without limit:

child.stdout?.on("data", (chunk) => {
  stdout += chunk.toString(); // grows unbounded
});

Lines 117–121 truncate only on close:

child.once("close", (code) => {
  resolvePromise({
    stdout: truncate(stdout, DEFAULT_BASH_OUTPUT_BYTES), // too late
  });
});
Prompt for agents
In runBashInSkill (agentSkillsRuntime.ts), the stdout and stderr strings accumulate data from the child process data events without any size cap. The truncate() call at close time only trims after the entire output is already in memory. To fix this, cap the accumulation in the on(data) handlers: once stdout.length exceeds DEFAULT_BASH_OUTPUT_BYTES, stop appending new chunks (or discard them). Same for stderr. This prevents an LLM-generated command from producing enough output to OOM the worker process. A simple approach: check the current length before appending and skip/trim chunks that would push past the limit. You could also track a boolean like stdoutCapped to avoid repeated length checks after the cap is hit.
Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

Comment on lines +61 to +71
function safeJoinInside(root: string, relative: string): string {
if (nodePath.isAbsolute(relative)) {
throw new Error(`Path must be relative to the skill directory: ${relative}`);
}
const resolved = nodePath.resolve(root, relative);
const normalized = nodePath.resolve(root) + nodePath.sep;
if (resolved !== nodePath.resolve(root) && !resolved.startsWith(normalized)) {
throw new Error(`Path escapes the skill directory: ${relative}`);
}
return resolved;
}
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.

🚩 safeJoinInside path-traversal guard does not resolve symlinks

The safeJoinInside function uses nodePath.resolve() which normalizes .. segments but does NOT resolve filesystem symlinks. A symlink placed inside the skill directory (e.g., skills/demo/link -> /etc) would pass the prefix check since resolve(skillPath, 'link/passwd') produces {skillPath}/link/passwd which starts with the normalized root. The actual fs.readFile call would then follow the symlink to /etc/passwd. In practice this is low-risk because skill directories are developer-authored content bundled at build time — the developer controls what's there. However, if bash tool calls create symlinks at runtime (the bash tool runs with the skill dir as cwd), an LLM could exploit this. Using fs.realpath on the resolved path before the prefix check would close this gap.

Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

@ericallam ericallam force-pushed the feature/chat-agent-runtime branch from 8b6fcfd to 353fbf1 Compare May 14, 2026 11:06
@ericallam ericallam force-pushed the feature/sessions-primitive branch from 219e550 to be1a6cf Compare May 14, 2026 12:13
@ericallam ericallam force-pushed the feature/chat-agent-runtime branch from 353fbf1 to 9a8b726 Compare May 14, 2026 12:13
Copy link
Copy Markdown
Contributor

@devin-ai-integration devin-ai-integration Bot left a comment

Choose a reason for hiding this comment

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

Devin Review found 2 new potential issues.

View 18 additional findings in Devin Review.

Open in Devin Review

Comment on lines +401 to +404
console.log("[usePendingMessages] promote blocked — already promoted:", id);
return;
}
console.log("[usePendingMessages] promoting:", id);
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.

🟡 Debug console.log statements left in production promoteToSteering callback

The promoteToSteering callback in usePendingMessages contains two console.log calls (lines 401 and 404) with the [usePendingMessages] prefix. These are clearly debug/development logs that will appear in end-users' browser consoles whenever they interact with the promote-to-steering feature. Production React hooks should not emit debug logs.

Suggested change
console.log("[usePendingMessages] promote blocked — already promoted:", id);
return;
}
console.log("[usePendingMessages] promoting:", id);
if (promotedIdsRef.current.has(id)) {
return;
}
promotedIdsRef.current.add(id);
Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

Comment on lines +745 to +748
onTurnComplete?.({
chatId,
lastEventId: state.lastEventId,
});
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.

🟡 Unhandled promise rejection from async onTurnComplete callback in AgentChat

In AgentChat.subscribeToSessionStream, the onTurnComplete callback (typed void | Promise<void>) is invoked fire-and-forget at chat-client.ts:745 without .catch() protection. If a customer passes an async onTurnComplete (e.g., persisting lastEventId to a database) and the operation throws, the returned Promise rejection is unhandled. In Node.js with --unhandled-rejections=throw (the default in recent versions), this crashes the process. The comparable browser-side transport (chat.ts:1207) uses synchronous notifySessionChange and doesn't have this issue.

Affected code

At line 745, onTurnComplete?.({...}) returns a potentially-rejected Promise that is never caught:

onTurnComplete?.({
  chatId,
  lastEventId: state.lastEventId,
});
internalAbort.abort();
Suggested change
onTurnComplete?.({
chatId,
lastEventId: state.lastEventId,
});
Promise.resolve(onTurnComplete?.({
chatId,
lastEventId: state.lastEventId,
})).catch(() => {});
Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

@ericallam ericallam force-pushed the feature/chat-agent-runtime branch from 9a8b726 to 7690836 Compare May 14, 2026 12:29
ericallam added a commit that referenced this pull request May 14, 2026
…3542)

## Summary

A `/sessions` dashboard for inspecting durable Sessions, an `AGENT` /
`SCHEDULED` task-kind filter for the runs list, and the server-side
hardening (rate-limit exemption for packets, retry-with-backoff on
stream appends, typed too-large-chunk error) that the `chat.agent`
runtime in #3543 needs. Builds on the Sessions primitive shipped in
#3417.

## Design

The Sessions list + detail routes mirror the run inspector pattern.
`TaskTriggerSource` gains `AGENT` and `SCHEDULED` values, persisted on
`BackgroundWorker.taskKind` and `TaskRun.taskKind` (plus a matching
Clickhouse column), so the runs list can filter by kind.

New `@trigger.dev/core` modules — `sessionStreams`, `inputStreams`, a
`sessionStreamInstance` for realtime streams, and the
`realtime-streams-api` / `session-streams-api` surfaces — expose the
typed shapes that chat.agent will use to drive `session.out`.
`ChatChunkTooLargeError` lets the runtime drop oversized chunks with a
typed surface instead of failing the run. `s2Append` retries transient
failures with exponential backoff. `/api/v[12]/packets/*` is exempt from
customer rate limits so chat snapshot reads and writes don't get
throttled under load.

## Stack

Part of a 4-PR stack. Merge bottom-up.

1. **This PR** (#3542) → `main`
2. #3543#3542 — `chat.agent` runtime + browser transport
3. #3545#3543 — agent-view dashboard
4. #3546#3545 — ai-chat reference + MCP tooling

Replaces #3173 (closed).

<!-- GitButler Footer Boundary Top -->
---
This is **part 5 of 5 in a stack** made with GitButler:
- <kbd>&nbsp;5&nbsp;</kbd> #3612
- <kbd>&nbsp;4&nbsp;</kbd> #3546
- <kbd>&nbsp;3&nbsp;</kbd> #3545
- <kbd>&nbsp;2&nbsp;</kbd> #3543
- <kbd>&nbsp;1&nbsp;</kbd> #3542 👈 
<!-- GitButler Footer Boundary Bottom -->
Base automatically changed from feature/sessions-primitive to main May 14, 2026 12:41
Adds the chat.agent({...}) task definition (server runtime) and the
browser-side TriggerChatTransport + AgentChat that drives it from a
React or Next.js app. The runtime sits on top of the Sessions primitive
and handles the durable conversational task lifecycle.

Server runtime:
- chat.agent({...}) — session-aware task definition
- Lifecycle hooks: onChatStart, onTurnStart, onTurnComplete, onAction,
  onValidateMessages, hydrateMessages
- chat.history read primitives for HITL flows
- chat.local, chat.headStart, chat.handover, oomMachine
- Delta-only wire + S3 snapshot reconstruction at run boot
- Actions are no longer turns

Browser transport:
- TriggerChatTransport (ai-sdk Transport): delta-only wire sends,
  SSE reconnection with lastEventId resume, stop/abort cleanup,
  dynamic accessToken refresh
- AgentChat: direct programmatic API
- useTriggerChatTransport (React hook)
- chat-tab-coordinator: cross-tab leader election

Includes the chat-agent, chat-agent-delta-wire-snapshots,
chat-history-read-primitives, chat-head-start, chat-actions-no-turn,
chat-session-attributes, agent-skills, and mock-chat-agent-test-harness
changesets.
@ericallam ericallam force-pushed the feature/chat-agent-runtime branch from 7690836 to 535245f Compare May 14, 2026 12:44
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.

2 participants