diff --git a/.changeset/chat-wire-types-portable.md b/.changeset/chat-wire-types-portable.md new file mode 100644 index 0000000000..4b5296f29b --- /dev/null +++ b/.changeset/chat-wire-types-portable.md @@ -0,0 +1,5 @@ +--- +"@trigger.dev/sdk": patch +--- + +Fix TS2742 ("inferred type cannot be named") when exporting a `chat.agent` from a project with declaration emit: `ChatTaskWirePayload` and `ChatInputChunk` are now declared in the public `@trigger.dev/sdk/chat` subpath, so inferred agent types emit portable declarations and the wire types are directly importable. diff --git a/packages/trigger-sdk/src/v3/ai-shared.ts b/packages/trigger-sdk/src/v3/ai-shared.ts index a77ffc2105..527c827cee 100644 --- a/packages/trigger-sdk/src/v3/ai-shared.ts +++ b/packages/trigger-sdk/src/v3/ai-shared.ts @@ -16,7 +16,7 @@ */ import type { Task, AnyTask } from "@trigger.dev/core/v3"; -import type { InferUITools, ModelMessage, ToolSet, UIDataTypes, UIMessage } from "ai"; +import type { InferUITools, ToolSet, UIDataTypes, UIMessage } from "ai"; /** * Message-part `type` value for the pending-message data part the agent @@ -24,142 +24,11 @@ import type { InferUITools, ModelMessage, ToolSet, UIDataTypes, UIMessage } from */ export const PENDING_MESSAGE_INJECTED_TYPE = "data-pending-message-injected" as const; -/** - * The wire payload shape sent by `TriggerChatTransport`. - * Uses `metadata` to match the AI SDK's `ChatRequestOptions` field name. - * - * Slim wire: at most ONE message per record. The agent runtime - * reconstructs prior history at run boot from a durable S3 snapshot + - * `session.out` replay (or `hydrateMessages` if registered). The wire is - * delta-only — see plan `vivid-humming-bonbon.md`. - */ -export type ChatTaskWirePayload = { - /** - * The single message being delivered on this trigger. Set for: - * - `submit-message`: the new user message OR a tool-approval-responded - * assistant message (with `state: "approval-responded"` tool parts). - * - `regenerate-message`: omitted (the agent slices its own history). - * - `preload` / `close` / `action`: omitted. - * - `handover-prepare`: omitted (use `headStartMessages` instead). - */ - message?: TMessage; - /** - * Bespoke escape hatch for `chat.headStart`. The customer's HTTP route - * handler ships full `UIMessage[]` history at the very first turn — before - * any snapshot exists. The route handler isn't subject to the - * `MAX_APPEND_BODY_BYTES` cap on `/in/append` because it goes through the - * customer's own HTTP endpoint. Used ONLY by `trigger: "handover-prepare"`. - * Ignored on every other trigger. - */ - headStartMessages?: TMessage[]; - chatId: string; - trigger: - | "submit-message" - | "regenerate-message" - | "preload" - | "close" - | "action" - /** - * The customer's `chat.handover` route handler kicked us off in - * parallel with the first-turn `streamText` running in the warm - * Next.js process. The run sits idle on `session.in` waiting for - * a `kind: "handover"` (continue from tool execution) or - * `kind: "handover-skip"` (handler finished pure-text, exit - * cleanly). See `chat.handover` in `@trigger.dev/sdk/chat-server`. - */ - | "handover-prepare"; - messageId?: string; - metadata?: TMetadata; - /** Custom action payload when `trigger` is `"action"`. Validated against `actionSchema` on the backend. */ - action?: unknown; - /** Whether this run is continuing an existing chat whose previous run ended. */ - continuation?: boolean; - /** The run ID of the previous run (only set when `continuation` is true). */ - previousRunId?: string; - /** Override idle timeout for this run (seconds). Set by transport.preload(). */ - idleTimeoutInSeconds?: number; - /** - * The friendlyId of the Session primitive backing this chat. The - * transport opens (or lazy-creates) the session with - * `externalId = chatId` on first message, then sends this friendlyId - * through to the run so the agent can attach to `.in` / `.out` - * without needing to round-trip through the control plane again. - * Optional for backward-compat while the migration is in flight; - * required once the legacy run-scoped stream path is removed. - */ - sessionId?: string; -}; - -/** - * One chunk on the chat input stream. `kind` discriminates the variants — - * a single ordered stream now carries all the signals the old three-stream - * split did (`chat-messages`, `chat-stop`, plus action messages piggybacked - * on `chat-messages`). - */ -export type ChatInputChunk = - | { - kind: "message"; - /** - * Full wire payload for a new user message or regeneration. Mirrors - * what the legacy `chat-messages` input stream carried. - */ - payload: ChatTaskWirePayload; - } - | { - kind: "stop"; - /** Optional human-readable reason. Maps to the legacy `chat-stop` record. */ - message?: string; - } - | { - /** - * Sent by `chat.headStart` when the customer's first-turn - * `streamText` finishes. The agent run (currently parked in - * `handover-prepare`) wakes, seeds its accumulators with - * `partialAssistantMessage`, and runs the normal turn loop - * (`onChatStart` → `onTurnStart` → … → `onTurnComplete`). - * - * What happens after that depends on `isFinal`: - * - * - `isFinal: false` — step 1 ended with `finishReason: - * "tool-calls"`. The partial carries the assistant's - * tool-call(s) wrapped in AI SDK's tool-approval round. The - * agent's `streamText` runs the approved tools and continues - * from step 2. - * - `isFinal: true` — step 1 ended pure-text (no tool calls). - * The partial carries the final assistant text. The agent - * skips the LLM call entirely (the response is already - * complete on the customer side) and runs `onTurnComplete` - * with the partial as `responseMessage` so persistence and - * any post-turn work fire normally. - */ - kind: "handover"; - /** Customer's step-1 response messages (ModelMessage form). */ - partialAssistantMessage: ModelMessage[]; - /** - * The UI messageId the customer's handler used for its step-1 - * assistant message. The agent reuses this so any post-handover - * chunks (tool-output-available, step-2 text, data-* parts - * written by hooks) merge into the SAME assistant message on - * the browser side instead of starting a new one. - */ - messageId?: string; - /** - * Whether the customer's step 1 is the final response. See - * `kind` description above for the two branches. - */ - isFinal: boolean; - } - | { - /** - * Sent by `chat.headStart` only when the customer's handler - * ABORTS before producing a finishReason (e.g., dispatch error, - * stream cancelled before any tokens). The agent run exits - * cleanly without firing turn hooks. Normal pure-text and - * tool-call finishes go through `kind: "handover"` with the - * appropriate `isFinal` flag. - */ - kind: "handover-skip"; - }; +// Declared in `chat.ts` (a public subpath) so customer declaration emit can +// name them — declaring them here breaks `declaration: true` consumers with +// TS2742, since this module isn't reachable via the package exports map. +import type { ChatTaskWirePayload } from "./chat.js"; +export type { ChatTaskWirePayload, ChatInputChunk } from "./chat.js"; /** * Extracts the client-data (`metadata`) type from a chat task. diff --git a/packages/trigger-sdk/src/v3/chat.ts b/packages/trigger-sdk/src/v3/chat.ts index f625155189..015b57eab9 100644 --- a/packages/trigger-sdk/src/v3/chat.ts +++ b/packages/trigger-sdk/src/v3/chat.ts @@ -23,7 +23,13 @@ * ``` */ -import type { ChatTransport, UIMessage, UIMessageChunk, ChatRequestOptions } from "ai"; +import type { + ChatTransport, + ModelMessage, + UIMessage, + UIMessageChunk, + ChatRequestOptions, +} from "ai"; import { controlSubtype, headerValue, @@ -37,10 +43,123 @@ function byteLength(body: string): number { return new TextEncoder().encode(body).byteLength; } import { ChatTabCoordinator } from "./chat-tab-coordinator.js"; -import type { ChatInputChunk, ChatTaskWirePayload } from "./ai-shared.js"; import { slimSubmitMessageForWire } from "./ai-shared.js"; const DEFAULT_BASE_URL = "https://api.trigger.dev"; + +/** + * The wire payload shape sent by `TriggerChatTransport`. + * Uses `metadata` to match the AI SDK's `ChatRequestOptions` field name. + * + * Slim wire: at most ONE message per record. The agent runtime + * reconstructs prior history at run boot from a durable S3 snapshot + + * `session.out` replay (or `hydrateMessages` if registered). + * + * Declared here (rather than the internal shared module) so `declaration: + * true` consumers can name inferred agent types via this public subpath. + */ +export type ChatTaskWirePayload = { + /** + * The single message being delivered on this trigger. Set for: + * - `submit-message`: the new user message OR a tool-approval-responded + * assistant message (with `state: "approval-responded"` tool parts). + * - `regenerate-message`: omitted (the agent slices its own history). + * - `preload` / `close` / `action`: omitted. + * - `handover-prepare`: omitted (use `headStartMessages` instead). + */ + message?: TMessage; + /** + * Bespoke escape hatch for `chat.headStart`. The customer's HTTP route + * handler ships full `UIMessage[]` history at the very first turn — before + * any snapshot exists. The route handler isn't subject to the + * `MAX_APPEND_BODY_BYTES` cap on `/in/append` because it goes through the + * customer's own HTTP endpoint. Used ONLY by `trigger: "handover-prepare"`. + * Ignored on every other trigger. + */ + headStartMessages?: TMessage[]; + chatId: string; + trigger: + | "submit-message" + | "regenerate-message" + | "preload" + | "close" + | "action" + /** + * The customer's `chat.handover` route handler kicked us off in + * parallel with the first-turn `streamText` running in the warm + * Next.js process. The run sits idle on `session.in` waiting for + * a `kind: "handover"` (continue from tool execution) or + * `kind: "handover-skip"` (handler finished pure-text, exit + * cleanly). See `chat.handover` in `@trigger.dev/sdk/chat-server`. + */ + | "handover-prepare"; + messageId?: string; + metadata?: TMetadata; + /** Custom action payload when `trigger` is `"action"`. Validated against `actionSchema` on the backend. */ + action?: unknown; + /** Whether this run is continuing an existing chat whose previous run ended. */ + continuation?: boolean; + /** The run ID of the previous run (only set when `continuation` is true). */ + previousRunId?: string; + /** Override idle timeout for this run (seconds). Set by transport.preload(). */ + idleTimeoutInSeconds?: number; + /** + * The friendlyId of the Session primitive backing this chat. The + * transport opens (or lazy-creates) the session with + * `externalId = chatId` on first message, then sends this friendlyId + * through to the run so the agent can attach to `.in` / `.out` + * without needing to round-trip through the control plane again. + * Optional for backward-compat while the migration is in flight; + * required once the legacy run-scoped stream path is removed. + */ + sessionId?: string; +}; + +/** + * One chunk on the chat input stream. `kind` discriminates the variants — + * a single ordered stream carries all the signals (`message`, `stop`, + * `handover`, `handover-skip`). + */ +export type ChatInputChunk = + | { + kind: "message"; + /** Full wire payload for a new user message or regeneration. */ + payload: ChatTaskWirePayload; + } + | { + kind: "stop"; + /** Optional human-readable reason. */ + message?: string; + } + | { + /** + * Sent by `chat.headStart` when the customer's first-turn + * `streamText` finishes. The agent run (parked in + * `handover-prepare`) wakes, seeds its accumulators with + * `partialAssistantMessage`, and runs the normal turn loop. + * `isFinal: false` means step 1 ended in tool-calls (the agent + * executes them and continues); `isFinal: true` means step 1 was + * the complete response (the agent skips the LLM call). + */ + kind: "handover"; + /** Customer's step-1 response messages (ModelMessage form). */ + partialAssistantMessage: ModelMessage[]; + /** + * The UI messageId the customer's handler used for its step-1 + * assistant message, reused so post-handover chunks merge into + * the SAME assistant message on the browser side. + */ + messageId?: string; + isFinal: boolean; + } + | { + /** + * Sent by `chat.headStart` only when the customer's handler + * ABORTS before producing a finishReason. The agent run exits + * cleanly without firing turn hooks. + */ + kind: "handover-skip"; + }; const DEFAULT_STREAM_TIMEOUT_SECONDS = 120; /** diff --git a/packages/trigger-sdk/test/declaration-emit.test.ts b/packages/trigger-sdk/test/declaration-emit.test.ts new file mode 100644 index 0000000000..9b3d70c1dd --- /dev/null +++ b/packages/trigger-sdk/test/declaration-emit.test.ts @@ -0,0 +1,123 @@ +import { + cpSync, + existsSync, + mkdirSync, + mkdtempSync, + realpathSync, + rmSync, + symlinkSync, +} from "node:fs"; +import { tmpdir } from "node:os"; +import { join, resolve } from "node:path"; +import { describe, expect, it } from "vitest"; +import ts from "typescript"; + +/** + * Regression test for declaration-emit portability (customer TS2742). + * + * Simulates a real consumer: the BUILT package (dist + package.json) is + * copied (not symlinked — tsc's module-specifier generation only uses the + * exports map for files under node_modules real paths) into a temp + * project's node_modules, then a chat-builder agent export is compiled + * with `declaration: true`. Every type appearing in the inferred public + * surface must be nameable through a public package specifier; a type + * declared in a module that isn't reachable via the exports map produces + * a relative-path import in the emit and TS2742 for consumers. + */ + +const packageRoot = resolve(__dirname, ".."); +const distDir = join(packageRoot, "dist"); +const coreRoot = resolve(packageRoot, "../core"); + +const FIXTURE_SOURCE = ` +import { chat } from "@trigger.dev/sdk/ai"; +import { streamText } from "ai"; +import type { UIMessage } from "ai"; +import { z } from "zod"; + +type FixtureUIMessage = UIMessage; + +export const fixtureAgent = chat + .withUIMessage() + .withClientData({ schema: z.object({ userId: z.string() }) }) + .agent({ + id: "fixture-agent", + run: async ({ messages, signal }) => { + return streamText({ model: "openai/gpt-5" as never, messages, abortSignal: signal }); + }, + }); +`; + +describe("declaration emit portability", () => { + it.skipIf(!existsSync(distDir) || !existsSync(join(coreRoot, "dist")))( + "emits portable declarations for inferred chat agent types", + () => { + const consumerDir = mkdtempSync(join(tmpdir(), "sdk-decl-emit-")); + try { + const scopedDir = join(consumerDir, "node_modules", "@trigger.dev"); + mkdirSync(scopedDir, { recursive: true }); + + for (const [name, root] of [ + ["sdk", packageRoot], + ["core", coreRoot], + ] as const) { + const target = join(scopedDir, name); + mkdirSync(target, { recursive: true }); + cpSync(join(root, "package.json"), join(target, "package.json")); + cpSync(join(root, "dist"), join(target, "dist"), { recursive: true }); + } + + // Third-party type deps resolve fine through symlinks. + for (const dep of ["ai", "zod", "@ai-sdk/provider"]) { + const source = realpathSync(join(packageRoot, "node_modules", dep)); + const target = join(consumerDir, "node_modules", dep); + mkdirSync(resolve(target, ".."), { recursive: true }); + symlinkSync(source, target); + } + + const fixturePath = join(consumerDir, "agent.ts"); + ts.sys.writeFile(fixturePath, FIXTURE_SOURCE); + + const emitted = new Map(); + const host = ts.createCompilerHost({}); + host.writeFile = (fileName, text) => emitted.set(fileName, text); + + const program = ts.createProgram({ + rootNames: [fixturePath], + options: { + target: ts.ScriptTarget.ES2022, + module: ts.ModuleKind.NodeNext, + moduleResolution: ts.ModuleResolutionKind.NodeNext, + strict: true, + declaration: true, + emitDeclarationOnly: true, + skipLibCheck: true, + outDir: join(consumerDir, "out"), + rootDir: consumerDir, + }, + host, + }); + + const emitResult = program.emit(); + const diagnostics = [ + ...ts.getPreEmitDiagnostics(program), + ...emitResult.diagnostics, + ].filter((d) => d.category === ts.DiagnosticCategory.Error); + const formatted = diagnostics.map((d) => + ts.flattenDiagnosticMessageText(d.messageText, "\n") + ); + expect(formatted).toEqual([]); + + const dts = [...emitted.entries()].find(([name]) => name.endsWith("agent.d.ts"))?.[1]; + expect(dts).toBeDefined(); + // The payload generic must be named via the public subpath, and the + // emit must not fall back to file paths into the package. + expect(dts).toContain('import("@trigger.dev/sdk/chat").ChatTaskWirePayload'); + expect(dts).not.toMatch(/import\("\.{1,2}\//); + expect(dts).not.toContain("ai-shared"); + } finally { + rmSync(consumerDir, { recursive: true, force: true }); + } + } + ); +});