Skip to content

Commit c295732

Browse files
committed
feat(sdk,webapp): X-Peek-Settled fast-close (webapp + docs)
Companion to the SDK opt-in. Webapp routes read X-Peek-Settled from the request and skip the tail peek when it isn't set, so active send-a-message paths can't race a stale trigger:turn-complete. Docs note the opt-in semantics; .server-changes records the change for the deploy log.
1 parent 6f924da commit c295732

11 files changed

Lines changed: 170 additions & 133 deletions

File tree

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
---
2+
"@trigger.dev/sdk": patch
3+
---
4+
5+
Three chat.agent fixes surfaced by smoke-testing the Sessions migration:
6+
7+
- **`chat.customAgent` now binds the session handle.** Previously only `chat.agent` set up the per-run `SessionHandle` in run-locals, so any custom agent that called `chat.messages.*`, `chat.stream.*`, `chat.createSession`, or `chat.createStopSignal` threw `chat.agent session handle is not initialized`. `chat.customAgent` now wraps the user's `run` function and opens the session via `payload.sessionId ?? payload.chatId` before invoking it, matching `chat.agent`'s behavior.
8+
- **Stop mid-stream no longer hangs the turn loop.** When the user aborts a turn, the AI SDK's `runResult.totalUsage` promise can stay unresolved indefinitely on Anthropic streams, blocking `onTurnComplete` / `writeTurnComplete` / the next-message wait. The await is now raced against a 2s timeout (mirroring the existing `onFinishPromise` race), so a stuck `totalUsage` falls through to a non-fatal "usage unknown" path and the turn finalizes correctly.
9+
- **New `chat.sessionId` getter.** Returns the friendlyId (`session_*`) of the run's backing Session. Useful in `onPreload` / `onChatStart` / `onTurnComplete` for persisting the session id alongside `runId` so reloads can resume the same conversation. Throws if called outside a chat.agent / chat.customAgent run.

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

Lines changed: 57 additions & 117 deletions
Original file line numberDiff line numberDiff line change
@@ -58,28 +58,14 @@ import { metadata } from "./metadata.js";
5858
import type { ResolvedPrompt } from "./prompt.js";
5959
import type { ResolvedSkill } from "./skill.js";
6060
// Bash-skill runtime lives in `./agentSkillsRuntime.ts` (exposed as
61-
// the `@trigger.dev/sdk/ai/skills-runtime` subpath) so `ai.ts`'s
62-
// top-level graph stays free of `node:*` imports. The chat-agent
63-
// surface in `@trigger.dev/sdk/ai` exports types like
64-
// `ChatUiMessage` / `CompactionChunkData` that frontend code
65-
// sometimes imports; Next.js + Webpack reject top-level node
66-
// builtins in the client graph even when only the type is used.
67-
//
68-
// The load path uses a computed-string variable so bundlers skip
69-
// static tracing — webpack emits a "Critical dependency" info-level
70-
// warning and defers resolution to runtime. On a server worker the
71-
// relative path lands next to `ai.js` in the emitted dist; on a
72-
// client bundle the bash tool is never invoked so the dynamic
73-
// import never fires.
74-
type BashRuntimeModule = typeof import("./agentSkillsRuntime.js");
75-
76-
let cachedBashRuntime: BashRuntimeModule | undefined;
77-
async function loadAgentSkillsRuntime(): Promise<BashRuntimeModule> {
78-
if (cachedBashRuntime) return cachedBashRuntime;
79-
const modulePath: string = "./agentSkillsRuntime.js";
80-
cachedBashRuntime = (await import(modulePath)) as BashRuntimeModule;
81-
return cachedBashRuntime;
82-
}
61+
// the `@trigger.dev/sdk/ai/skills-runtime` subpath). It's a normal
62+
// static import — `ai.ts` is server-only by reachability now that
63+
// browser-side primitives (PENDING_MESSAGE_INJECTED_TYPE and the
64+
// chat-task wire types) live in `./ai-shared.ts`. Any browser bundle
65+
// that wants those primitives imports `./ai-shared.js` directly and
66+
// never touches `ai.ts`'s module graph, so the `node:*` builtins
67+
// pulled in transitively here never reach a client chunk.
68+
import { runBashInSkill, readFileInSkill } from "./agentSkillsRuntime.js";
8369
import { streams } from "./streams.js";
8470
import {
8571
sessions,
@@ -801,69 +787,12 @@ async function withChatWriter<T>(fn: (writer: ChatWriter) => Promise<T> | T): Pr
801787
return result;
802788
}
803789

804-
/**
805-
* The wire payload shape sent by `TriggerChatTransport`.
806-
* Uses `metadata` to match the AI SDK's `ChatRequestOptions` field name.
807-
*/
808-
export type ChatTaskWirePayload<TMessage extends UIMessage = UIMessage, TMetadata = unknown> = {
809-
messages: TMessage[];
810-
chatId: string;
811-
trigger: "submit-message" | "regenerate-message" | "preload" | "close" | "action";
812-
messageId?: string;
813-
metadata?: TMetadata;
814-
/** Custom action payload when `trigger` is `"action"`. Validated against `actionSchema` on the backend. */
815-
action?: unknown;
816-
/** Whether this run is continuing an existing chat whose previous run ended. */
817-
continuation?: boolean;
818-
/** The run ID of the previous run (only set when `continuation` is true). */
819-
previousRunId?: string;
820-
/** Override idle timeout for this run (seconds). Set by transport.preload(). */
821-
idleTimeoutInSeconds?: number;
822-
/**
823-
* The friendlyId of the Session primitive backing this chat. The
824-
* transport opens (or lazy-creates) the session with
825-
* `externalId = chatId` on first message, then sends this friendlyId
826-
* through to the run so the agent can attach to `.in` / `.out`
827-
* without needing to round-trip through the control plane again.
828-
* Optional for backward-compat while the migration is in flight;
829-
* required once the legacy run-scoped stream path is removed.
830-
*/
831-
sessionId?: string;
832-
/**
833-
* Client-side `chat.store` value sent by the transport. Applied at turn
834-
* start before `run()` fires, overwriting any in-memory store value on the
835-
* agent (last-write-wins).
836-
*
837-
* The transport queues this via `setStore` / `applyStorePatch` and flushes
838-
* it with the next `sendMessage`. On the agent you typically don't read
839-
* this directly — it's applied into `chat.store` transparently.
840-
*/
841-
incomingStore?: unknown;
842-
};
843-
844-
/**
845-
* A single record on a chat Session's `.in` channel. The transport and
846-
* the agent agree on this tagged shape so one Session channel carries
847-
* all the signals the old three-stream split did (`chat-messages`,
848-
* `chat-stop`, plus action messages piggybacked on `chat-messages`).
849-
*
850-
* The agent subscribes via `session.in.on` / `.waitWithIdleTimeout`
851-
* inside `chatAgent()` and dispatches on `kind`.
852-
*/
853-
export type ChatInputChunk<TMessage extends UIMessage = UIMessage, TMetadata = unknown> =
854-
| {
855-
kind: "message";
856-
/**
857-
* Full wire payload for a new user message or regeneration. Mirrors
858-
* what the legacy `chat-messages` input stream carried.
859-
*/
860-
payload: ChatTaskWirePayload<TMessage, TMetadata>;
861-
}
862-
| {
863-
kind: "stop";
864-
/** Optional human-readable reason. Maps to the legacy `chat-stop` record. */
865-
message?: string;
866-
};
790+
// `ChatTaskWirePayload` and `ChatInputChunk` live in `./ai-shared.ts` so
791+
// browser bundles (which import them via `chat-client.ts` / `chat.ts`)
792+
// can pull the types without dragging `ai.ts` into the client graph.
793+
// Re-exported here so `@trigger.dev/sdk/ai` consumers see them.
794+
import type { ChatTaskWirePayload, ChatInputChunk } from "./ai-shared.js";
795+
export type { ChatTaskWirePayload, ChatInputChunk } from "./ai-shared.js";
867796

868797
/**
869798
* The payload shape passed to the `chatAgent` run function.
@@ -1547,7 +1476,12 @@ export type PendingMessagesOptions<TUIM extends UIMessage = UIMessage> = {
15471476
* between tool-call steps. The frontend can match on this to render
15481477
* injection points inline in the assistant response.
15491478
*/
1550-
export const PENDING_MESSAGE_INJECTED_TYPE = "data-pending-message-injected" as const;
1479+
// `PENDING_MESSAGE_INJECTED_TYPE` lives in `./ai-shared.ts` so the chat
1480+
// React hooks (`@trigger.dev/sdk/chat/react`) can import it without
1481+
// dragging `ai.ts` into the browser graph. Re-exported here so
1482+
// `@trigger.dev/sdk/ai` consumers still see it.
1483+
export { PENDING_MESSAGE_INJECTED_TYPE } from "./ai-shared.js";
1484+
import { PENDING_MESSAGE_INJECTED_TYPE } from "./ai-shared.js";
15511485

15521486
/** @internal */
15531487
type SteeringQueueEntry = { uiMessage: UIMessage; modelMessages: ModelMessage[] };
@@ -2240,7 +2174,6 @@ function getChatPrompt(): ChatPromptValue {
22402174
/** @internal */
22412175
const chatSkillsKey = locals.create<ResolvedSkill[]>("chat.skills");
22422176

2243-
22442177
/**
22452178
* Store resolved skills for the current run. Call from any hook
22462179
* (`onPreload`, `onChatStart`, `onTurnStart`) or `run()`.
@@ -2336,7 +2269,6 @@ export function buildSkillTools(skills: ResolvedSkill[]): Record<string, Tool> {
23362269
return { error: `Skill "${skillName}" not found.` };
23372270
}
23382271
try {
2339-
const { readFileInSkill } = await loadAgentSkillsRuntime();
23402272
return await readFileInSkill({
23412273
skillPath: skill.path,
23422274
relativePath: relPath,
@@ -2371,7 +2303,6 @@ export function buildSkillTools(skills: ResolvedSkill[]): Record<string, Tool> {
23712303
return { error: `Skill "${skillName}" not found.` };
23722304
}
23732305
try {
2374-
const { runBashInSkill } = await loadAgentSkillsRuntime();
23752306
return await runBashInSkill({
23762307
skillPath: skill.path,
23772308
command,
@@ -3595,7 +3526,7 @@ function chatCustomAgent<
35953526
>(
35963527
options: ChatCustomAgentOptions<TIdentifier, TClientDataSchema, TUIMessage>
35973528
): Task<TIdentifier, ChatTaskWirePayload<TUIMessage, inferSchemaIn<TClientDataSchema>>, unknown> {
3598-
const { clientDataSchema, ...restOptions } = options;
3529+
const { clientDataSchema, run: userRun, ...restOptions } = options;
35993530

36003531
const task = createTask<
36013532
TIdentifier,
@@ -3605,6 +3536,20 @@ function chatCustomAgent<
36053536
...restOptions,
36063537
triggerSource: "agent",
36073538
agentConfig: { type: "ai-sdk-chat" },
3539+
run: async (
3540+
payload: ChatTaskWirePayload<TUIMessage, inferSchemaIn<TClientDataSchema>>,
3541+
runOptions
3542+
) => {
3543+
// Bind the run to its backing Session so module-level helpers
3544+
// (chat.messages, chat.stream, chat.createStopSignal, chat.createSession)
3545+
// resolve to this chat's `.in` / `.out` channels — same setup as
3546+
// chat.agent. Without this, any helper that calls getChatSession()
3547+
// throws "session handle is not initialized".
3548+
const sessionIdForHandle = payload.sessionId ?? payload.chatId;
3549+
locals.set(chatSessionHandleKey, sessions.open(sessionIdForHandle));
3550+
locals.set(chatAgentRunContextKey, runOptions.ctx);
3551+
return userRun(payload, runOptions);
3552+
},
36083553
});
36093554

36103555
// Register clientDataSchema so the CLI converts it to JSONSchema
@@ -4553,10 +4498,17 @@ function chatAgent<
45534498

45544499
// Capture token usage from the streamText result (if available).
45554500
// totalUsage is a PromiseLike that resolves after the stream is consumed.
4501+
// Race with a 2s timeout — on stop-abort the AI SDK's totalUsage
4502+
// promise can hang indefinitely (the underlying provider stream
4503+
// never reports final usage), which would block the turn loop
4504+
// from ever firing onTurnComplete / writeTurnComplete.
45564505
let turnUsage: LanguageModelUsage | undefined;
45574506
if (runResult != null && typeof (runResult as any).totalUsage?.then === "function") {
45584507
try {
4559-
turnUsage = await (runResult as any).totalUsage;
4508+
turnUsage = (await Promise.race([
4509+
(runResult as any).totalUsage,
4510+
new Promise<undefined>((r) => setTimeout(() => r(undefined), 2_000)),
4511+
])) as LanguageModelUsage | undefined;
45604512
} catch {
45614513
/* non-fatal — usage capture failed */
45624514
}
@@ -6882,32 +6834,12 @@ function chatLocal<T extends Record<string, unknown>>(options: { id: string }):
68826834
* // { model?: string; userId: string }
68836835
* ```
68846836
*/
6885-
export type InferChatClientData<TTask extends AnyTask> = TTask extends Task<
6886-
string,
6887-
ChatTaskWirePayload<any, infer TMetadata>,
6888-
any
6889-
>
6890-
? TMetadata
6891-
: unknown;
6892-
6893-
/**
6894-
* Extracts the UI message type from a chat task (wire payload `messages` items).
6895-
*
6896-
* @example
6897-
* ```ts
6898-
* import type { InferChatUIMessage } from "@trigger.dev/sdk/ai";
6899-
* import type { myChat } from "@/trigger/chat";
6900-
*
6901-
* type Msg = InferChatUIMessage<typeof myChat>;
6902-
* ```
6903-
*/
6904-
export type InferChatUIMessage<TTask extends AnyTask> = TTask extends Task<
6905-
string,
6906-
ChatTaskWirePayload<infer TUIM extends UIMessage, any>,
6907-
any
6908-
>
6909-
? TUIM
6910-
: UIMessage;
6837+
// `InferChatClientData` and `InferChatUIMessage` live in `./ai-shared.ts`
6838+
// so the chat React hooks can import them without dragging `ai.ts` into
6839+
// the browser graph. Re-exported here so `@trigger.dev/sdk/ai` consumers
6840+
// still see them.
6841+
import type { InferChatClientData, InferChatUIMessage } from "./ai-shared.js";
6842+
export type { InferChatClientData, InferChatUIMessage } from "./ai-shared.js";
69116843

69126844
/**
69136845
* Options for {@link createChatTriggerAction}.
@@ -7124,6 +7056,14 @@ export const chat = {
71247056
compact: chatCompact,
71257057
/** Read the current compaction state (summary + base message count). */
71267058
getCompactionState,
7059+
/**
7060+
* The friendlyId (`session_*`) of the backing Session for the current chat.agent run.
7061+
* Useful for persisting alongside `runId` so reloads can resume the same session.
7062+
* Throws if called outside a chat.agent `run()` or hook.
7063+
*/
7064+
get sessionId(): string {
7065+
return getChatSession().id;
7066+
},
71277067
};
71287068

71297069
/**

references/ai-chat/package.json

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -38,10 +38,10 @@
3838
"@types/react": "^19",
3939
"@types/react-dom": "^19",
4040
"@types/turndown": "^5.0.6",
41-
"tailwindcss": "^4",
4241
"prisma": "^7.4.2",
42+
"tailwindcss": "^4",
4343
"trigger.dev": "workspace:*",
4444
"typescript": "^5",
4545
"vitest": "^3.1.4"
4646
}
47-
}
47+
}
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
-- AlterTable
2+
ALTER TABLE "Chat" ADD COLUMN "model" TEXT NOT NULL DEFAULT 'gpt-4o-mini';
3+
4+
-- AlterTable
5+
ALTER TABLE "User" ADD COLUMN "githubToken" TEXT;
Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
-- AlterTable
2+
ALTER TABLE "ChatSession" ADD COLUMN "sessionId" TEXT;

references/ai-chat/prisma/schema.prisma

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@ model Chat {
3232

3333
model ChatSession {
3434
id String @id // chatId
35+
sessionId String? // backing Session friendlyId — session_*
3536
runId String
3637
publicAccessToken String
3738
lastEventId String?

references/ai-chat/src/app/actions.ts

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -143,6 +143,7 @@ export async function getSessionForChat(chatId: string) {
143143
const session = await prisma.chatSession.findUnique({ where: { id: chatId } });
144144
if (!session) return null;
145145
return {
146+
sessionId: session.sessionId ?? undefined,
146147
runId: session.runId,
147148
publicAccessToken: session.publicAccessToken,
148149
lastEventId: session.lastEventId ?? undefined,
@@ -151,10 +152,13 @@ export async function getSessionForChat(chatId: string) {
151152

152153
export async function getAllSessions() {
153154
const sessions = await prisma.chatSession.findMany();
154-
const result: Record<string, { runId: string; publicAccessToken: string; lastEventId?: string }> =
155-
{};
155+
const result: Record<
156+
string,
157+
{ sessionId?: string; runId: string; publicAccessToken: string; lastEventId?: string }
158+
> = {};
156159
for (const s of sessions) {
157160
result[s.id] = {
161+
sessionId: s.sessionId ?? undefined,
158162
runId: s.runId,
159163
publicAccessToken: s.publicAccessToken,
160164
lastEventId: s.lastEventId ?? undefined,

references/ai-chat/src/components/chat-app.tsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ type ChatMeta = {
2626
};
2727

2828
type SessionInfo = {
29+
sessionId?: string;
2930
runId: string;
3031
publicAccessToken: string;
3132
lastEventId?: string;

references/ai-chat/src/components/chat-view.tsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ import { useCallback, useEffect, useState } from "react";
1616
import { useRouter } from "next/navigation";
1717

1818
type SessionInfo = {
19+
sessionId?: string;
1920
runId: string;
2021
publicAccessToken: string;
2122
lastEventId?: string;

0 commit comments

Comments
 (0)