fix(sdk): make inferred chat agent types portable for declaration emit (#4218)

## Summary

Exporting a `chat.agent` from a project with `declaration: true` failed
with TS2742: the inferred type of the agent references
`ChatTaskWirePayload`, which was declared in an internal module not
reachable through the package exports map, so tsc could only name it via
a file path into `node_modules` and refused to emit. Consumers had to
hand-mirror the wire type and annotate their export.

## Fix

`ChatTaskWirePayload` and `ChatInputChunk` are now declared in
`@trigger.dev/sdk/chat` (a public subpath) and re-exported type-only
from the internal shared module, so every internal import is unchanged
and the browser/server module split is untouched. Declaration emit for
an inferred agent type now produces a portable specifier:

```ts
export declare const chatAgent: Task<"chat-agent", import("@trigger.dev/sdk/chat").ChatTaskWirePayload<MyUIMessage, MyClientData>, unknown>;
```

As a side effect the wire types are now directly importable, which is
what affected users were reconstructing by hand.

## Verification

Reproduced against the built 4.5.2-equivalent package: a consumer
fixture with declaration emit produced `import("<file
path>/ai-shared.js")` in its declaration (the TS2742 trigger); after the
fix the same fixture emits the public specifier with zero diagnostics. A
regression test now builds that consumer simulation in a temp directory
on every test run: it copies the built package into a fake node_modules
(copied, not symlinked, because tsc only applies exports-map naming to
real node_modules paths), compiles the fixture with the TypeScript API,
and asserts no errors, no relative-path imports, and no internal module
references in the emit.
This commit is contained in:
Eric Allam
2026-07-10 07:35:03 +01:00
committed by GitHub
parent 6b0588bef1
commit 25254d0201
4 changed files with 255 additions and 139 deletions
+5
View File
@@ -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.
+6 -137
View File
@@ -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<TMessage extends UIMessage = UIMessage, TMetadata = unknown> = {
/**
* 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<TMessage extends UIMessage = UIMessage, TMetadata = unknown> =
| {
kind: "message";
/**
* Full wire payload for a new user message or regeneration. Mirrors
* what the legacy `chat-messages` input stream carried.
*/
payload: ChatTaskWirePayload<TMessage, TMetadata>;
}
| {
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.
+121 -2
View File
@@ -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<TMessage extends UIMessage = UIMessage, TMetadata = unknown> = {
/**
* 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<TMessage extends UIMessage = UIMessage, TMetadata = unknown> =
| {
kind: "message";
/** Full wire payload for a new user message or regeneration. */
payload: ChatTaskWirePayload<TMessage, TMetadata>;
}
| {
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;
/**
@@ -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<never, { kind: { value: string } }>;
export const fixtureAgent = chat
.withUIMessage<FixtureUIMessage>()
.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<string, string>();
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 });
}
}
);
});