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:
@@ -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.
|
||||
@@ -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.
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
);
|
||||
});
|
||||
Reference in New Issue
Block a user