diff --git a/.changeset/ai-tool-helpers.md b/.changeset/ai-tool-helpers.md new file mode 100644 index 00000000000..09e3b612ada --- /dev/null +++ b/.changeset/ai-tool-helpers.md @@ -0,0 +1,15 @@ +--- +"@trigger.dev/sdk": patch +--- + +Add `ai.toolExecute(task)` so you can wire a Trigger subtask in as the `execute` handler of an AI SDK `tool()` while defining `description` and `inputSchema` yourself — useful when you want full control over the tool surface and just need Trigger's subtask machinery for the body. + +```ts +const myTool = tool({ + description: "...", + inputSchema: z.object({ ... }), + execute: ai.toolExecute(mySubtask), +}); +``` + +`ai.tool(task)` (`toolFromTask`) keeps doing the all-in-one wrap and now aligns its return type with AI SDK's `ToolSet`. Minimum `ai` peer raised to `^6.0.116` to avoid cross-version `ToolSet` mismatches in monorepos. diff --git a/.changeset/mcp-agent-chat-sessions.md b/.changeset/mcp-agent-chat-sessions.md new file mode 100644 index 00000000000..c3f01aebf28 --- /dev/null +++ b/.changeset/mcp-agent-chat-sessions.md @@ -0,0 +1,5 @@ +--- +"trigger.dev": patch +--- + +The CLI MCP server's agent-chat tools (`start_agent_chat`, `send_agent_message`, `close_agent_chat`) now run on the new Sessions primitive, so AI assistants driving a `chat.agent` get the same idempotent-by-`chatId`, durable-across-runs behavior the browser transport gets. Required PAT scopes go from `write:inputStreams` to `read:sessions` + `write:sessions`. diff --git a/.claude/review-guides/chat-agent-sessions-row-agnostic.md b/.claude/review-guides/chat-agent-sessions-row-agnostic.md new file mode 100644 index 00000000000..7fb9851f308 --- /dev/null +++ b/.claude/review-guides/chat-agent-sessions-row-agnostic.md @@ -0,0 +1,287 @@ +# Review guide — chat.agent on Sessions, row-agnostic addressing + +Scope: the 12 uncommitted files. **No new behaviour beyond the public surface +already on this branch** — this is plumbing cleanup that: + +1. Eliminates the transport's session-creation step +2. Makes `chatId` the universal addressing string everywhere +3. Makes the server-side stream/append/wait routes row-agnostic + +## The two design moves + +**Move 1 — agent owns session lifecycle.** `chat.agent` and +`chat.customAgent` upsert the backing `Session` row at bind, fire-and-forget, +keyed on `externalId = payload.chatId`. The transport, server-side +`AgentChat`, and `chat.createTriggerAction` no longer create sessions at all. +Browsers cannot mint sessions either (`POST /api/v1/sessions` is now +secret-key-only). One owner, one path. + +**Move 2 — `chatId` is the only address.** The transport, server-side +`AgentChat`, JWT scopes, and S2 stream paths all use `chatId` directly. The +Session's friendlyId is informational. To make this safe, the three stream +routes (`.in/.out` PUT, GET, POST append, plus the run-engine `wait` +endpoint) became "row-optional" and derive a *canonical addressing key* +(`row.externalId ?? row.friendlyId`, fallback to the URL param when the row +hasn't been upserted yet). Same canonical key is used to build the S2 stream +path, the waitpoint cache key, and the JWT resource set — so any caller +addressing by either form converges on the same physical stream. + +Together these remove an entire class of "did the row land yet?" races. The +transport can subscribe to `/sessions/{chatId}/out` before the agent boots, +the agent's `void sessions.create({externalId: chatId})` lands a moment +later, and any earlier reads/writes are already on the right S2 key. + +--- + +## Read in this order + +### 1. `apps/webapp/app/services/realtime/sessions.server.ts` (+34 lines) + +The new primitive. Two helpers: + +- `isSessionFriendlyIdForm(value)` — `value.startsWith("session_")`. Used to + decide whether a missing row is a hard 404 (opaque friendlyId) or a soft + "row will land later" (externalId form). +- `canonicalSessionAddressingKey(row, paramSession)` — `row.externalId ?? + row.friendlyId` if the row exists, else `paramSession`. **This is the load- + bearing function.** Read its docstring. + +**Question to ask:** can two callers addressing the "same" session ever get +different canonical keys? Only if the row exists for one and not the other, +*and* the URL forms differ — but in that case the row-less caller used the +externalId form (friendlyId-form would have 404'd earlier), and the row-ful +caller computes `row.externalId ?? row.friendlyId`. If the row's externalId +matches the URL, they converge. If it doesn't, there's no row to find by +that string anyway. The interesting edge is "row exists with no externalId", +addressed via friendlyId — both sides read `row.friendlyId`. ✓ + +### 2. `apps/webapp/app/routes/realtime.v1.sessions.$session.$io.ts` (+47/-12) + +PUT initialize + GET subscribe (SSE). Both use the helper. The interesting +part is the loader's `findResource` + `authorization.resource`: + +```ts +findResource: async (params, auth) => { + const row = await resolveSessionByIdOrExternalId(...); + if (!row && isSessionFriendlyIdForm(params.session)) return undefined; // 404 + return { row, addressingKey: canonicalSessionAddressingKey(row, params.session) }; +}, +authorization: { + resource: ({ row, addressingKey }) => { + const ids = new Set([addressingKey]); + if (row) { + ids.add(row.friendlyId); + if (row.externalId) ids.add(row.externalId); + } + return { sessions: [...ids] }; + }, + superScopes: ["read:sessions", "read:all", "admin"], +}, +``` + +**Why three IDs in the resource set?** `checkAuthorization` is "any-match" +across the resource values. We want a JWT scoped to *either* form to +authorize *either* URL form. Smoke test verified the 4-cell matrix passes. + +**The PUT path** (action handler) is simpler — it just resolves the row, +builds an addressing key, and hands it to `initializeSessionStream`. Worth +noting the `closedAt` check is now `maybeSession?.closedAt` — no row means +no closedAt to enforce. + +### 3. `apps/webapp/app/routes/realtime.v1.sessions.$session.$io.append.ts` (+22/-13) + +POST append (browser writes a record to `.in` or server writes to `.out`). +Same row-optional pattern. Both the S2 append and the waitpoint drain use +`addressingKey`. + +**Question to ask:** what fires the waitpoint? An agent's +`session.in.wait()` registers a waitpoint keyed on `(addressingKey, io)` via +the wait endpoint (file 4). The append handler drains by the *same* key — +even if the agent registered with externalId form and the transport +appended via friendlyId form, both compute the same canonical key, so they +converge. ✓ + +### 4. `apps/webapp/app/routes/api.v1.runs.$runFriendlyId.session-streams.wait.ts` (+18/-13) + +The agent's `.in.wait()` endpoint. Run-engine creates the waitpoint, then +registers it in Redis under `(addressingKey, io)`. The race-check that runs +right after creation reads from S2 by the same key. Three call sites — +`addSessionStreamWaitpoint`, `readSessionStreamRecords`, +`removeSessionStreamWaitpoint` — all consistent. + +### 5. `apps/webapp/app/routes/api.v1.sessions.ts` (+4/-2) + +**Security tightening.** Removed `allowJWT: true` and `corsStrategy: "all"` +from the `POST /api/v1/sessions` action — secret-key only now. + +**Question to ask:** was the JWT path actually used? Until this branch, the +transport called it via `ensureSession` (now deleted). After this branch, +nobody reaches it from the browser. `chat.createTriggerAction` (server +secret key) is the only browser-adjacent path. + +### 6. `packages/trigger-sdk/src/v3/ai.ts` (+62/-39) + +Two near-identical edits — one in `chatAgent`, one in `chatCustomAgent`. +Both bind on `payload.chatId` and fire-and-forget the upsert: + +```ts +locals.set(chatSessionHandleKey, sessions.open(payload.chatId)); +void sessions + .create({ type: "chat.agent", externalId: payload.chatId }) + .catch(() => { /* best effort */ }); +``` + +**Question to ask:** why `void`-and-`catch`? Awaiting the upsert would gate +the agent's bind on a network round-trip that doesn't unblock anything +user-visible — `.in/.out` routes are row-agnostic and the waitpoint cache +is keyed on the addressing string, not the row id. If the upsert genuinely +fails, the next bind retries the same idempotent call (`sessions.create` +upserts on `externalId`, so concurrent triggers on one chatId converge to +one row). The row matters for downstream metadata + listing, not for live +addressing. + +The PAT scope minting in `chatAgent` (two call sites — preload and +sendMessage) now uses `payload.chatId` for the `sessions:` resource. That +matches what the transport/AgentChat use as the JWT resource and what the +JWT's resource set in the loader includes. Cross-form addressing works +either way (smoke-tested), but using `chatId` keeps the chain tight. + +`createChatTriggerAction` is the most visibly trimmed: no pre-create, no +threading `sessionId` into payload, scope mint uses `chatId`. Return type +no longer carries `sessionId` — note `TriggerChatTaskResult.sessionId` was +already declared optional, so this isn't a public-API break. + +**Stale docstring to flag:** `chat.ts:59` and `chat.ts:112` still describe +PAT scopes as `read:sessions:{sessionId}` and +`write:sessions:{sessionId}`. Functionally either ID works (row lookup +canonicalises), but the doc text is now out of date — it should say +`{chatId}`. Worth a tidy-up before merge but not blocking. + +### 7. `packages/trigger-sdk/src/v3/chat.ts` (+63/-117) + +**The biggest mechanical edit.** Net -54 lines from deleting `ensureSession` +and untangling its callers. + +What disappeared: +- `private async ensureSession(chatId)` — gone +- The "lazy upsert from the browser if no triggerTask callback" branch in + `sendMessages` and `preload` — gone +- The "throw if neither path surfaced a sessionId" guard — gone +- All `state.sessionId` URL params replaced with `chatId` +- `subscribeToSessionStream`'s `chatId?` (optional) is now `chatId` (required) + +What stayed: +- `state.sessionId` in `ChatSessionState` — optional, informational +- The `restore from external storage` branch in the constructor still + hydrates `sessionId` if persisted, just doesn't *require* it +- `notifySessionChange` still surfaces `sessionId` if known + +**Question to ask:** does the transport ever still need the friendlyId? The +only place is the `onSessionChange` callback's payload (so consumers +persisting state can save it for later display). The transport itself never +puts it in a URL or a waitpoint key. + +The `sendMessages` path is worth re-reading: when state.runId is set, it +appends to `.in/append` and subscribes to `.out`. If the append fails with +a non-auth error, it falls through to triggering a new run (legacy "run is +dead" detection — unchanged from pre-Sessions, doesn't depend on +addressing). + +### 8. `packages/trigger-sdk/src/v3/chat-client.ts` (+34/-33) + +Server-side `AgentChat`. Mirrors the transport changes — every URL uses +`this.chatId`. `triggerNewRun` no longer pre-creates a session. `ChatSession` +and internal `SessionState` types now have optional `sessionId`. + +The shape of the diff is identical to the transport: delete the upsert, +swap addressing identifiers, optionalise the friendlyId. If you've read +`chat.ts` carefully, this one is mostly mechanical confirmation that both +client surfaces (browser transport + server-side AgentChat) speak the same +addressing protocol. + +### 9. Test infrastructure — `sessions.ts` (+18) + `mock-chat-agent.ts` (+25) + +`__setSessionCreateImplForTests` mirrors the existing +`__setSessionOpenImplForTests`. `mockChatAgent` installs a no-op create stub +returning a synthetic `CreatedSessionResponseBody` so the agent's bind-time +`void sessions.create(...)` doesn't try to hit a real API. Cleanup runs in +the same `.finally` as the open override. + +**Question to ask:** is the synthetic response shape correct? It mirrors +`CreatedSessionResponseBody` — `id`, `externalId`, `type`, `tags`, +`metadata`, `closedAt`, `closedReason`, `expiresAt`, `createdAt`, +`updatedAt`, `isCached`. Tests don't currently assert on this object, so +the bar is "doesn't crash + matches the type". Met. + +### 10. `packages/trigger-sdk/src/v3/chat.test.ts` (+13/-12) + +Three classes of test edits, all consequences: + +- Stream URL assertion: `chat-1` (the chatId) instead of + `session_streamurl` (the friendlyId) +- `renewRunAccessToken` callback: `sessionId: undefined` (was + `DEFAULT_SESSION_ID` because the mocked trigger doesn't surface it) +- Token resolve count: `1` (was `2` — second resolve was for `ensureSession`) +- One `onSessionChange` matchObject loses `sessionId` + +### 11. `apps/webapp/app/routes/_app.../playground/.../route.tsx` (1 line) + +`sessionId: string` → `sessionId?: string` in the playground sidebar prop +to track the transport type change. + +--- + +## Edge cases I checked, so you don't have to + +- **Cross-form JWT auth (curl matrix).** JWT scoped to externalId can call + externalId URL ✓ and friendlyId URL ✓. JWT scoped to friendlyId can call + externalId URL ✓ and friendlyId URL ✓. Smoke-tested. +- **Row materialises after subscribe.** Transport opens + `GET /sessions/{chatId}/out` before agent's bind upsert lands → 200 OK, + `addressingKey = chatId` (paramSession fallback). Once the row lands + with `externalId = chatId`, addressingKey resolves to the same value via + `row.externalId`. Same S2 key throughout. +- **Concurrent triggers on one chatId.** Two browser tabs trigger two runs + → two binds → two `sessions.create({externalId: chatId})` calls. Upsert + semantics: both return the same row. +- **Closed session enforcement.** Still enforced when a row exists. + `maybeSession?.closedAt` is null-safe; no row = no close-state to honour. +- **Agent run cancellation.** Frontend doesn't auto-detect — unchanged from + pre-Sessions; messages sit in S2 until the next trigger (the existing + run-PAT auth-error path is the only reaper). Out of scope for this branch. +- **Idle timeout in dev.** Runs stay `EXECUTING_WITH_WAITPOINTS` past the + configured idle because dev runs don't snapshot/restore; the in-process + idle clock advances locally without touching the row. Expected, not a + regression. + +## Things explicitly **not** in this branch + +- Run-state subscription on the transport side (the "run died, re-trigger + silently" UX gap) +- Session auto-close on agent exit (still client-driven by design) +- Any change to `Session` schema, `sessions.create` semantics, or + `chatAccessTokenTTL` +- Docstring updates for `read:sessions:{sessionId}` / `write:sessions:{sessionId}` + in `chat.ts:59` and `chat.ts:112` (functional but textually stale — + follow-up nit) + +--- + +## What I'd be ready to answer cold + +- Why fire-and-forget upsert (vs. `await`) in the agent's bind step +- Why the route's authorization resource set has three IDs (cross-form JWT + auth) +- Why `POST /api/v1/sessions` lost `allowJWT` (security tightening — no + caller needs it after the transport's `ensureSession` is gone) +- What converges two callers using different URL forms onto the same S2 + stream (`canonicalSessionAddressingKey`, identical computation on both + sides for any given row) +- What makes `sessions.create` race-safe under concurrent triggers + (`externalId` upsert) +- Why `state.sessionId` stayed on `ChatSessionState` at all (pure + informational, surfaced via `onSessionChange` for consumer persistence; + zero addressing role) +- Why the chat-client (server-side AgentChat) and chat (transport) edits + look near-identical (they implement the same client protocol against the + same row-agnostic routes) diff --git a/.claude/rules/package-installation.md b/.claude/rules/package-installation.md new file mode 100644 index 00000000000..310074823c5 --- /dev/null +++ b/.claude/rules/package-installation.md @@ -0,0 +1,22 @@ +--- +paths: + - "**/package.json" +--- + +# Installing Packages + +When adding a new dependency to any package.json in the monorepo: + +1. **Look up the latest version** on npm before adding: + ```bash + pnpm view version + ``` + If unsure which version to use (e.g. major version compatibility), confirm with the user. + +2. **Edit the package.json directly** — do NOT use `pnpm add` as it can cause issues in the monorepo. Add the dependency with the correct version range (typically `^x.y.z`). + +3. **Run `pnpm i` from the repo root** after editing to install and update the lockfile: + ```bash + pnpm i + ``` + Always run from the repo root, not from the package directory. diff --git a/packages/cli-v3/src/build/buildWorker.ts b/packages/cli-v3/src/build/buildWorker.ts index b23b802f502..c3e1641ade1 100644 --- a/packages/cli-v3/src/build/buildWorker.ts +++ b/packages/cli-v3/src/build/buildWorker.ts @@ -1,6 +1,7 @@ import { ResolvedConfig } from "@trigger.dev/core/v3/build"; import { BuildManifest, BuildTarget } from "@trigger.dev/core/v3/schemas"; import { BundleResult, bundleWorker, createBuildManifestFromBundle } from "./bundle.js"; +import { bundleSkills } from "./bundleSkills.js"; import { createBuildContext, notifyExtensionOnBuildComplete, @@ -8,6 +9,8 @@ import { resolvePluginsForContext, } from "./extensions.js"; import { createExternalsBuildExtension } from "./externals.js"; +import { tmpdir } from "node:os"; +import { mkdtemp, rm } from "node:fs/promises"; import { join, relative, sep } from "node:path"; import { generateContainerfile } from "../deploy/buildImage.js"; import { writeFile } from "node:fs/promises"; @@ -97,6 +100,31 @@ export async function buildWorker(options: BuildWorkerOptions) { envVars: options.envVars, }); + // Built-in skill bundler — discovers `ai.defineSkill` registrations + // via a local indexer run and copies each skill folder into + // `{destination}/.trigger/skills/{id}/` before Docker COPY picks up + // the bundle. First-class, not a build extension. + const skillsTmpDir = await mkdtemp(join(tmpdir(), "trigger-skills-")); + const skillsBuildManifestPath = join(skillsTmpDir, "build.json"); + try { + await writeFile(skillsBuildManifestPath, JSON.stringify(buildManifest)); + const skillsResult = await bundleSkills({ + buildManifest, + buildManifestPath: skillsBuildManifestPath, + workingDir: resolvedConfig.workingDir, + env: { + ...process.env, + ...(options.envVars ?? {}), + }, + logger: buildContext.logger, + }); + buildManifest = skillsResult.buildManifest; + } catch (err) { + logger.warn("Skill bundling failed; continuing without skills", err); + } finally { + await rm(skillsTmpDir, { recursive: true, force: true }).catch(() => {}); + } + buildManifest = await notifyExtensionOnBuildComplete(buildContext, buildManifest); if (options.target !== "dev") { diff --git a/packages/cli-v3/src/build/bundleSkills.ts b/packages/cli-v3/src/build/bundleSkills.ts new file mode 100644 index 00000000000..65ad9834abe --- /dev/null +++ b/packages/cli-v3/src/build/bundleSkills.ts @@ -0,0 +1,135 @@ +import { createHash } from "node:crypto"; +import { readFile } from "node:fs/promises"; +import { dirname, isAbsolute, join, resolve as resolvePath } from "node:path"; +import type { BuildManifest, SkillManifest } from "@trigger.dev/core/v3/schemas"; +import { copyDirectoryRecursive } from "@trigger.dev/build/internal"; +import { indexWorkerManifest } from "../indexing/indexWorkerManifest.js"; +import { execOptionsForRuntime, type BuildLogger } from "@trigger.dev/core/v3/build"; + +export type BundleSkillsOptions = { + buildManifest: BuildManifest; + buildManifestPath: string; + workingDir: string; + env: Record; + logger: BuildLogger; +}; + +export type BundleSkillsResult = { + /** The input manifest, annotated with `skills` on return. */ + buildManifest: BuildManifest; + /** Discovered skills, in deterministic order. */ + skills: SkillManifest[]; +}; + +/** + * Built-in skill bundler — not an extension. Runs the indexer locally + * against the bundled worker output to discover `ai.defineSkill(...)` + * registrations, validates each skill's `SKILL.md`, and copies the + * folder into `{outputPath}/.trigger/skills/{id}/` so the deploy image + * picks it up via the existing Dockerfile `COPY`. + * + * No `trigger.config.ts` changes required — discovery is side-effect + * based, same mechanism as task/prompt registration. + */ +export async function bundleSkills( + options: BundleSkillsOptions +): Promise { + const { buildManifest, buildManifestPath, workingDir, env, logger } = options; + + let skills: SkillManifest[]; + try { + const workerManifest = await indexWorkerManifest({ + runtime: buildManifest.runtime, + indexWorkerPath: buildManifest.indexWorkerEntryPoint, + buildManifestPath, + nodeOptions: execOptionsForRuntime(buildManifest.runtime, buildManifest), + env, + cwd: workingDir, + otelHookInclude: buildManifest.otelImportHook?.include, + otelHookExclude: buildManifest.otelImportHook?.exclude, + handleStdout(data) { + logger.debug(`[bundleSkills] ${data}`); + }, + handleStderr(data) { + if (!data.includes("Debugger attached")) { + logger.debug(`[bundleSkills:stderr] ${data}`); + } + }, + }); + skills = workerManifest.skills ?? []; + } catch (err) { + // Skill discovery via the indexer is best-effort — if the user's + // bundle doesn't load cleanly here the downstream full indexer will + // surface the real error. Warn so the user sees what went wrong. + logger.warn( + `[bundleSkills] skill discovery failed, skipping skill bundling: ${(err as Error).message}` + ); + return { buildManifest, skills: [] }; + } + + if (skills.length === 0) { + return { buildManifest, skills: [] }; + } + + // Destination layout differs between dev and deploy: + // - Dev: the worker runs with cwd = workingDir, so skills must live at + // {workingDir}/.trigger/skills/{id}/ for skill.local() to find them. + // - Deploy: the Dockerfile COPY picks up everything under outputPath into + // /app, so we target {outputPath}/.trigger/skills/{id}/ and the + // container's cwd (/app) resolves correctly. + const destinationRoot = + buildManifest.target === "dev" + ? join(workingDir, ".trigger", "skills") + : join(buildManifest.outputPath, ".trigger", "skills"); + + for (const skill of skills) { + // Resolve the skill's source folder relative to the file that called + // `skills.define(...)`. Absolute paths are honored as-is. + const callerDir = skill.filePath + ? dirname(resolvePath(workingDir, skill.filePath)) + : workingDir; + const sourcePath = isAbsolute(skill.sourcePath) + ? skill.sourcePath + : resolvePath(callerDir, skill.sourcePath); + const skillMdPath = join(sourcePath, "SKILL.md"); + + let skillMd: string; + try { + skillMd = await readFile(skillMdPath, "utf8"); + } catch { + throw new Error( + `Skill "${skill.id}": SKILL.md not found at ${skillMdPath}. ` + + `Registered via ai.defineSkill({ id: "${skill.id}", path: "${skill.sourcePath}" }) ` + + `at ${skill.filePath}.` + ); + } + + if (!/^---\r?\n[\s\S]*?\r?\n---/.test(skillMd)) { + throw new Error( + `Skill "${skill.id}": SKILL.md at ${skillMdPath} is missing a frontmatter block.` + ); + } + if (!/\bname:\s*\S/.test(skillMd) || !/\bdescription:\s*\S/.test(skillMd)) { + throw new Error( + `Skill "${skill.id}": SKILL.md at ${skillMdPath} frontmatter must include both \`name\` and \`description\`.` + ); + } + + const skillDest = join(destinationRoot, skill.id); + logger.debug(`[bundleSkills] Copying ${sourcePath} → ${skillDest}`); + await copyDirectoryRecursive(sourcePath, skillDest); + } + + // Sort by id for deterministic manifest output + skills = [...skills].sort((a, b) => a.id.localeCompare(b.id)); + + // Content hash is derived from each SKILL.md's content for cache invalidation + // downstream (dashboard persistence in Phase 2). Not used in Phase 1. + void createHash; + void dirname; + + return { + buildManifest: { ...buildManifest, skills }, + skills, + }; +} diff --git a/packages/cli-v3/src/dev/devSession.ts b/packages/cli-v3/src/dev/devSession.ts index 482ebf6fc17..2d6645cd50c 100644 --- a/packages/cli-v3/src/dev/devSession.ts +++ b/packages/cli-v3/src/dev/devSession.ts @@ -9,6 +9,7 @@ import { logBuildFailure, logBuildWarnings, } from "../build/bundle.js"; +import { bundleSkills } from "../build/bundleSkills.js"; import { createBuildContext, notifyExtensionOnBuildComplete, @@ -118,6 +119,26 @@ export async function startDevSession({ bundle.metafile ); + // Built-in skill bundling — copies registered skill folders into + // `.trigger/skills/{id}/` so `skill.local()` works at dev runtime. + try { + const buildManifestPath = join( + workerDir?.path ?? destination.path, + "build.json" + ); + await writeJSONFile(buildManifestPath, buildManifest); + const skillsResult = await bundleSkills({ + buildManifest, + buildManifestPath, + workingDir: rawConfig.workingDir, + env: process.env, + logger: buildContext.logger, + }); + buildManifest = skillsResult.buildManifest; + } catch (err) { + logger.warn("Skill bundling failed during dev rebuild", err); + } + buildManifest = await notifyExtensionOnBuildComplete(buildContext, buildManifest); try { diff --git a/packages/cli-v3/src/dev/taskRunProcessPool.ts b/packages/cli-v3/src/dev/taskRunProcessPool.ts index 810be7acb43..414626e64cc 100644 --- a/packages/cli-v3/src/dev/taskRunProcessPool.ts +++ b/packages/cli-v3/src/dev/taskRunProcessPool.ts @@ -127,7 +127,11 @@ export class TaskRunProcessPool { return { taskRunProcess: newProcess, isReused: false }; } - async returnProcess(process: TaskRunProcess, version: string): Promise { + async returnProcess( + process: TaskRunProcess, + version: string, + options?: { forceKill?: boolean } + ): Promise { // Remove from busy processes for this version const busyProcesses = this.busyProcessesByVersion.get(version); if (busyProcesses) { @@ -141,6 +145,19 @@ export class TaskRunProcessPool { ); } + // `forceKill` skips the reuse heuristic and tears the process down. Used + // on outcomes that leave the process in a state we can't safely reuse + // (OOM in particular — production would get a fresh container, so local + // dev should match that). + if (options?.forceKill) { + logger.debug("[TaskRunProcessPool] Force-killing process", { + version, + pid: process.pid, + }); + await this.killProcess(process); + return; + } + if (this.shouldReuseProcess(process, version)) { const availableCount = this.availableProcessesByVersion.get(version)?.length || 0; const busyCount = this.busyProcessesByVersion.get(version)?.size || 0; diff --git a/packages/cli-v3/src/entryPoints/dev-index-worker.ts b/packages/cli-v3/src/entryPoints/dev-index-worker.ts index 53b95ad040a..3e48c70e42e 100644 --- a/packages/cli-v3/src/entryPoints/dev-index-worker.ts +++ b/packages/cli-v3/src/entryPoints/dev-index-worker.ts @@ -184,6 +184,7 @@ await sendMessageInCatalog( manifest: { tasks, prompts: convertPromptSchemasToJsonSchemas(resourceCatalog.listPromptManifests()), + skills: resourceCatalog.listSkillManifests(), queues: resourceCatalog.listQueueManifests(), configPath: buildManifest.configPath, runtime: buildManifest.runtime, diff --git a/packages/cli-v3/src/entryPoints/dev-run-controller.ts b/packages/cli-v3/src/entryPoints/dev-run-controller.ts index fe486677452..ffa4228cbcd 100644 --- a/packages/cli-v3/src/entryPoints/dev-run-controller.ts +++ b/packages/cli-v3/src/entryPoints/dev-run-controller.ts @@ -2,6 +2,8 @@ import { CompleteRunAttemptResult, DequeuedMessage, IntervalService, + isManualOutOfMemoryError, + isOOMRunError, LogLevel, RunExecutionData, SuspendedProcessError, @@ -52,6 +54,12 @@ export class DevRunController { private readonly cwd?: string; private isCompletingRun = false; private isShuttingDown = false; + // Set when the current attempt's outcome means the worker process can't + // safely be reused (OOM in particular). Production gives every retry a + // fresh container; local dev's process pool needs the same on these + // outcomes or in-process state (e.g. session.in cursors) leaks across + // attempts and the OOM retry skips the message that triggered it. + private discardProcessOnReturn = false; private state: | { @@ -539,6 +547,13 @@ export class DevRunController { error: TaskRunProcess.parseExecuteError(error), } satisfies TaskRunFailedExecutionResult; + // Same OOM check as the success path: if the thrown error parses to + // an OOM, force-kill the process when it's eventually returned (via + // runFinished / stop) instead of recycling it. + if (isOOMRunError(completion.error) || isManualOutOfMemoryError(completion.error)) { + this.discardProcessOnReturn = true; + } + const completionResult = await this.httpClient.dev.completeRunAttempt( run.friendlyId, this.snapshotFriendlyId ?? snapshot.friendlyId, @@ -591,6 +606,9 @@ export class DevRunController { }); this.isCompletingRun = false; + // Reset between attempts so a stale OOM flag from a prior attempt + // doesn't force-kill a healthy reused process on RETRY_IMMEDIATELY. + this.discardProcessOnReturn = false; // Get process from pool instead of creating new one const { taskRunProcess, isReused } = await this.opts.taskRunProcessPool.getProcess( @@ -664,10 +682,22 @@ export class DevRunController { this.isCompletingRun = true; + // Detect OOM in the failure result so we can force-kill the worker + // instead of returning it to the pool. Mirrors the production behavior + // where OOM retry happens on a brand-new container. + if ( + !completion.ok && + (isOOMRunError(completion.error) || isManualOutOfMemoryError(completion.error)) + ) { + this.discardProcessOnReturn = true; + } + // Return process to pool instead of killing it try { const version = this.opts.worker.serverWorker?.version || "unknown"; - await this.opts.taskRunProcessPool.returnProcess(this.taskRunProcess, version); + await this.opts.taskRunProcessPool.returnProcess(this.taskRunProcess, version, { + forceKill: this.discardProcessOnReturn, + }); this.taskRunProcess = undefined; } catch (error) { logger.debug("Failed to return task run process to pool, submitting completion anyway", { @@ -820,7 +850,9 @@ export class DevRunController { if (this.taskRunProcess) { try { const version = this.opts.worker.serverWorker?.version || "unknown"; - await this.opts.taskRunProcessPool.returnProcess(this.taskRunProcess, version); + await this.opts.taskRunProcessPool.returnProcess(this.taskRunProcess, version, { + forceKill: this.discardProcessOnReturn, + }); this.taskRunProcess = undefined; } catch (error) { logger.debug("Failed to return task run process to pool during runFinished", { error }); @@ -854,7 +886,9 @@ export class DevRunController { if (this.taskRunProcess && !this.taskRunProcess.isBeingKilled) { try { const version = this.opts.worker.serverWorker?.version || "unknown"; - await this.opts.taskRunProcessPool.returnProcess(this.taskRunProcess, version); + await this.opts.taskRunProcessPool.returnProcess(this.taskRunProcess, version, { + forceKill: this.discardProcessOnReturn, + }); this.taskRunProcess = undefined; } catch (error) { logger.debug("Failed to return task run process to pool during stop", { error }); diff --git a/packages/cli-v3/src/entryPoints/dev-run-worker.ts b/packages/cli-v3/src/entryPoints/dev-run-worker.ts index 6ffc6afd29b..067076a7b99 100644 --- a/packages/cli-v3/src/entryPoints/dev-run-worker.ts +++ b/packages/cli-v3/src/entryPoints/dev-run-worker.ts @@ -34,6 +34,7 @@ import { heartbeats, realtimeStreams, inputStreams, + sessionStreams, } from "@trigger.dev/core/v3"; import { TriggerTracer } from "@trigger.dev/core/v3/tracer"; import { @@ -61,6 +62,7 @@ import { StandardHeartbeatsManager, StandardRealtimeStreamsManager, StandardInputStreamManager, + StandardSessionStreamManager, } from "@trigger.dev/core/v3/workers"; import { ZodIpcConnection } from "@trigger.dev/core/v3/zodIpc"; import { readFile } from "node:fs/promises"; @@ -186,6 +188,14 @@ const standardInputStreamManager = new StandardInputStreamManager( ); inputStreams.setGlobalManager(standardInputStreamManager); +const standardSessionStreamManager = new StandardSessionStreamManager( + apiClientManager.clientOrThrow(), + getEnvVar("TRIGGER_STREAM_URL", getEnvVar("TRIGGER_API_URL")) ?? "https://api.trigger.dev", + (getEnvVar("TRIGGER_STREAMS_DEBUG") === "1" || getEnvVar("TRIGGER_STREAMS_DEBUG") === "true") ?? + false +); +sessionStreams.setGlobalManager(standardSessionStreamManager); + const waitUntilTimeoutInMs = getNumberEnvVar("TRIGGER_WAIT_UNTIL_TIMEOUT_MS", 60_000); const waitUntilManager = new StandardWaitUntilManager(waitUntilTimeoutInMs); waitUntil.setGlobalManager(waitUntilManager); @@ -360,6 +370,7 @@ function resetExecutionEnvironment() { runMetadataManager.reset(); standardRealtimeStreamsManager.reset(); standardInputStreamManager.reset(); + standardSessionStreamManager.reset(); waitUntilManager.reset(); _sharedWorkerRuntime?.reset(); durableClock.reset(); diff --git a/packages/cli-v3/src/entryPoints/managed-index-controller.ts b/packages/cli-v3/src/entryPoints/managed-index-controller.ts index 181d3d1093c..21aa3d829d2 100644 --- a/packages/cli-v3/src/entryPoints/managed-index-controller.ts +++ b/packages/cli-v3/src/entryPoints/managed-index-controller.ts @@ -104,6 +104,7 @@ async function indexDeployment({ packageVersion: buildManifest.packageVersion, cliPackageVersion: buildManifest.cliPackageVersion, tasks: workerManifest.tasks, + prompts: workerManifest.prompts, queues: workerManifest.queues, sourceFiles, runtime: workerManifest.runtime, diff --git a/packages/cli-v3/src/entryPoints/managed-index-worker.ts b/packages/cli-v3/src/entryPoints/managed-index-worker.ts index 644673537e3..1bf73b22621 100644 --- a/packages/cli-v3/src/entryPoints/managed-index-worker.ts +++ b/packages/cli-v3/src/entryPoints/managed-index-worker.ts @@ -180,6 +180,7 @@ await sendMessageInCatalog( manifest: { tasks, prompts: convertPromptSchemasToJsonSchemas(resourceCatalog.listPromptManifests()), + skills: resourceCatalog.listSkillManifests(), queues: resourceCatalog.listQueueManifests(), configPath: buildManifest.configPath, runtime: buildManifest.runtime, diff --git a/packages/cli-v3/src/entryPoints/managed-run-worker.ts b/packages/cli-v3/src/entryPoints/managed-run-worker.ts index 1d5d2a5f0d6..3fc27dd8ab9 100644 --- a/packages/cli-v3/src/entryPoints/managed-run-worker.ts +++ b/packages/cli-v3/src/entryPoints/managed-run-worker.ts @@ -33,6 +33,7 @@ import { heartbeats, realtimeStreams, inputStreams, + sessionStreams, } from "@trigger.dev/core/v3"; import { TriggerTracer } from "@trigger.dev/core/v3/tracer"; import { @@ -61,6 +62,7 @@ import { StandardHeartbeatsManager, StandardRealtimeStreamsManager, StandardInputStreamManager, + StandardSessionStreamManager, } from "@trigger.dev/core/v3/workers"; import { ZodIpcConnection } from "@trigger.dev/core/v3/zodIpc"; import { readFile } from "node:fs/promises"; @@ -159,6 +161,14 @@ const standardInputStreamManager = new StandardInputStreamManager( ); inputStreams.setGlobalManager(standardInputStreamManager); +const standardSessionStreamManager = new StandardSessionStreamManager( + apiClientManager.clientOrThrow(), + getEnvVar("TRIGGER_STREAM_URL", getEnvVar("TRIGGER_API_URL")) ?? "https://api.trigger.dev", + (getEnvVar("TRIGGER_STREAMS_DEBUG") === "1" || getEnvVar("TRIGGER_STREAMS_DEBUG") === "true") ?? + false +); +sessionStreams.setGlobalManager(standardSessionStreamManager); + const waitUntilTimeoutInMs = getNumberEnvVar("TRIGGER_WAIT_UNTIL_TIMEOUT_MS", 60_000); const waitUntilManager = new StandardWaitUntilManager(waitUntilTimeoutInMs); waitUntil.setGlobalManager(waitUntilManager); @@ -333,6 +343,7 @@ function resetExecutionEnvironment() { waitUntilManager.reset(); standardRealtimeStreamsManager.reset(); standardInputStreamManager.reset(); + standardSessionStreamManager.reset(); _sharedWorkerRuntime?.reset(); durableClock.reset(); taskContext.disable(); diff --git a/packages/cli-v3/src/mcp/config.ts b/packages/cli-v3/src/mcp/config.ts index d878d881e7d..0cef5cfaab4 100644 --- a/packages/cli-v3/src/mcp/config.ts +++ b/packages/cli-v3/src/mcp/config.ts @@ -213,4 +213,28 @@ export const toolsMetadata = { description: "Reactivate a previous dashboard-sourced version as the active override. Use get_prompt_versions to find dashboard versions that can be reactivated.", }, + list_agents: { + name: "list_agents", + title: "List Agents", + description: + "List all chat agents in the current worker. Agents are tasks created with chat.agent() or chat.customAgent(). Use start_agent_chat with an agent's slug to start a conversation.", + }, + start_agent_chat: { + name: "start_agent_chat", + title: "Start Agent Chat", + description: + "Start a conversation with a chat agent. Returns a chatId you can use with send_agent_message. Optionally preloads the agent so it initializes before the first message.", + }, + send_agent_message: { + name: "send_agent_message", + title: "Send Agent Message", + description: + "Send a message to an active agent chat and get the full response text back. Use the chatId from start_agent_chat. The agent remembers full context from previous messages in the same chat.", + }, + close_agent_chat: { + name: "close_agent_chat", + title: "Close Agent Chat", + description: + "Close an agent chat conversation. The agent exits its loop gracefully. Without this, the agent will close on its own when its idle timeout expires.", + }, }; diff --git a/packages/cli-v3/src/mcp/tools.ts b/packages/cli-v3/src/mcp/tools.ts index fd013b77644..f438989258d 100644 --- a/packages/cli-v3/src/mcp/tools.ts +++ b/packages/cli-v3/src/mcp/tools.ts @@ -29,6 +29,12 @@ import { removePromptOverrideTool, reactivatePromptOverrideTool, } from "./tools/prompts.js"; +import { listAgentsTool } from "./tools/agents.js"; +import { + startAgentChatTool, + sendAgentMessageTool, + closeAgentChatTool, +} from "./tools/agentChat.js"; import { respondWithError } from "./utils.js"; /** Tool names that perform write/mutating operations. */ @@ -43,6 +49,9 @@ const WRITE_TOOLS = new Set([ updatePromptOverrideTool.name, removePromptOverrideTool.name, reactivatePromptOverrideTool.name, + startAgentChatTool.name, + sendAgentMessageTool.name, + closeAgentChatTool.name, ]); export function registerTools(context: McpContext) { @@ -80,6 +89,10 @@ export function registerTools(context: McpContext) { updatePromptOverrideTool, removePromptOverrideTool, reactivatePromptOverrideTool, + listAgentsTool, + startAgentChatTool, + sendAgentMessageTool, + closeAgentChatTool, ]; for (const tool of tools) { diff --git a/packages/cli-v3/src/mcp/tools/agentChat.ts b/packages/cli-v3/src/mcp/tools/agentChat.ts new file mode 100644 index 00000000000..27965c06b39 --- /dev/null +++ b/packages/cli-v3/src/mcp/tools/agentChat.ts @@ -0,0 +1,522 @@ +import { z } from "zod"; +import { ApiClient, SSEStreamSubscription } from "@trigger.dev/core/v3"; +import { toolsMetadata } from "../config.js"; +import { CommonProjectsInput } from "../schemas.js"; +import { respondWithError, toolHandler } from "../utils.js"; + +// ─── In-memory chat sessions ────────────────────────────────────── + +type ChatMessage = { + id: string; + role: string; + parts: Array<{ type: string; [key: string]: unknown }>; +}; + +type ChatSession = { + /** `session_*` friendlyId — durable identity for the conversation. */ + sessionId: string; + /** Last-known live run id. Cleared when a run ends. */ + runId: string; + chatId: string; + agentId: string; + lastEventId?: string; + apiClient: ApiClient; + clientData?: Record; + /** Accumulated conversation messages for continuation payloads. */ + messages: ChatMessage[]; +}; + +const activeSessions = new Map(); + +// ─── ChatInputChunk serialization (mirrors TriggerChatTransport) ── +// +// Slim-wire: one delta `message` per chunk, not a full `messages[]` array. +// The agent's run loop destructures `payload.message` (singular). Sending +// a `messages: [...]` array makes `message` undefined and turn 1 calls +// `streamText({ messages: [] })`, which throws +// `AI_InvalidPromptError: messages must not be empty`. +type ChatInputChunk = + | { + kind: "message"; + payload: { + message?: ChatMessage; + chatId: string; + trigger: "submit-message" | "close" | "preload" | "regenerate-message" | "action"; + metadata?: unknown; + sessionId?: string; + continuation?: boolean; + previousRunId?: string; + }; + } + | { kind: "stop"; message?: string }; + +function serializeInputChunk(chunk: ChatInputChunk): string { + return JSON.stringify(chunk); +} + +// ─── Start Agent Chat ───────────────────────────────────────────── + +const StartAgentChatInput = CommonProjectsInput.extend({ + agentId: z + .string() + .describe( + "The agent task ID to chat with. Use get_current_worker to see available agents." + ), + chatId: z + .string() + .describe("A unique conversation ID. Reuse to resume a conversation.") + .optional(), + clientData: z + .record(z.unknown()) + .describe("Client data to include with every message (e.g. userId, model).") + .optional(), + preload: z + .boolean() + .describe("Whether to preload the agent before the first message.") + .default(true), +}); + +export const startAgentChatTool = { + name: toolsMetadata.start_agent_chat.name, + title: toolsMetadata.start_agent_chat.title, + description: toolsMetadata.start_agent_chat.description, + inputSchema: StartAgentChatInput.shape, + handler: toolHandler(StartAgentChatInput.shape, async (input, { ctx }) => { + ctx.logger?.log("calling start_agent_chat", { input }); + + if (ctx.options.devOnly && input.environment !== "dev") { + return respondWithError( + `This MCP server is only available for the dev environment.` + ); + } + + const projectRef = await ctx.getProjectRef({ + projectRef: input.projectRef, + cwd: input.configPath, + }); + + const apiClient = await ctx.getApiClient({ + projectRef, + environment: input.environment, + scopes: [ + "write:tasks", + "read:runs", + "read:sessions", + "write:sessions", + ], + branch: input.branch, + }); + + const chatId = input.chatId ?? crypto.randomUUID(); + + // Check if session already exists + if (activeSessions.has(chatId)) { + return { + content: [ + { + type: "text", + text: `Chat ${chatId} is already active with agent ${activeSessions.get(chatId)!.agentId}. Use send_agent_message to continue the conversation.`, + }, + ], + }; + } + + // Create (or upsert) the backing Session. Idempotent via externalId — + // two MCP clients targeting the same chatId converge to the same row. + // Sessions are now task-bound: taskIdentifier + triggerConfig are + // required, and the server reuses them for every run scheduled by + // this session (initial + continuations after run termination). + // + // basePayload mirrors the browser-mediated `chat.createStartSessionAction` + // shape so the auto-triggered first run hits `onPreload` (not + // `onChatStart` with `preloaded: true`). Without `trigger: "preload"` + // + `messages: []`, the agent runtime bypasses both lifecycle hooks + // and `onTurnStart`'s DB write fails with "No record found". + // + // POST /api/v1/sessions auto-triggers the first run and returns its + // runId, so we don't need a separate triggerTask call. The `preload` + // flag on this MCP tool is kept as a no-op signal (true=default) for + // backwards compat — a Session is always created with a live run now. + const session = await apiClient.createSession({ + type: "chat.agent", + externalId: chatId, + taskIdentifier: input.agentId, + triggerConfig: { + basePayload: { + messages: [], + trigger: "preload", + chatId, + ...(input.clientData ? { metadata: input.clientData } : {}), + }, + tags: [`chat:${chatId}`], + }, + }); + + activeSessions.set(chatId, { + sessionId: session.id, + runId: session.runId, + chatId, + agentId: input.agentId, + apiClient, + clientData: input.clientData, + messages: [], + }); + + return { + content: [ + { + type: "text", + text: [ + `Agent chat started${input.preload ? " and preloaded" : ""}.`, + `- Chat ID: ${chatId}`, + `- Session ID: ${session.id}`, + `- Agent: ${input.agentId}`, + `- Run ID: ${session.runId}`, + ``, + `Use send_agent_message with chatId "${chatId}" to send messages.`, + ].join("\n"), + }, + ], + }; + }), +}; + +// ─── Send Agent Message ─────────────────────────────────────────── + +const SendAgentMessageInput = z.object({ + chatId: z.string().describe("The chat ID from start_agent_chat."), + message: z.string().describe("The message to send to the agent."), +}); + +export const sendAgentMessageTool = { + name: toolsMetadata.send_agent_message.name, + title: toolsMetadata.send_agent_message.title, + description: toolsMetadata.send_agent_message.description, + inputSchema: SendAgentMessageInput.shape, + handler: toolHandler(SendAgentMessageInput.shape, async (input, { ctx }) => { + ctx.logger?.log("calling send_agent_message", { input }); + + const session = activeSessions.get(input.chatId); + if (!session) { + return respondWithError( + `No active chat with ID "${input.chatId}". Use start_agent_chat first.` + ); + } + + const msgId = `msg-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`; + const userMessage: ChatMessage = { + id: msgId, role: "user", parts: [{ type: "text", text: input.message }], + }; + + // Track the outgoing user message + session.messages.push(userMessage); + + // Slim-wire: one delta `message` per trigger. Prior turns live in the + // session.out snapshot+replay; we only ship the new user message. + const wirePayload = { + message: userMessage, + chatId: session.chatId, + trigger: "submit-message" as const, + metadata: session.clientData, + }; + + // If we have an active run, send via session.in. If that fails + // (run ended, token expired, etc.) fall back to triggering a new + // run on the same session — the new run replays prior turns from the + // snapshot and picks up `message` as turn N's user delta. + if (session.runId) { + try { + await session.apiClient.appendToSessionStream( + session.sessionId, + "in", + serializeInputChunk({ kind: "message", payload: wirePayload }) + ); + } catch (sendErr: any) { + ctx.logger?.log("appendToSessionStream failed, falling back to triggerTask", { + chatId: session.chatId, + sessionId: session.sessionId, + error: sendErr?.message ?? String(sendErr), + }); + const result = await session.apiClient.triggerTask(session.agentId, { + payload: { + message: userMessage, + chatId: session.chatId, + sessionId: session.sessionId, + trigger: "submit-message", + metadata: session.clientData, + continuation: true, + previousRunId: session.runId, + }, + options: { + payloadType: "application/json", + tags: [`chat:${session.chatId}`], + }, + }); + session.runId = result.id; + // Keep session.lastEventId as-is. The .out stream is per-session, so + // resuming from the last-seen chunk's id skips historical chunks — + // including stale `trigger:turn-complete` markers from prior turns + // that would otherwise break collectAgentResponse's read loop with + // empty/old text. Same reasoning as the trigger:upgrade-required + // path below. + } + } else { + // No run yet — trigger one (agent opens the session on startup). + const result = await session.apiClient.triggerTask(session.agentId, { + payload: { + ...wirePayload, + sessionId: session.sessionId, + }, + options: { + payloadType: "application/json", + tags: [`chat:${session.chatId}`], + }, + }); + session.runId = result.id; + } + + // Subscribe to the response stream and collect the full text + const { text, toolCalls, assistantMessage } = await collectAgentResponse(session); + + // Track the assistant response for continuation payloads + session.messages.push(assistantMessage); + + const formatted = formatAssistantParts(assistantMessage.parts); + const footer = `\n\n---\nRun: ${session.runId}`; + + return { + content: [{ type: "text", text: formatted + footer }], + }; + }), +}; + +// ─── Close Agent Chat ───────────────────────────────────────────── + +const CloseAgentChatInput = z.object({ + chatId: z.string().describe("The chat ID to close."), +}); + +export const closeAgentChatTool = { + name: toolsMetadata.close_agent_chat.name, + title: toolsMetadata.close_agent_chat.title, + description: toolsMetadata.close_agent_chat.description, + inputSchema: CloseAgentChatInput.shape, + handler: toolHandler(CloseAgentChatInput.shape, async (input, { ctx }) => { + ctx.logger?.log("calling close_agent_chat", { input }); + + const session = activeSessions.get(input.chatId); + if (!session) { + return respondWithError( + `No active chat with ID "${input.chatId}".` + ); + } + + if (session.runId) { + try { + await session.apiClient.appendToSessionStream( + session.sessionId, + "in", + serializeInputChunk({ + kind: "message", + payload: { + // `trigger: "close"` carries no message delta — the agent + // looks at `trigger` and exits without touching `message`. + chatId: session.chatId, + trigger: "close", + }, + }) + ); + } catch { + // Best effort — run may already be done + } + } + + activeSessions.delete(input.chatId); + + return { + content: [ + { + type: "text", + text: `Chat ${input.chatId} closed.`, + }, + ], + }; + }), +}; + +// ─── Stream collector ───────────────────────────────────────────── + +// Safety bound on chained upgrades during a single send. A misconfigured +// agent or upgrade-loop bug would otherwise grow the call stack without limit. +const MAX_UPGRADE_RECURSION_DEPTH = 10; + +async function collectAgentResponse( + session: ChatSession, + depth = 0 +): Promise<{ text: string; toolCalls: string[]; assistantMessage: ChatMessage }> { + if (depth > MAX_UPGRADE_RECURSION_DEPTH) { + throw new Error( + `Agent upgrade recursion depth exceeded (${depth} chained trigger:upgrade-required signals)` + ); + } + const baseURL = session.apiClient.baseUrl; + const streamUrl = `${baseURL}/realtime/v1/sessions/${encodeURIComponent(session.sessionId)}/out`; + + const subscription = new SSEStreamSubscription(streamUrl, { + headers: { + Authorization: `Bearer ${session.apiClient.accessToken}`, + }, + timeoutInSeconds: 120, + lastEventId: session.lastEventId, + }); + + const sseStream = await subscription.subscribe(); + const reader = sseStream.getReader(); + + let text = ""; + const toolCalls: string[] = []; + const parts: Array<{ type: string; [key: string]: unknown }> = []; + // Track current text part to accumulate deltas + let currentTextId: string | undefined; + + try { + while (true) { + const { done, value } = await reader.read(); + if (done) { + break; + } + + if (value.id) { + session.lastEventId = value.id; + } + + // v2 (session) SSE already parses record.body.data, so `chunk` is + // the UIMessageChunk object written by the agent. + if (value.chunk != null && typeof value.chunk === "object") { + const chunk = value.chunk as Record; + + if (chunk.type === "trigger:turn-complete") { + break; + } + + if (chunk.type === "trigger:upgrade-required") { + // Agent requested upgrade — trigger continuation. Same session, + // new run — reuse sessionId, swap runId. Slim-wire: ship only + // the latest user message as the turn-N delta; prior turns + // come back via snapshot+replay on the new run's boot. + const lastUserMessage = [...session.messages] + .reverse() + .find((m) => m.role === "user"); + const previousRunId = session.runId; + const result = await session.apiClient.triggerTask(session.agentId, { + payload: { + message: lastUserMessage, + chatId: session.chatId, + sessionId: session.sessionId, + trigger: "submit-message", + metadata: session.clientData, + continuation: true, + previousRunId, + }, + options: { + payloadType: "application/json", + tags: [`chat:${session.chatId}`], + }, + }); + session.runId = result.id; + // Keep session.lastEventId pointing at the trigger:upgrade-required + // chunk's id (set at line 370 when the chunk arrived). The recursive + // subscribe resumes right after that marker, so we don't replay the + // entire session.out stream — which would hit a historical + // trigger:turn-complete and break the loop with empty/old text. + reader.releaseLock(); + // Recurse — subscribe to the new run's stream (same session.out URL) + return collectAgentResponse(session, depth + 1); + } + + if (chunk.type === "text-delta" && typeof chunk.delta === "string") { + text += chunk.delta; + // Accumulate into a text part + const textId = (chunk.id as string) ?? "text"; + if (currentTextId !== textId) { + currentTextId = textId; + parts.push({ type: "text", text: chunk.delta }); + } else { + const last = parts[parts.length - 1]; + if (last && last.type === "text") { + last.text = (last.text as string) + chunk.delta; + } + } + } + + if (chunk.type === "tool-input-available" && typeof chunk.toolName === "string") { + toolCalls.push(chunk.toolName); + parts.push({ + type: `tool-${chunk.toolName}`, + toolCallId: chunk.toolCallId as string, + toolName: chunk.toolName, + state: "input-available", + input: chunk.input, + }); + } + + if (chunk.type === "tool-output-available" && typeof chunk.toolCallId === "string") { + // Update existing tool part with output + const toolPart = parts.find( + (p) => p.toolCallId === chunk.toolCallId + ); + if (toolPart) { + toolPart.state = "output-available"; + toolPart.output = chunk.output; + } + } + } + } + } finally { + reader.releaseLock(); + } + + const assistantMessage: ChatMessage = { + id: `msg-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`, + role: "assistant", + parts: parts.length > 0 ? parts : [{ type: "text", text }], + }; + + return { text, toolCalls, assistantMessage }; +} + +// ─── Response formatter ────────────────────────────────────────── + +function formatAssistantParts( + parts: Array<{ type: string; [key: string]: unknown }> +): string { + const sections: string[] = []; + + for (const part of parts) { + if (part.type === "text" && typeof part.text === "string" && part.text) { + sections.push(part.text); + } else if (part.type.startsWith("tool-") && part.toolName) { + const name = part.toolName as string; + const input = part.input; + const output = part.output; + + let toolSection = `[Tool: ${name}]`; + if (input != null) { + toolSection += `\nInput: ${compactJson(input)}`; + } + if (output != null) { + toolSection += `\nOutput: ${compactJson(output)}`; + } + sections.push(toolSection); + } + } + + return sections.join("\n\n"); +} + +function compactJson(value: unknown): string { + const str = JSON.stringify(value); + // Keep short values inline, truncate long ones + if (str.length <= 200) return str; + return str.slice(0, 200) + "…"; +} diff --git a/packages/cli-v3/src/mcp/tools/agents.ts b/packages/cli-v3/src/mcp/tools/agents.ts new file mode 100644 index 00000000000..e40bcafab6d --- /dev/null +++ b/packages/cli-v3/src/mcp/tools/agents.ts @@ -0,0 +1,71 @@ +import { toolsMetadata } from "../config.js"; +import { CommonProjectsInput } from "../schemas.js"; +import { respondWithError, toolHandler } from "../utils.js"; + +export const listAgentsTool = { + name: toolsMetadata.list_agents.name, + title: toolsMetadata.list_agents.title, + description: toolsMetadata.list_agents.description, + inputSchema: CommonProjectsInput.shape, + handler: toolHandler(CommonProjectsInput.shape, async (input, { ctx }) => { + ctx.logger?.log("calling list_agents", { input }); + + if (ctx.options.devOnly && input.environment !== "dev") { + return respondWithError( + `This MCP server is only available for the dev environment. You tried to access the ${input.environment} environment. Remove the --dev-only flag to access other environments.` + ); + } + + const projectRef = await ctx.getProjectRef({ + projectRef: input.projectRef, + cwd: input.configPath, + }); + + const cliApiClient = await ctx.getCliApiClient(input.branch); + + const workerResult = await cliApiClient.getWorkerByTag( + projectRef, + input.environment, + "current" + ); + + if (!workerResult.success) { + return respondWithError(workerResult.error); + } + + const { worker } = workerResult.data; + const agents = worker.tasks.filter((t) => t.triggerSource === "AGENT"); + + if (agents.length === 0) { + return { + content: [ + { + type: "text", + text: `No agents found in the current worker (${worker.version}) for ${input.environment}. Agents are tasks created with chat.agent() or chat.customAgent().`, + }, + ], + }; + } + + const contents = [ + `Found ${agents.length} agent${agents.length === 1 ? "" : "s"} in worker ${worker.version} (${input.environment}):`, + "", + ]; + + for (const agent of agents) { + contents.push(`- **${agent.slug}** (${agent.filePath})`); + } + + contents.push(""); + contents.push( + "Use `start_agent_chat` with an agent's slug as the `agentId` to start a conversation." + ); + contents.push( + "Use `get_task_schema` with an agent's slug to see its payload schema." + ); + + return { + content: [{ type: "text", text: contents.join("\n") }], + }; + }), +}; diff --git a/packages/cli-v3/src/mcp/tools/tasks.ts b/packages/cli-v3/src/mcp/tools/tasks.ts index fda3cc8943e..e82929db485 100644 --- a/packages/cli-v3/src/mcp/tools/tasks.ts +++ b/packages/cli-v3/src/mcp/tools/tasks.ts @@ -44,7 +44,8 @@ export const getCurrentWorker = { contents.push(`The worker has ${worker.tasks.length} tasks registered:`); for (const task of worker.tasks) { - contents.push(`- ${task.slug} in ${task.filePath}`); + const label = task.triggerSource === "AGENT" ? " [agent]" : ""; + contents.push(`- ${task.slug}${label} in ${task.filePath}`); } contents.push(""); diff --git a/references/ai-chat/.gitignore b/references/ai-chat/.gitignore new file mode 100644 index 00000000000..30838110ecc --- /dev/null +++ b/references/ai-chat/.gitignore @@ -0,0 +1 @@ +lib/generated/ diff --git a/references/ai-chat/.nvmrc b/references/ai-chat/.nvmrc new file mode 100644 index 00000000000..92f279e3e66 --- /dev/null +++ b/references/ai-chat/.nvmrc @@ -0,0 +1 @@ +v22 \ No newline at end of file diff --git a/references/ai-chat/DEMO-SHORTHAND.md b/references/ai-chat/DEMO-SHORTHAND.md new file mode 100644 index 00000000000..ff846401a65 --- /dev/null +++ b/references/ai-chat/DEMO-SHORTHAND.md @@ -0,0 +1,51 @@ +# Demo Cheat Sheet + +## Pitch +- Started as workflow engine, now people building chat agents +- Deep AI SDK useChat integration +- One chat = one persistent isolated execution environment +- Two-way communication + +## 1. Preloading +- Click New Chat, DON'T type anything +- Flip to dashboard — run already executing +- "waiting for first message" span +- Zero cold start + +## 2. First message — PostHog query +- "What are the top events on our PostHog instance this week?" +- Watch posthogQuery tool call +- Real data, real HogQL +- Show trace: onTurnStart → run → tool call → response +- Run stays alive after turn + +## 3. Follow-up — incremental +- "Which of those are custom events vs autocapture?" +- Only new message sent, not full history +- Backend has context in memory +- Same execution environment + +## 4. Suspend/resume +- 60s idle → snapshot → suspend → zero compute +- Next message → restore → continue +- Same run, same state + +## 5. Tool subtasks +- "Can you research what's new with PostHog lately?" +- deepResearch = separate task, own container +- Streams progress back to chat +- Show trace: triggerAndSubscribe → child run +- Stop cancels child automatically + +## 6. Code +- All regions collapsed — show the skeleton +- idleTimeoutInSeconds, clientDataSchema +- Hooks: onPreload, onTurnStart, onTurnComplete, run +- Expand run: just return streamText() +- Expand onTurnComplete: background self-review, chat.inject() + +## Wrap +- One chat, one persistent run +- Lifecycle hooks, streaming, subtasks, background injection +- Snapshot/restore, full observability +- Available now diff --git a/references/ai-chat/DEMO.md b/references/ai-chat/DEMO.md new file mode 100644 index 00000000000..5cdb560317d --- /dev/null +++ b/references/ai-chat/DEMO.md @@ -0,0 +1,96 @@ +# AI Chat Demo Script (5-7 min) + +**Setup:** Three windows ready — ai-chat app (localhost:3000), Trigger.dev dashboard, VS Code with chat.ts open (all regions collapsed). + +**Audience:** PostHog event + +**Pitch:** Trigger.dev started as a workflow engine for async background tasks, but more and more people are using us to build full chat agents. We've built a deep integration with the AI SDK's useChat hook that connects a single chat to a single persisted, isolated, fully customizable execution environment with two-way communication. + +--- + +## 1. New chat — preloading (1 min) + +**Open localhost:3000. Click "New Chat".** + +> I haven't typed anything yet. But flip to the dashboard — + +**Switch to dashboard. Show the run that just started.** + +> There's already a run executing. This is preloading. When the user opens the chat page, the frontend calls `transport.preload()` which triggers the task immediately. It loaded the user from the DB, resolved the system prompt, created the chat record — all before the first keystroke. Imagine this in something like PostHog's AI product assistant — when a user opens the chat, you want the agent ready instantly, not cold-starting while they wait. + +**Point to the "waiting for first message" span.** + +--- + +## 2. First message + live analytics query (1.5 min) + +**Switch back to chat. Type: "What are the top events on our PostHog instance this week?"** + +> Now the first turn starts — and watch, it's going to call the posthogQuery tool. This tool writes a HogQL query and runs it against our actual PostHog instance — this is our real Trigger.dev analytics data. + +**Watch the tool call + results stream back.** + +> It wrote the query, executed it, and summarized the results — all in one turn. + +**Switch to dashboard, show turn 1 span with the tool call.** + +> Here's the lifecycle — onTurnStart persisted the message, run() called streamText, the LLM decided to use the posthogQuery tool, got the results, and generated a response. After the turn completes, the run doesn't end — it waits for the next message. Same process, same memory. + +--- + +## 3. Follow-up — incremental sends + persistent state (45s) + +**Switch back to chat. Send: "How does that compare to last week?" or "Which of those are custom events vs autocapture?"** + +**Switch to dashboard, show turn 2.** + +> Turn 2 — the frontend only sent the new user message, not the full conversation. The backend already has the accumulated context. It knows what "those" refers to because it's the same execution environment. For a product analytics assistant where users iteratively drill into their data, this is huge — no context lost between turns. + +--- + +## 4. Idle, suspend, resume (30s) + +> After 60 seconds of no messages, the run snapshots its state and suspends. Zero compute while the user is away. When they come back — maybe they went to check their PostHog dashboard based on what the agent told them and came back with a follow-up — we restore from the snapshot and continue. Same run, same state. + +**Point to the "suspended" span in the trace if visible.** + +--- + +## 5. Tool subtasks (1 min) + +**Switch back to chat. Send: "Can you research what's new with PostHog lately?"** + +> Now it's using the deepResearch tool — this one is different. It's a separate Trigger.dev task running in its own container, fetching multiple URLs and streaming progress back to the chat in real time. You could have tools for querying PostHog, tools for checking feature flags, tools for pulling session recordings — and the heavy ones run as subtasks with their own retries and traces. + +**Show the trace — triggerAndSubscribe span with child run nested inside.** + +> The parent subscribes to the child via realtime. If the user hits stop, the child gets cancelled automatically. + +--- + +## 6. The code (1.5 min) + +**Switch to VS Code with chat.ts, all regions collapsed.** + +> This is the whole thing — one file. A chat.task with lifecycle hooks and a run function. + +Point out the collapsed view: + +- `idleTimeoutInSeconds`, `clientDataSchema` — typed metadata from the frontend +- `onPreload` — that's what fired before the first message +- `onTurnStart`, `onTurnComplete` — persistence hooks +- `run` — just `return streamText()`. The SDK handles everything else. + +**Expand the run region.** + +> Messages come in already converted. You return streamText. The posthogQuery tool is just a plain AI SDK tool that calls the PostHog API — deepResearch is a subtask wrapped with ai.tool. Mix and match. + +**Expand onTurnComplete if time.** + +> After every turn we defer a background call to gpt-4o-mini that reviews the response with generateObject. If it finds improvements, chat.inject adds a system message before the next LLM call. The agent gets coaching between turns — and it doesn't block the user. + +--- + +## 7. Wrap up (15s) + +> One chat, one persistent run. Lifecycle hooks, streaming, tool subtasks, background self-improvement — all on Trigger.dev's infrastructure with snapshot/restore and full observability. This is available now in the SDK. diff --git a/references/ai-chat/README.md b/references/ai-chat/README.md new file mode 100644 index 00000000000..39a6038f8c8 --- /dev/null +++ b/references/ai-chat/README.md @@ -0,0 +1,62 @@ +# AI Chat Reference App + +A multi-turn chat app built with the AI SDK's `useChat` hook and Trigger.dev's `chat.task`. Conversations run as durable Trigger.dev tasks with realtime streaming, automatic message accumulation, and persistence across page refreshes. + +## Data Models + +### Chat + +The conversation itself — your application data. + +| Column | Description | +| ---------- | ---------------------------------------- | +| `id` | Unique chat ID (generated on the client) | +| `title` | Display title for the sidebar | +| `messages` | Full `UIMessage[]` history (JSON) | + +A Chat lives forever (until the user deletes it). It is independent of any particular Trigger.dev run. + +### ChatSession + +The transport's connection state for a chat — what the frontend needs to reconnect to the same Trigger.dev run after a page refresh. + +| Column | Description | +| ------------------- | --------------------------------------------------------------------------- | +| `id` | Same as the chat ID (1:1 relationship) | +| `runId` | The Trigger.dev run handling this conversation | +| `publicAccessToken` | Scoped token for reading the run's stream and sending input stream messages | +| `lastEventId` | Stream position — used to resume without replaying old events | + +A Chat can outlive many ChatSessions. When the run ends (turn timeout, max turns reached, crash), the ChatSession is gone but the Chat and its messages remain. The next message from the user starts a fresh run and creates a new ChatSession for the same Chat. + +**Think of it as: Chat = the conversation, ChatSession = the live connection to the run handling it.** + +## Lifecycle Hooks + +Persistence is handled server-side in the Trigger.dev task via three hooks: + +- **`onChatStart`** — Creates the Chat and ChatSession records when a new conversation starts (turn 0). +- **`onTurnStart`** — Saves messages and updates the session _before_ streaming begins, so a mid-stream page refresh still shows the user's message. +- **`onTurnComplete`** — Saves the assistant's response and the `lastEventId` for stream resumption. + +## Setup + +```bash +# From the repo root +pnpm run docker # Start PostgreSQL, Redis, Electric +pnpm run db:migrate # Run webapp migrations +pnpm run db:seed # Seed the database + +# Set up the reference app's database +cd references/ai-chat +cp .env.example .env # Edit DATABASE_URL if needed +npx prisma migrate deploy + +# Build and run +pnpm run build --filter trigger.dev --filter @trigger.dev/sdk +pnpm run dev --filter webapp # In one terminal +cd references/ai-chat && pnpm exec trigger dev # In another +cd references/ai-chat && pnpm run dev # In another +``` + +Open http://localhost:3000 to use the chat app. diff --git a/references/ai-chat/next-env.d.ts b/references/ai-chat/next-env.d.ts new file mode 100644 index 00000000000..1b3be0840f3 --- /dev/null +++ b/references/ai-chat/next-env.d.ts @@ -0,0 +1,5 @@ +/// +/// + +// NOTE: This file should not be edited +// see https://nextjs.org/docs/app/api-reference/config/typescript for more information. diff --git a/references/ai-chat/next.config.ts b/references/ai-chat/next.config.ts new file mode 100644 index 00000000000..ca6c9392a18 --- /dev/null +++ b/references/ai-chat/next.config.ts @@ -0,0 +1,7 @@ +import type { NextConfig } from "next"; + +const nextConfig: NextConfig = { + devIndicators: false, +}; + +export default nextConfig; diff --git a/references/ai-chat/package.json b/references/ai-chat/package.json new file mode 100644 index 00000000000..45071838a68 --- /dev/null +++ b/references/ai-chat/package.json @@ -0,0 +1,48 @@ +{ + "name": "references-ai-chat", + "version": "0.1.0", + "private": true, + "scripts": { + "dev": "next dev --turbopack", + "build": "next build", + "start": "next start", + "dev:trigger": "trigger dev", + "db:migrate": "prisma migrate dev", + "db:push": "prisma db push", + "db:generate": "prisma generate", + "db:reset:chats": "prisma db execute --file prisma/reset-chats.sql", + "test": "vitest" + }, + "dependencies": { + "@ai-sdk/anthropic": "^3.0.0", + "@ai-sdk/openai": "^3.0.0", + "@ai-sdk/react": "^3.0.0", + "@prisma/adapter-pg": "^7.4.2", + "@prisma/client": "^7.4.2", + "@e2b/code-interpreter": "^2.4.0", + "@trigger.dev/sdk": "workspace:*", + "serialize-error": "^11.0.3", + "ai": "^6.0.0", + "next": "15.3.3", + "pg": "^8.16.3", + "react": "^19.0.0", + "react-dom": "^19.0.0", + "streamdown": "^2.3.0", + "turndown": "^7.2.2", + "zod": "3.25.76" + }, + "devDependencies": { + "@ai-sdk/provider": "3.0.8", + "@tailwindcss/postcss": "^4", + "@trigger.dev/build": "workspace:*", + "@types/node": "^22", + "@types/react": "^19", + "@types/react-dom": "^19", + "@types/turndown": "^5.0.6", + "prisma": "^7.4.2", + "tailwindcss": "^4", + "trigger.dev": "workspace:*", + "typescript": "^5", + "vitest": "^3.1.4" + } +} diff --git a/references/ai-chat/postcss.config.mjs b/references/ai-chat/postcss.config.mjs new file mode 100644 index 00000000000..79bcf135dc4 --- /dev/null +++ b/references/ai-chat/postcss.config.mjs @@ -0,0 +1,8 @@ +/** @type {import('postcss-load-config').Config} */ +const config = { + plugins: { + "@tailwindcss/postcss": {}, + }, +}; + +export default config; diff --git a/references/ai-chat/prisma.config.ts b/references/ai-chat/prisma.config.ts new file mode 100644 index 00000000000..d73df7b3168 --- /dev/null +++ b/references/ai-chat/prisma.config.ts @@ -0,0 +1,12 @@ +import "dotenv/config"; +import { defineConfig, env } from "prisma/config"; + +export default defineConfig({ + schema: "prisma/schema.prisma", + migrations: { + path: "prisma/migrations", + }, + datasource: { + url: env("DATABASE_URL"), + }, +}); diff --git a/references/ai-chat/prisma/migrations/20260305112427_init/migration.sql b/references/ai-chat/prisma/migrations/20260305112427_init/migration.sql new file mode 100644 index 00000000000..951cd33d94e --- /dev/null +++ b/references/ai-chat/prisma/migrations/20260305112427_init/migration.sql @@ -0,0 +1,20 @@ +-- CreateTable +CREATE TABLE "Chat" ( + "id" TEXT NOT NULL, + "title" TEXT NOT NULL, + "messages" JSONB NOT NULL DEFAULT '[]', + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "Chat_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "ChatSession" ( + "id" TEXT NOT NULL, + "runId" TEXT NOT NULL, + "publicAccessToken" TEXT NOT NULL, + "lastEventId" TEXT, + + CONSTRAINT "ChatSession_pkey" PRIMARY KEY ("id") +); diff --git a/references/ai-chat/prisma/migrations/20260306165319_add_user_model/migration.sql b/references/ai-chat/prisma/migrations/20260306165319_add_user_model/migration.sql new file mode 100644 index 00000000000..4a1bca35872 --- /dev/null +++ b/references/ai-chat/prisma/migrations/20260306165319_add_user_model/migration.sql @@ -0,0 +1,18 @@ +-- AlterTable +ALTER TABLE "Chat" ADD COLUMN "userId" TEXT; + +-- CreateTable +CREATE TABLE "User" ( + "id" TEXT NOT NULL, + "name" TEXT NOT NULL, + "plan" TEXT NOT NULL DEFAULT 'free', + "preferredModel" TEXT, + "messageCount" INTEGER NOT NULL DEFAULT 0, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "User_pkey" PRIMARY KEY ("id") +); + +-- AddForeignKey +ALTER TABLE "Chat" ADD CONSTRAINT "Chat_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE; diff --git a/references/ai-chat/prisma/migrations/20260327180000_remove_user_tool/migration.sql b/references/ai-chat/prisma/migrations/20260327180000_remove_user_tool/migration.sql new file mode 100644 index 00000000000..c7a35afc8f2 --- /dev/null +++ b/references/ai-chat/prisma/migrations/20260327180000_remove_user_tool/migration.sql @@ -0,0 +1,2 @@ +-- DropTable +DROP TABLE IF EXISTS "UserTool"; diff --git a/references/ai-chat/prisma/migrations/20260425091008_add_chat_model_and_user_github_token/migration.sql b/references/ai-chat/prisma/migrations/20260425091008_add_chat_model_and_user_github_token/migration.sql new file mode 100644 index 00000000000..277ee3276f8 --- /dev/null +++ b/references/ai-chat/prisma/migrations/20260425091008_add_chat_model_and_user_github_token/migration.sql @@ -0,0 +1,5 @@ +-- AlterTable +ALTER TABLE "Chat" ADD COLUMN "model" TEXT NOT NULL DEFAULT 'gpt-4o-mini'; + +-- AlterTable +ALTER TABLE "User" ADD COLUMN "githubToken" TEXT; diff --git a/references/ai-chat/prisma/migrations/20260425121916_add_session_id_to_chat_session/migration.sql b/references/ai-chat/prisma/migrations/20260425121916_add_session_id_to_chat_session/migration.sql new file mode 100644 index 00000000000..ee079856702 --- /dev/null +++ b/references/ai-chat/prisma/migrations/20260425121916_add_session_id_to_chat_session/migration.sql @@ -0,0 +1,2 @@ +-- AlterTable +ALTER TABLE "ChatSession" ADD COLUMN "sessionId" TEXT; diff --git a/references/ai-chat/prisma/migrations/20260427053743_simplify_chat_session_for_run_manager/migration.sql b/references/ai-chat/prisma/migrations/20260427053743_simplify_chat_session_for_run_manager/migration.sql new file mode 100644 index 00000000000..2b5d51564df --- /dev/null +++ b/references/ai-chat/prisma/migrations/20260427053743_simplify_chat_session_for_run_manager/migration.sql @@ -0,0 +1,10 @@ +/* + Warnings: + + - You are about to drop the column `runId` on the `ChatSession` table. All the data in the column will be lost. + - You are about to drop the column `sessionId` on the `ChatSession` table. All the data in the column will be lost. + +*/ +-- AlterTable +ALTER TABLE "ChatSession" DROP COLUMN "runId", +DROP COLUMN "sessionId"; diff --git a/references/ai-chat/prisma/migrations/migration_lock.toml b/references/ai-chat/prisma/migrations/migration_lock.toml new file mode 100644 index 00000000000..044d57cdb0d --- /dev/null +++ b/references/ai-chat/prisma/migrations/migration_lock.toml @@ -0,0 +1,3 @@ +# Please do not edit this file manually +# It should be added in your version-control system (e.g., Git) +provider = "postgresql" diff --git a/references/ai-chat/prisma/reset-chats.sql b/references/ai-chat/prisma/reset-chats.sql new file mode 100644 index 00000000000..06363e73397 --- /dev/null +++ b/references/ai-chat/prisma/reset-chats.sql @@ -0,0 +1,7 @@ +-- Wipe customer-side chat state for a fresh smoke-test slate. +-- Run via `pnpm run db:reset:chats`. +-- Leaves User rows intact (they're upserted by onPreload/onChatStart), +-- but clears every Chat + ChatSession so a chatId from one target +-- (test cloud / local) can't carry stale session/PAT/lastEventId state +-- into the other. +TRUNCATE "Chat", "ChatSession"; diff --git a/references/ai-chat/prisma/schema.prisma b/references/ai-chat/prisma/schema.prisma new file mode 100644 index 00000000000..51d6437a9b3 --- /dev/null +++ b/references/ai-chat/prisma/schema.prisma @@ -0,0 +1,42 @@ +generator client { + provider = "prisma-client" + output = "../lib/generated/prisma" +} + +datasource db { + provider = "postgresql" +} + +model User { + id String @id + name String + plan String @default("free") // "free" | "pro" + preferredModel String? + githubToken String? + messageCount Int @default(0) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + chats Chat[] +} + +model Chat { + id String @id + title String + model String @default("gpt-4o-mini") + messages Json @default("[]") + userId String? + user User? @relation(fields: [userId], references: [id]) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt +} + +// Persistable session state for a chat. After the Sessions-as-run-manager +// refactor, the transport addresses by `chatId` (used as the Session +// `externalId`) on every wire path — so we only need a session-scoped +// PAT and the SSE last-event-id for resume. Runs come and go inside +// the Session and are managed server-side. +model ChatSession { + id String @id // chatId + publicAccessToken String + lastEventId String? +} diff --git a/references/ai-chat/src/app/actions.ts b/references/ai-chat/src/app/actions.ts new file mode 100644 index 00000000000..0ef650cfc8c --- /dev/null +++ b/references/ai-chat/src/app/actions.ts @@ -0,0 +1,194 @@ +"use server"; + +import { auth } from "@trigger.dev/sdk"; +import { chat } from "@trigger.dev/sdk/ai"; +import type { + aiChat, + aiChatHydrated, + aiChatRaw, + aiChatSession, + upgradeTestAgent, +} from "@/trigger/chat"; +import type { ChatUiMessage } from "@/lib/chat-tools-schemas"; +import { prisma } from "@/lib/prisma"; + +/** Short-lived PATs for local testing of expiry + renewal (not for production). */ +const CHAT_EXAMPLE_PAT_TTL = "1h" as const; + +export type ChatReferenceTaskId = + | "ai-chat" + | "ai-chat-hydrated" + | "ai-chat-raw" + | "ai-chat-session" + | "upgrade-test"; + +function isChatReferenceTaskId(id: string): id is ChatReferenceTaskId { + return ( + id === "ai-chat" || + id === "ai-chat-hydrated" || + id === "ai-chat-raw" || + id === "ai-chat-session" || + id === "upgrade-test" + ); +} + +/** Keeps compile-time alignment with exported chat tasks. */ +type TaskIdentifierForChat = + | (typeof aiChat)["id"] + | (typeof aiChatHydrated)["id"] + | (typeof aiChatRaw)["id"] + | (typeof aiChatSession)["id"] + | (typeof upgradeTestAgent)["id"]; + +/** + * Server-mediated start: creates the Session row + triggers the first + * run via secret-key access, returns the session-scoped PAT for the + * browser to use. Wired into the transport's `startSession` callback — + * the transport invokes it on `transport.preload(chatId)` and lazily on + * the first `sendMessage` for any chatId without a cached PAT. + * + * The browser never sees a `start` token in this path; the customer's + * server keeps the secret. + * + * `clientData` flows through from the transport's typed `clientData` + * option — same value the transport merges into per-turn `metadata` + * — and lands in `triggerConfig.basePayload.metadata` so the first + * run's `payload.metadata` (visible to `onPreload` / `onChatStart`) + * matches what subsequent turns see. Server-side authorization can + * still override or augment what the browser claims (e.g. ignore a + * spoofed userId and substitute the request-session's userId). + */ +const startChatSessionFor = (taskId: TaskIdentifierForChat) => + chat.createStartSessionAction(taskId, { tokenTTL: CHAT_EXAMPLE_PAT_TTL }); + +const startActionByTaskId: Record< + ChatReferenceTaskId, + ReturnType +> = { + "ai-chat": startChatSessionFor("ai-chat"), + "ai-chat-hydrated": startChatSessionFor("ai-chat-hydrated"), + "ai-chat-raw": startChatSessionFor("ai-chat-raw"), + "ai-chat-session": startChatSessionFor("ai-chat-session"), + "upgrade-test": startChatSessionFor("upgrade-test"), +}; + +export async function startChatSession(input: { + chatId: string; + taskId?: string; + clientData?: Record; +}): Promise<{ publicAccessToken: string }> { + const id = input.taskId ?? "ai-chat"; + const taskId: ChatReferenceTaskId = !isChatReferenceTaskId(id) ? "ai-chat" : id; + + // `clientData` arrives from the transport's typed `clientData` option. + // In a real app the server would also resolve the user from the + // request session and merge/override accordingly — never trust the + // browser-claimed identity. The reference demo just trusts it. + const result = await startActionByTaskId[taskId]({ + chatId: input.chatId, + triggerConfig: input.clientData + ? { basePayload: { metadata: input.clientData } } + : undefined, + }); + + // Persist the latest PAT alongside the chat so a fresh tab can + // hydrate without going through the start callback again. + await prisma.chatSession + .upsert({ + where: { id: input.chatId }, + create: { id: input.chatId, publicAccessToken: result.publicAccessToken }, + update: { publicAccessToken: result.publicAccessToken }, + }) + .catch(() => { + /* best-effort persistence */ + }); + + return { publicAccessToken: result.publicAccessToken }; +} + +/** + * Mint a session-scoped PAT for a chatId. Pure: just calls + * `auth.createPublicToken` with `read:sessions:{chatId}` + + * `write:sessions:{chatId}` scopes — no DB writes, no session + * creation, no run triggering. + * + * The transport's `accessToken` callback wraps this. It fires on + * initial use (when no PAT is hydrated) and on 401/403 refresh. + * Session creation happens separately via `startChatSession` at page + * load — keeping these concerns split avoids re-triggering runs every + * time a PAT expires. + */ +export async function mintChatAccessToken(chatId: string): Promise { + return auth.createPublicToken({ + scopes: { + read: { sessions: chatId }, + write: { sessions: chatId }, + }, + expirationTime: CHAT_EXAMPLE_PAT_TTL, + }); +} + +export async function getChatList() { + const chats = await prisma.chat.findMany({ + select: { id: true, title: true, model: true, createdAt: true, updatedAt: true }, + orderBy: { updatedAt: "desc" }, + }); + return chats.map((c) => ({ + id: c.id, + title: c.title, + model: c.model, + createdAt: c.createdAt.getTime(), + updatedAt: c.updatedAt.getTime(), + })); +} + +export async function getChatMessages(chatId: string): Promise { + const found = await prisma.chat.findUnique({ where: { id: chatId } }); + if (!found) return []; + return found.messages as unknown as ChatUiMessage[]; +} + +export async function deleteChat(chatId: string) { + await prisma.chat.delete({ where: { id: chatId } }).catch(() => { }); + await prisma.chatSession.delete({ where: { id: chatId } }).catch(() => { }); +} + +export async function deleteAllChats() { + await prisma.chatSession.deleteMany(); + await prisma.chat.deleteMany(); +} + +export async function updateChatTitle(chatId: string, title: string) { + await prisma.chat.update({ where: { id: chatId }, data: { title } }).catch(() => { }); +} + +export async function updateSessionLastEventId(chatId: string, lastEventId: string) { + await prisma.chatSession + .update({ where: { id: chatId }, data: { lastEventId } }) + .catch(() => { }); +} + +export async function deleteSessionAction(chatId: string) { + await prisma.chatSession.delete({ where: { id: chatId } }).catch(() => { }); +} + +export async function getSessionForChat(chatId: string) { + const session = await prisma.chatSession.findUnique({ where: { id: chatId } }); + if (!session) return null; + return { + publicAccessToken: session.publicAccessToken, + lastEventId: session.lastEventId ?? undefined, + }; +} + +export async function getAllSessions() { + const sessions = await prisma.chatSession.findMany(); + const result: Record = {}; + for (const s of sessions) { + result[s.id] = { + publicAccessToken: s.publicAccessToken, + lastEventId: s.lastEventId ?? undefined, + }; + } + return result; +} diff --git a/references/ai-chat/src/app/api/chat/route.ts b/references/ai-chat/src/app/api/chat/route.ts new file mode 100644 index 00000000000..42812f16399 --- /dev/null +++ b/references/ai-chat/src/app/api/chat/route.ts @@ -0,0 +1,54 @@ +/** + * chat.headStart first-turn endpoint. + * + * The browser transport POSTs first-turn messages here when the + * `headStart` option is set on `useTriggerChatTransport`. This + * handler: + * + * 1. Creates the chat.agent session and triggers a `handover-prepare` + * run (atomic, one round-trip), so the agent boots in parallel. + * 2. Runs `streamText` step 1 right here in the warm Next.js process + * and returns the SSE stream directly to the browser — no waiting + * on the agent's cold start. + * 3. On step 1's tool-call boundary, hands ownership of the durable + * session.out stream over to the agent run, which executes tools + * and continues from step 2+ (or exits clean for pure-text turns). + * + * Subsequent turns bypass this endpoint — the transport hydrates the + * session PAT from response headers and writes directly to + * `session.in` for turn 2 onward. + * + * The TTFC win: cold-start agent boot (~488ms) + onTurnStart hooks + * (~316ms) overlap with the LLM TTFB instead of stacking before it, + * so the user-perceived first chunk arrives ~50% sooner. The agent + * still owns tool execution and everything after — heavy deps stay + * where they belong. + */ +import { chat } from "@trigger.dev/sdk/chat-server"; +import { streamText } from "ai"; +import { anthropic } from "@ai-sdk/anthropic"; +// ⚠️ Imports MUST come from `chat-tools-schemas` only — see the +// header comment in that file for the bundle-isolation rationale. +// Importing `src/trigger/chat-tools.ts` here would drag E2B, +// turndown, the trigger SDK runtime, etc. into the Next.js bundle +// and defeat the whole point of `chat.headStart`. +import { headStartTools } from "@/lib/chat-tools-schemas"; + +export const POST = chat.headStart({ + agentId: "ai-chat", + run: async ({ chat: chatHelper }) => { + return streamText({ + // `toStreamTextOptions` wires `messages` (converted from + // UIMessages), `tools`, `stopWhen: stepCountIs(1)`, and the + // combined `abortSignal`. Customer adds model + system prompt on + // top — anything else `streamText` accepts is fair game. + ...chatHelper.toStreamTextOptions({ tools: headStartTools }), + // Match the agent's default (`DEFAULT_MODEL` in `lib/models.ts`) + // so step 1 and step 2+ run on the same provider — no jarring + // tone/style shift mid-turn, and TTFC comparisons stay honest. + model: anthropic("claude-sonnet-4-6"), + system: + "You are a helpful AI assistant. Be concise and friendly. Use the available tools when relevant.", + }); + }, +}); diff --git a/references/ai-chat/src/app/chats/[chatId]/page.tsx b/references/ai-chat/src/app/chats/[chatId]/page.tsx new file mode 100644 index 00000000000..a75d22e518e --- /dev/null +++ b/references/ai-chat/src/app/chats/[chatId]/page.tsx @@ -0,0 +1,40 @@ +import { + getChatMessages, + getSessionForChat, + getChatList, +} from "@/app/actions"; +import { ChatView } from "@/components/chat-view"; +import { DEFAULT_MODEL } from "@/lib/models"; + +export default async function ChatPage({ + params, +}: { + params: Promise<{ chatId: string }>; +}) { + const { chatId } = await params; + + // Hydrate any persisted session PAT from a previous visit. For brand + // new chats `getSessionForChat` returns null and the client-side + // `chat-view.tsx` mount triggers `startChatSession` with the + // user-selected `taskMode` — the server-rendered page can't see the + // dropdown's React-context state. + const [messages, session, chatList] = await Promise.all([ + getChatMessages(chatId), + getSessionForChat(chatId), + getChatList(), + ]); + + const chatMeta = chatList.find((c) => c.id === chatId); + const isNewChat = !chatMeta; + const model = chatMeta?.model ?? DEFAULT_MODEL; + + return ( + + ); +} diff --git a/references/ai-chat/src/app/chats/layout.tsx b/references/ai-chat/src/app/chats/layout.tsx new file mode 100644 index 00000000000..d76cd85f23d --- /dev/null +++ b/references/ai-chat/src/app/chats/layout.tsx @@ -0,0 +1,16 @@ +import { getChatList } from "@/app/actions"; +import { ChatSettingsProvider } from "@/components/chat-settings-context"; +import { ChatSidebarWrapper } from "@/components/chat-sidebar-wrapper"; + +export default async function ChatsLayout({ children }: { children: React.ReactNode }) { + const chatList = await getChatList(); + + return ( + +
+ +
{children}
+
+
+ ); +} diff --git a/references/ai-chat/src/app/chats/page.tsx b/references/ai-chat/src/app/chats/page.tsx new file mode 100644 index 00000000000..04cd57b7012 --- /dev/null +++ b/references/ai-chat/src/app/chats/page.tsx @@ -0,0 +1,16 @@ +import { getChatList } from "@/app/actions"; +import { redirect } from "next/navigation"; + +export default async function ChatsPage() { + const chatList = await getChatList(); + + if (chatList.length > 0) { + redirect(`/chats/${chatList[0]!.id}`); + } + + return ( +
+

No conversations yet. Start a new chat.

+
+ ); +} diff --git a/references/ai-chat/src/app/globals.css b/references/ai-chat/src/app/globals.css new file mode 100644 index 00000000000..92c4b9a7860 --- /dev/null +++ b/references/ai-chat/src/app/globals.css @@ -0,0 +1,2 @@ +@import "tailwindcss"; +@source "../../../node_modules/streamdown/dist/*.js"; diff --git a/references/ai-chat/src/app/layout.tsx b/references/ai-chat/src/app/layout.tsx new file mode 100644 index 00000000000..544dd9142d8 --- /dev/null +++ b/references/ai-chat/src/app/layout.tsx @@ -0,0 +1,16 @@ +import type { Metadata } from "next"; +import "./globals.css"; +import "streamdown/styles.css"; + +export const metadata: Metadata = { + title: "AI Chat — Trigger.dev", + description: "AI SDK useChat powered by Trigger.dev durable tasks", +}; + +export default function RootLayout({ children }: { children: React.ReactNode }) { + return ( + + {children} + + ); +} diff --git a/references/ai-chat/src/app/page.tsx b/references/ai-chat/src/app/page.tsx new file mode 100644 index 00000000000..6792870396c --- /dev/null +++ b/references/ai-chat/src/app/page.tsx @@ -0,0 +1,5 @@ +import { redirect } from "next/navigation"; + +export default function Home() { + redirect("/chats"); +} diff --git a/references/ai-chat/src/components/chat-app.tsx b/references/ai-chat/src/components/chat-app.tsx new file mode 100644 index 00000000000..4d9ede23eba --- /dev/null +++ b/references/ai-chat/src/components/chat-app.tsx @@ -0,0 +1,209 @@ +"use client"; + +import { generateId } from "ai"; +import { useTriggerChatTransport } from "@trigger.dev/sdk/chat/react"; +import type { ChatUiMessage } from "@/lib/chat-tools-schemas"; +import { useCallback, useEffect, useState } from "react"; +import { Chat } from "@/components/chat"; +import { ChatSidebar } from "@/components/chat-sidebar"; +import { DEFAULT_MODEL } from "@/lib/models"; +import { + mintChatAccessToken, + startChatSession, + getChatList, + getChatMessages, + deleteChat as deleteChatAction, + deleteAllChats, + updateChatTitle, + deleteSessionAction, +} from "@/app/actions"; + +type ChatMeta = { + id: string; + title: string; + model: string; + createdAt: number; + updatedAt: number; +}; + +type SessionInfo = { + publicAccessToken: string; + lastEventId?: string; +}; + +type ChatAppProps = { + taskMode: string; + onTaskModeChange: (mode: string) => void; + initialChatList: ChatMeta[]; + initialActiveChatId: string | null; + initialMessages: ChatUiMessage[]; + initialSessions: Record; +}; + +export function ChatApp({ + taskMode, + onTaskModeChange, + initialChatList, + initialActiveChatId, + initialMessages, + initialSessions, +}: ChatAppProps) { + const [chatList, setChatList] = useState(initialChatList); + const [activeChatId, setActiveChatId] = useState(initialActiveChatId); + const [messages, setMessages] = useState(initialMessages); + const [sessions, setSessions] = useState>(initialSessions); + + // Model for new chats (before first message is sent) + const [newChatModel, setNewChatModel] = useState(DEFAULT_MODEL); + const [idleTimeoutInSeconds, setIdleTimeoutInSeconds] = useState(60); + + const handleSessionChange = useCallback((chatId: string, session: SessionInfo | null) => { + if (session) { + setSessions((prev) => ({ ...prev, [chatId]: session })); + } else { + setSessions((prev) => { + const next = { ...prev }; + delete next[chatId]; + return next; + }); + deleteSessionAction(chatId); + } + }, []); + + const transport = useTriggerChatTransport({ + task: taskMode, + // Pure mint — server action calls `auth.createPublicToken({ scopes: + // { sessions: chatId } })`. Fired on 401/403 refresh. + accessToken: ({ chatId }) => mintChatAccessToken(chatId), + // Session create — server action wraps `chat.createStartSessionAction`. + // Transport invokes it on `preload(chatId)` and lazily on first + // `sendMessage` for any chatId without a cached PAT. `clientData` + // is threaded through to `triggerConfig.basePayload.metadata` so + // the first run sees the same shape as per-turn `metadata`. + startSession: ({ chatId, taskId, clientData }) => + startChatSession({ chatId, taskId, clientData }), + baseURL: process.env.NEXT_PUBLIC_TRIGGER_API_URL, + sessions: initialSessions, + onSessionChange: handleSessionChange, + clientData: { userId: "user_123" }, + }); + + // Load messages when active chat changes + useEffect(() => { + if (!activeChatId) { + setMessages([]); + return; + } + // Don't reload if we already have the initial messages for the initial chat + if (activeChatId === initialActiveChatId && messages === initialMessages) { + return; + } + getChatMessages(activeChatId).then(setMessages); + }, [activeChatId]); + + function handleNewChat() { + const id = generateId(); + setActiveChatId(id); + setMessages([]); + setNewChatModel(DEFAULT_MODEL); + void idleTimeoutInSeconds; + } + + function handleSelectChat(id: string) { + setActiveChatId(id); + } + + async function handleDeleteChat(id: string) { + await deleteChatAction(id); + const list = await getChatList(); + setChatList(list); + if (activeChatId === id) { + if (list.length > 0) { + setActiveChatId(list[0]!.id); + } else { + setActiveChatId(null); + } + } + } + + async function handleWipeAll() { + await deleteAllChats(); + setChatList([]); + setActiveChatId(null); + setMessages([]); + setSessions({}); + } + + const handleFirstMessage = useCallback(async (chatId: string, text: string) => { + const title = text.slice(0, 40).trim() || "New chat"; + await updateChatTitle(chatId, title); + const list = await getChatList(); + setChatList(list); + }, []); + + const handleMessagesChange = useCallback(async (_chatId: string, _messages: ChatUiMessage[]) => { + // Messages are persisted server-side via onTurnComplete. + // Refresh the chat list to update timestamps. + const list = await getChatList(); + setChatList(list); + }, []); + + // Determine the model for the active chat + const activeChatMeta = chatList.find((c) => c.id === activeChatId); + const isNewChat = activeChatId != null && !activeChatMeta; + const activeModel = isNewChat ? newChatModel : activeChatMeta?.model ?? DEFAULT_MODEL; + + // Get session for the active chat + const activeSession = activeChatId ? sessions[activeChatId] : undefined; + + return ( +
+ {}} + /> +
+ {activeChatId ? ( + 0} + model={activeModel} + isNewChat={isNewChat} + onModelChange={isNewChat ? setNewChatModel : undefined} + session={activeSession} + dashboardUrl={process.env.NEXT_PUBLIC_TRIGGER_DASHBOARD_URL} + projectDashboardPath={process.env.NEXT_PUBLIC_TRIGGER_PROJECT_DASHBOARD_PATH} + onFirstMessage={handleFirstMessage} + onMessagesChange={handleMessagesChange} + /> + ) : ( +
+
+

No conversation selected

+ +
+
+ )} +
+
+ ); +} diff --git a/references/ai-chat/src/components/chat-settings-context.tsx b/references/ai-chat/src/components/chat-settings-context.tsx new file mode 100644 index 00000000000..6eb6366ca01 --- /dev/null +++ b/references/ai-chat/src/components/chat-settings-context.tsx @@ -0,0 +1,45 @@ +"use client"; + +import { createContext, useContext, useState, type ReactNode } from "react"; + +type ChatSettings = { + taskMode: string; + setTaskMode: (mode: string) => void; + idleTimeoutInSeconds: number; + setIdleTimeoutInSeconds: (seconds: number) => void; + /** + * When true, first-turn messages are POSTed to `/api/chat` + * (`chat.handover` route handler) instead of triggering the agent + * directly. Subsequent turns bypass the endpoint regardless. + */ + useHandover: boolean; + setUseHandover: (on: boolean) => void; +}; + +const ChatSettingsContext = createContext(null); + +export function ChatSettingsProvider({ children }: { children: ReactNode }) { + const [taskMode, setTaskMode] = useState("ai-chat"); + const [idleTimeoutInSeconds, setIdleTimeoutInSeconds] = useState(60); + const [useHandover, setUseHandover] = useState(false); + + const value: ChatSettings = { + taskMode, + setTaskMode, + idleTimeoutInSeconds, + setIdleTimeoutInSeconds, + useHandover, + setUseHandover, + }; + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const Provider = ChatSettingsContext.Provider as any; + + return {children}; +} + +export function useChatSettings() { + const ctx = useContext(ChatSettingsContext); + if (!ctx) throw new Error("useChatSettings must be used within ChatSettingsProvider"); + return ctx; +} diff --git a/references/ai-chat/src/components/chat-sidebar-wrapper.tsx b/references/ai-chat/src/components/chat-sidebar-wrapper.tsx new file mode 100644 index 00000000000..8b3dc3b8e51 --- /dev/null +++ b/references/ai-chat/src/components/chat-sidebar-wrapper.tsx @@ -0,0 +1,94 @@ +"use client"; + +import { useRouter, usePathname } from "next/navigation"; +import { ChatSidebar } from "@/components/chat-sidebar"; +import { useChatSettings } from "@/components/chat-settings-context"; +import { useState, useCallback, useEffect } from "react"; +import { generateId } from "ai"; +import { getChatList, deleteChat as deleteChatAction, deleteAllChats } from "@/app/actions"; + +type ChatMeta = { + id: string; + title: string; + model: string; + createdAt: number; + updatedAt: number; +}; + +export function ChatSidebarWrapper({ + initialChatList, +}: { + initialChatList: ChatMeta[]; +}) { + const router = useRouter(); + const pathname = usePathname(); + const [chatList, setChatList] = useState(initialChatList); + const { + taskMode, + setTaskMode, + idleTimeoutInSeconds, + setIdleTimeoutInSeconds, + useHandover, + setUseHandover, + } = useChatSettings(); + + // Extract active chatId from URL + const activeChatId = + pathname?.startsWith("/chats/") ? (pathname.split("/chats/")[1]?.split("/")[0] ?? null) : null; + + const refreshChatList = useCallback(async () => { + const list = await getChatList(); + setChatList(list); + }, []); + + // Refresh chat list on navigation + useEffect(() => { + refreshChatList(); + }, [pathname, refreshChatList]); + + function handleSelectChat(id: string) { + router.push(`/chats/${id}`); + } + + function handleNewChat() { + const id = generateId(); + router.push(`/chats/${id}`); + } + + async function handleDeleteChat(id: string) { + await deleteChatAction(id); + const list = await getChatList(); + setChatList(list); + if (activeChatId === id) { + if (list.length > 0) { + router.push(`/chats/${list[0]!.id}`); + } else { + router.push("/chats"); + } + } + } + + async function handleWipeAll() { + if (!confirm("Delete ALL chats? This cannot be undone.")) return; + await deleteAllChats(); + setChatList([]); + router.push("/chats"); + } + + return ( + + ); +} diff --git a/references/ai-chat/src/components/chat-sidebar.tsx b/references/ai-chat/src/components/chat-sidebar.tsx new file mode 100644 index 00000000000..e036eebc71a --- /dev/null +++ b/references/ai-chat/src/components/chat-sidebar.tsx @@ -0,0 +1,145 @@ +"use client"; + +type ChatMeta = { + id: string; + title: string; + createdAt: number; + updatedAt: number; +}; + +function timeAgo(ts: number): string { + const seconds = Math.floor((Date.now() - ts) / 1000); + if (seconds < 60) return "just now"; + const minutes = Math.floor(seconds / 60); + if (minutes < 60) return `${minutes}m ago`; + const hours = Math.floor(minutes / 60); + if (hours < 24) return `${hours}h ago`; + const days = Math.floor(hours / 24); + return `${days}d ago`; +} + +type ChatSidebarProps = { + chats: ChatMeta[]; + activeChatId: string | null; + onSelectChat: (id: string) => void; + onNewChat: () => void; + onDeleteChat: (id: string) => void; + onWipeAll: () => void; + idleTimeoutInSeconds: number; + onIdleTimeoutChange: (seconds: number) => void; + taskMode: string; + onTaskModeChange: (mode: string) => void; + useHandover: boolean; + onUseHandoverChange: (on: boolean) => void; +}; + +export function ChatSidebar({ + chats, + activeChatId, + onSelectChat, + onNewChat, + onDeleteChat, + onWipeAll, + idleTimeoutInSeconds, + onIdleTimeoutChange, + taskMode, + onTaskModeChange, + useHandover, + onUseHandoverChange, +}: ChatSidebarProps) { + const sorted = [...chats].sort((a, b) => b.updatedAt - a.updatedAt); + + return ( +
+
+ +
+ +
+ {sorted.length === 0 && ( +

No conversations yet

+ )} + + {sorted.map((chat) => ( + + ))} +
+ +
+
+ Idle timeout + onIdleTimeoutChange(Number(e.target.value))} + className="w-16 rounded border border-gray-300 px-1.5 py-0.5 text-xs text-gray-600 outline-none focus:border-blue-500" + /> + s +
+
+ Task + +
+ + +
+
+ ); +} diff --git a/references/ai-chat/src/components/chat-view.tsx b/references/ai-chat/src/components/chat-view.tsx new file mode 100644 index 00000000000..5c1d52296ec --- /dev/null +++ b/references/ai-chat/src/components/chat-view.tsx @@ -0,0 +1,116 @@ +"use client"; + +import { useTriggerChatTransport } from "@trigger.dev/sdk/chat/react"; +import type { ChatUiMessage } from "@/lib/chat-tools-schemas"; +import { Chat } from "@/components/chat"; +import { useChatSettings } from "@/components/chat-settings-context"; +import { + mintChatAccessToken, + startChatSession, + updateChatTitle, + deleteSessionAction, +} from "@/app/actions"; +import { useCallback, useState } from "react"; +import { useRouter } from "next/navigation"; + +type SessionInfo = { + publicAccessToken: string; + lastEventId?: string; +}; + +type ChatViewProps = { + chatId: string; + initialMessages: ChatUiMessage[]; + initialSession: SessionInfo | null; + isNewChat: boolean; + model: string; +}; + +export function ChatView({ + chatId, + initialMessages, + initialSession, + isNewChat, + model, +}: ChatViewProps) { + const router = useRouter(); + const { taskMode, useHandover } = useChatSettings(); + + const [currentSession, setCurrentSession] = useState(initialSession); + + const handleSessionChange = useCallback((id: string, session: SessionInfo | null) => { + if (session) { + setCurrentSession(session); + } else { + setCurrentSession(null); + deleteSessionAction(id); + } + }, []); + + const transport = useTriggerChatTransport({ + task: taskMode, + // Pure mint — server action calls `auth.createPublicToken({ scopes: + // { sessions: chatId } })` and returns the JWT. Fired on 401/403 to + // refresh the session PAT. Never creates a session. + accessToken: ({ chatId }) => mintChatAccessToken(chatId), + // Session create — server action wraps `chat.createStartSessionAction` + // (secret-key auth, server-side authorization). Idempotent on + // `(env, externalId)`. Transport invokes it on `preload(chatId)` + // and lazily on first `sendMessage` for any chatId without a + // cached PAT. `clientData` is the transport's typed `clientData` + // option, threaded through so the first run's `payload.metadata` + // matches per-turn `metadata`. + startSession: ({ chatId, taskId, clientData }) => + startChatSession({ chatId, taskId, clientData }), + baseURL: process.env.NEXT_PUBLIC_TRIGGER_API_URL, + sessions: initialSession ? { [chatId]: initialSession } : {}, + onSessionChange: handleSessionChange, + clientData: { userId: "user_123" }, + multiTab: true, + // Head-start URL: opt-in fast-path for the first message of a + // brand-new chat. The transport POSTs to `/api/chat` (which + // exports `chat.handover({ agentId, run })`) so step 1's LLM + // call runs in the warm Next.js process while the trigger agent + // run boots in parallel. After turn 1 the transport hydrates + // session state from response headers and writes directly to + // `session.in` for turn 2 onward — same direct-trigger path as + // when `headStart` is unset. + headStart: useHandover ? "/api/chat" : undefined, + }); + + const handleFirstMessage = useCallback( + async (cId: string, text: string) => { + const title = text.slice(0, 40).trim() || "New chat"; + await updateChatTitle(cId, title); + router.refresh(); + }, + [router] + ); + + const handleMessagesChange = useCallback( + async (_cId: string, _msgs: ChatUiMessage[]) => { + router.refresh(); + }, + [router] + ); + + const activeSession = currentSession ?? undefined; + + return ( + 0 || !!initialSession} + model={model} + isNewChat={isNewChat} + session={activeSession} + dashboardUrl={process.env.NEXT_PUBLIC_TRIGGER_DASHBOARD_URL} + projectDashboardPath={process.env.NEXT_PUBLIC_TRIGGER_PROJECT_DASHBOARD_PATH} + onFirstMessage={handleFirstMessage} + onMessagesChange={handleMessagesChange} + handoverEnabled={useHandover} + /> + ); +} diff --git a/references/ai-chat/src/components/chat.tsx b/references/ai-chat/src/components/chat.tsx new file mode 100644 index 00000000000..c4a37e258d4 --- /dev/null +++ b/references/ai-chat/src/components/chat.tsx @@ -0,0 +1,995 @@ +"use client"; + +import { useChat } from "@ai-sdk/react"; +import { + lastAssistantMessageIsCompleteWithApprovalResponses, + lastAssistantMessageIsCompleteWithToolCalls, +} from "ai"; +import type { ChatUiMessage } from "@/lib/chat-tools-schemas"; +import type { TriggerChatTransport } from "@trigger.dev/sdk/chat"; + +// Structural type mirroring @trigger.dev/sdk/ai's CompactionChunkData. +// Importing from the `ai` subpath drags the full chat.agent module — +// including skills' `node:child_process` import — into the client +// bundle. Keeping this inline is a type-only dependency. +type CompactionChunkData = { + status: "compacting" | "compacted"; + totalTokens?: number; +}; +import { usePendingMessages, useMultiTabChat } from "@trigger.dev/sdk/chat/react"; +import { useCallback, useEffect, useRef, useState } from "react"; +import { Streamdown } from "streamdown"; +import { MODEL_OPTIONS } from "@/lib/models"; + +function ToolInvocation({ + part, + onApprove, + onDeny, + onToolOutput, +}: { + part: any; + onApprove?: (approvalId: string) => void; + onDeny?: (approvalId: string) => void; + onToolOutput?: (tool: string, toolCallId: string, output: unknown) => void; +}) { + const [expanded, setExpanded] = useState(false); + const toolName = part.type.startsWith("tool-") ? part.type.slice(5) : "tool"; + const state = part.state ?? "input-available"; + const args = part.input; + const result = part.output; + + const isLoading = state === "input-streaming" || state === "input-available"; + const isError = state === "output-error"; + const needsApproval = state === "approval-requested"; + const wasApproved = state === "approval-responded" && part.approval?.approved === true; + const wasDenied = state === "approval-responded" && part.approval?.approved === false; + + return ( +
+ + + {needsApproval && ( +
+ + +
+ )} + + {/* askUser tool: show question + option buttons when input-available */} + {toolName === "askUser" && state === "input-available" && args?.question && ( +
+
{args.question}
+
+ {(args.options ?? []).map((opt: any) => ( + + ))} +
+
+ )} + + {expanded && ( +
+ {args && Object.keys(args).length > 0 && ( +
+
Input
+
+                {JSON.stringify(args, null, 2)}
+              
+
+ )} + {state === "output-available" && result !== undefined && ( +
+
Output
+
+                {JSON.stringify(result, null, 2)}
+              
+
+ )} + {isError && result !== undefined && ( +
+
Error
+
+                {typeof result === "string" ? result : JSON.stringify(result, null, 2)}
+              
+
+ )} +
+ )} +
+ ); +} + +function ResearchProgress({ part }: { part: any }) { + const data = part.data as { + status: "fetching" | "done"; + query: string; + current: number; + total: number; + currentUrl?: string; + completedUrls: string[]; + }; + + const isDone = data.status === "done"; + + return ( +
+
+ {isDone ? ( + + ) : ( + + )} + + {isDone + ? `Research complete — ${data.total} sources fetched` + : `Researching "${data.query}" (${data.current}/${data.total})`} + +
+ {data.currentUrl && !isDone && ( +
Fetching {data.currentUrl}
+ )} + {data.completedUrls.length > 0 && ( +
+ {data.completedUrls.map((url, i) => ( +
+ ✓ {url} +
+ ))} +
+ )} +
+ ); +} + +type TtfbEntry = { turn: number; ttfbMs: number }; + +function DebugPanel({ + chatId, + model, + status, + session, + dashboardUrl, + projectDashboardPath, + messageCount, + ttfbHistory, +}: { + chatId: string; + model: string; + status: string; + session?: { publicAccessToken: string; lastEventId?: string; isStreaming?: boolean }; + dashboardUrl?: string; + projectDashboardPath?: string; + messageCount: number; + ttfbHistory: TtfbEntry[]; +}) { + const runsUrl = + dashboardUrl && projectDashboardPath + ? `${dashboardUrl}${projectDashboardPath}/env/dev/runs?tags=${encodeURIComponent(`chat:${chatId}`)}` + : undefined; + const [open, setOpen] = useState(false); + + const latestTtfb = ttfbHistory.length > 0 ? ttfbHistory[ttfbHistory.length - 1]! : undefined; + const avgTtfb = + ttfbHistory.length > 0 + ? Math.round(ttfbHistory.reduce((sum, e) => sum + e.ttfbMs, 0) / ttfbHistory.length) + : undefined; + + return ( +
+ + + {open && ( +
+ + + + + {runsUrl && } + {session ? ( + <> + + + + ) : ( + + )} + {ttfbHistory.length > 0 && ( + <> +
+ TTFB + {avgTtfb !== undefined && ( + avg {avgTtfb.toLocaleString()}ms + )} +
+ {ttfbHistory.map((entry) => ( +
+ Turn {entry.turn} + {entry.ttfbMs.toLocaleString()}ms +
+ ))} + + )} +
+ )} +
+ ); +} + +function Row({ + label, + value, + mono, + link, +}: { + label: string; + value: string; + mono?: boolean; + link?: string; +}) { + return ( +
+ {label} + {link ? ( + + {value} + + ) : ( + {value} + )} +
+ ); +} + +type ChatProps = { + chatId: string; + initialMessages: ChatUiMessage[]; + transport: TriggerChatTransport; + resume?: boolean; + model: string; + isNewChat: boolean; + onModelChange?: (model: string) => void; + session?: { publicAccessToken: string; lastEventId?: string; isStreaming?: boolean }; + dashboardUrl?: string; + projectDashboardPath?: string; + onFirstMessage?: (chatId: string, text: string) => void; + onMessagesChange?: (chatId: string, messages: ChatUiMessage[]) => void; + /** Whether the transport is configured to route first-turn through `chat.handover`. */ + handoverEnabled?: boolean; +}; + +export function Chat({ + chatId, + initialMessages, + transport, + resume: resumeProp, + model, + isNewChat, + onModelChange, + session, + dashboardUrl, + projectDashboardPath, + onFirstMessage, + onMessagesChange, + handoverEnabled = false, +}: ChatProps) { + const [input, setInput] = useState(""); + const hasCalledFirstMessage = useRef(false); + + // TTFB tracking + const sendTimestamp = useRef(null); + const turnCounter = useRef(0); + const [ttfbHistory, setTtfbHistory] = useState([]); + + const { + messages, + setMessages, + sendMessage, + stop: aiStop, + addToolApprovalResponse, + addToolOutput, + regenerate, + status, + error, + } = useChat({ + id: chatId, + messages: initialMessages, + transport, + resume: resumeProp, + sendAutomaticallyWhen: (opts) => + lastAssistantMessageIsCompleteWithApprovalResponses(opts) || + lastAssistantMessageIsCompleteWithToolCalls(opts), + }); + + // Multi-tab coordination: sync messages between tabs + const { isReadOnly } = useMultiTabChat(transport, chatId, messages, setMessages); + + // Use transport.stopGeneration for reliable stop after reconnect. + // Once the AI SDK passes abortSignal through reconnectToStream, + // aiStop() alone will suffice. Until then, this covers both cases. + const stop = useCallback(() => { + transport.stopGeneration(chatId); + aiStop(); + }, [transport, chatId, aiStop]); + + // Tool approval callbacks + const handleApprove = useCallback( + (approvalId: string) => { + addToolApprovalResponse({ id: approvalId, approved: true }); + }, + [addToolApprovalResponse, chatId, messages, status] + ); + + const handleDeny = useCallback( + (approvalId: string) => { + addToolApprovalResponse({ id: approvalId, approved: false, reason: "User denied" }); + }, + [addToolApprovalResponse, chatId] + ); + + // Notify parent of first user message (for chat metadata creation) + useEffect(() => { + if (hasCalledFirstMessage.current) return; + const firstUser = messages.find((m) => m.role === "user"); + if (firstUser) { + hasCalledFirstMessage.current = true; + const text = firstUser.parts + .filter((p: any) => p.type === "text") + .map((p: any) => p.text) + .join(" "); + onFirstMessage?.(chatId, text); + } + }, [messages, chatId, onFirstMessage]); + + // TTFB detection: record when first assistant content appears after send + useEffect(() => { + if (status !== "streaming") return; + if (sendTimestamp.current === null) return; + const lastMsg = messages[messages.length - 1]; + if (lastMsg?.role === "assistant") { + const ttfbMs = Date.now() - sendTimestamp.current; + const turn = turnCounter.current; + sendTimestamp.current = null; + setTtfbHistory((prev) => [...prev, { turn, ttfbMs }]); + } + }, [status, messages]); + + // Pending messages — handles steering messages during streaming + const pending = usePendingMessages({ + transport, + chatId, + status, + messages, + setMessages, + sendMessage, + metadata: { model }, + }); + + // Expose test helpers for automated testing via Chrome DevTools. + // All actions go through refs so closures always call the latest version. + const stateRef = useRef({ status, messages, pending: pending.pending, error }); + stateRef.current = { status, messages, pending: pending.pending, error }; + + // Diagnostic: when the AI SDK transitions into an error state, log the + // root cause once. Useful for catching transient mid-flow errors that + // surface as `status: "error"` but leave no obvious clue otherwise. + const prevErrorRef = useRef(null); + useEffect(() => { + if (error && error !== prevErrorRef.current) { + // eslint-disable-next-line no-console + console.error("[chat.error]", { + message: error.message, + name: error.name, + stack: error.stack?.split("\n").slice(0, 6).join("\n"), + chatId, + status, + msgCount: messages.length, + lastEventId: transport.getSession(chatId)?.lastEventId ?? null, + }); + } + prevErrorRef.current = error; + }, [error, chatId, status, messages.length, transport]); + + const actionsRef = useRef({ + steer: pending.steer, + queue: pending.queue, + promote: pending.promoteToSteering, + send: (text: string) => { + turnCounter.current++; + sendTimestamp.current = Date.now(); + sendMessage({ text }, { metadata: { model } }); + }, + stop, + }); + actionsRef.current = { + steer: pending.steer, + queue: pending.queue, + promote: pending.promoteToSteering, + send: (text: string) => { + turnCounter.current++; + sendTimestamp.current = Date.now(); + sendMessage({ text }, { metadata: { model } }); + }, + stop, + }; + + useEffect(() => { + // ── Test bridge ────────────────────────────────────────────────── + // + // Exposes `window.__chat` so an automated driver (Chrome DevTools + // MCP, Playwright, etc.) can exercise the chat end-to-end without + // clicking buttons. The bridge is mounted only when this component + // is alive — unmount clears `window.__chat`, so each chat page owns + // the namespace. + // + // Bridge surface groups: + // + // - **State accessors** (always fresh via refs): `status`, `messages`, + // `pending`, `chatId`, plus the full `session` object (sessionId, + // runId, lastEventId, isStreaming) and its convenience unwraps + // `sessionId` / `runId` / `lastEventId`. + // - **Actions**: `send`, `stop`, `steer`, `queue`, `promote`, and + // `setMessages` so scripts can inject fixture state for + // refresh-replay tests. `stop` calls both `transport.stopGeneration` + // and `aiStop()` — same surface the UI's Stop button uses. + // - **Waiters** — resolve a Promise when an async condition holds: + // `waitForStatus(target, timeoutMs)`, + // `waitForMessage(predicate, timeoutMs)`, + // `waitForFirstAssistantText(timeoutMs)` (convenience — resolves + // once any assistant message has a non-empty text part), + // `steerOnToolCall(text)`. Default timeout 30s; rejects on timeout. + // - **Scripted helpers** (`steerAfterDelay`, `queueAfterDelay`) for + // fire-at-time side effects. + // + // Waiters poll at 50ms. That's tight enough for text-delta races + // and light enough that the React tree doesn't feel it. + const POLL_MS = 50; + const DEFAULT_TIMEOUT_MS = 30_000; + + const waitFor = ( + check: () => T | false | null | undefined, + timeoutMs = DEFAULT_TIMEOUT_MS, + label = "condition" + ): Promise => + new Promise((resolve, reject) => { + const start = Date.now(); + const immediate = check(); + if (immediate) { + resolve(immediate as T); + return; + } + const interval = setInterval(() => { + const got = check(); + if (got) { + clearInterval(interval); + resolve(got as T); + return; + } + if (Date.now() - start > timeoutMs) { + clearInterval(interval); + reject(new Error(`__chat: timed out after ${timeoutMs}ms waiting for ${label}`)); + } + }, POLL_MS); + }); + + (window as any).__chat = { + // ── State ───────────────────────────────────────────────────── + get status() { + return stateRef.current.status; + }, + get messages() { + return stateRef.current.messages; + }, + get pending() { + return stateRef.current.pending; + }, + get session() { + // Live session state from the transport (sessionId, runId, + // lastEventId, isStreaming). Falls back to the SSR-supplied + // session for runs the transport hasn't observed yet. + return transport.getSession(chatId) ?? session ?? null; + }, + get sessionId() { + // Sessions-as-run-manager: sessionId is no longer surfaced + // through the transport. The chat is addressed by `chatId` and + // any consumer that wants the friendlyId must look it up via + // `sessions.retrieve(chatId)` server-side. + return null; + }, + get runId() { + // Sessions-as-run-manager: runs come and go inside the Session + // and are managed server-side. The transport doesn't track the + // live runId anymore; consumers wanting it should query + // `sessions.retrieve(chatId)` server-side. + return null; + }, + get lastEventId() { + return transport.getSession(chatId)?.lastEventId ?? null; + }, + get error() { + // Surface the AI SDK's last error so smoke tests can capture the + // root cause when status flips to "error" mid-flow. + const err = stateRef.current.error; + if (!err) return null; + return { + message: (err as Error).message, + name: (err as Error).name, + stack: (err as Error).stack?.split("\n").slice(0, 6).join("\n"), + }; + }, + chatId, + /** True when the transport is configured to route first-turn through `chat.handover`. */ + handoverEnabled, + + // ── Actions ─────────────────────────────────────────────────── + steer: (text: string) => actionsRef.current.steer(text), + queue: (text: string) => actionsRef.current.queue(text), + promote: (id: string) => actionsRef.current.promote(id), + send: (text: string) => actionsRef.current.send(text), + stop: () => actionsRef.current.stop(), + sendAction: (action: unknown) => transport.sendAction(chatId, action), + regenerate: () => regenerate(), + + // ── Waiters ─────────────────────────────────────────────────── + waitForStatus: (target: string, timeoutMs = DEFAULT_TIMEOUT_MS) => + waitFor( + () => (stateRef.current.status === target ? (true as const) : false), + timeoutMs, + `status === "${target}" (current: "${stateRef.current.status}")` + ), + waitForMessage: ( + predicate: (m: any, all: any[]) => boolean, + timeoutMs = DEFAULT_TIMEOUT_MS + ): Promise => + waitFor( + () => stateRef.current.messages.find((m) => predicate(m, stateRef.current.messages)) as M | undefined, + timeoutMs, + "matching message" + ), + waitForFirstAssistantText: (timeoutMs = DEFAULT_TIMEOUT_MS) => + waitFor( + () => { + const assistant = stateRef.current.messages.find((m) => m.role === "assistant"); + if (!assistant) return false; + const text = (assistant.parts ?? []) + .filter((p: any) => p.type === "text" && typeof p.text === "string" && p.text.length > 0) + .map((p: any) => p.text) + .join(""); + return text.length > 0 ? { id: assistant.id, text } : false; + }, + timeoutMs, + "first assistant text" + ), + steerOnToolCall: (text: string, timeoutMs = DEFAULT_TIMEOUT_MS) => + waitFor( + () => { + const lastMsg = stateRef.current.messages[stateRef.current.messages.length - 1]; + const hasTool = + lastMsg?.role === "assistant" && + lastMsg.parts?.some((p: any) => p.type?.startsWith("tool-")); + if (!hasTool) return false; + actionsRef.current.steer(text); + return true as const; + }, + timeoutMs, + "tool call" + ), + + // ── Scripted helpers ───────────────────────────────────────── + steerAfterDelay: (text: string, ms: number) => + new Promise((r) => + setTimeout(() => { + actionsRef.current.steer(text); + r(); + }, ms) + ), + queueAfterDelay: (text: string, ms: number) => + new Promise((r) => + setTimeout(() => { + actionsRef.current.queue(text); + r(); + }, ms) + ), + }; + return () => { + delete (window as any).__chat; + }; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [chatId]); + + // Persist messages when a turn completes + const prevStatus = useRef(status); + useEffect(() => { + const turnCompleted = prevStatus.current === "streaming" && status === "ready"; + prevStatus.current = status; + if (!turnCompleted) return; + if (messages.length > 0) { + onMessagesChange?.(chatId, messages); + } + }, [status, messages, chatId, onMessagesChange]); + + return ( +
+ {/* Model selector for new chats */} + {isNewChat && messages.length === 0 && onModelChange && ( +
+ Model: + +
+ )} + + {/* Model badge for existing chats */} + {(!isNewChat || messages.length > 0) && ( +
+ + {model} + +
+ )} + + {/* Messages */} +
+ {messages.length === 0 && ( +

+ Send a message to start chatting. +

+ )} + + {messages.map((message) => ( +
+
+
+ {message.parts.map((part, i) => { + if (part.type === "text") { + if (message.role === "assistant") { + return {part.text}; + } + return {part.text}; + } + + if (part.type === "reasoning") { + return ( +
+ + Thinking... + +
+ {part.text} +
+
+ ); + } + + // Transient status parts — hide from rendered output + if ( + part.type === "data-turn-status" || + part.type === "data-background-context-injected" + ) { + return null; + } + + if (part.type === "data-research-progress") { + return ; + } + + if (part.type === "data-compaction") { + const data = (part as any).data as CompactionChunkData; + return ( +
+ {data.status === "compacting" ? "⏳" : "✂️"} + + {data.status === "compacting" + ? `Compacting conversation${ + data.totalTokens + ? ` (${data.totalTokens.toLocaleString()} tokens)` + : "" + }...` + : "Conversation compacted"} + +
+ ); + } + + if (part.type.startsWith("tool-")) { + return ( + + addToolOutput({ tool, toolCallId, output }) + } + /> + ); + } + + if (pending.isInjectionPoint(part)) { + const injectedMsgs = pending.getInjectedMessages(part); + if (injectedMsgs.length === 0) return null; + return ( +
+
+ {injectedMsgs.map((m) => ( +
+ {m.text} +
+ ))} +
+ injected mid-response +
+
+
+ ); + } + + if (part.type.startsWith("data-")) { + return ( +
+ {part.type} +
+                          {JSON.stringify((part as any).data, null, 2)}
+                        
+
+ ); + } + + return null; + })} +
+
+
+ ))} + + {status === "streaming" && messages[messages.length - 1]?.role !== "assistant" && ( +
+
+ Thinking... +
+
+ )} + + {pending.pending.map((msg) => ( +
+
+
+ {msg.text} +
+
+ + {msg.mode === "steering" + ? "Steering — waiting for injection point" + : "Queued for next turn"} + + {msg.mode === "queued" && status === "streaming" && ( + + )} +
+
+
+ ))} +
+ + {error && ( +
+ {error.message} +
+ )} + + {/* Debug panel */} + + +
{ + e.preventDefault(); + if (!input.trim()) return; + if (status !== "streaming") { + turnCounter.current++; + sendTimestamp.current = Date.now(); + } + pending.steer(input); + setInput(""); + }} + className="shrink-0 border-t border-gray-200 bg-white p-4" + > + {isReadOnly && ( +
+ This chat is active in another tab. Messages are read-only. +
+ )} +
+ setInput(e.target.value)} + placeholder={isReadOnly ? "Chat is active in another tab" : "Type a message..."} + disabled={isReadOnly} + className="flex-1 rounded-lg border border-gray-300 px-3 py-2 text-sm outline-none focus:border-blue-500 focus:ring-1 focus:ring-blue-500 disabled:bg-gray-100 disabled:text-gray-400" + /> + + {/* Preload — only visible before the first message lands. After + the user sends, the transport creates the session lazily, so + session becomes truthy and this button hides itself. The + transport tracks an in-flight preload internally; double-clicks + are a no-op. */} + {messages.length === 0 && !session && ( + + )} + {status === "streaming" && ( + + )} + {status === "streaming" && ( + + )} + {/* Undo — server-side `chat.history.slice(0, -2)` via the + `undo` action, optimistically reflected in the local + `useChat` state. Drops the last user / assistant exchange. + Only meaningful when there's at least one full exchange + and the chat isn't currently streaming. */} + {status !== "streaming" && messages.length >= 2 && ( + + )} +
+
+
+ ); +} diff --git a/references/ai-chat/src/lib/chat-tools-schemas.ts b/references/ai-chat/src/lib/chat-tools-schemas.ts new file mode 100644 index 00000000000..2def5f68553 --- /dev/null +++ b/references/ai-chat/src/lib/chat-tools-schemas.ts @@ -0,0 +1,189 @@ +/** + * Schema-only tool definitions — shared between the chat.handover + * route handler and the trigger.dev agent task. + * + * ⚠️ HARD CONSTRAINT — bundle isolation + * + * This file is imported by `app/api/chat/route.ts` (the chat.handover + * POST handler) and runs in the Next.js process. Anything imported + * here lands in the route-handler bundle. + * + * Allowed imports: `ai` (for `tool()`), `zod`, type-only AI SDK + * imports. Nothing else. + * + * DO NOT import from this file: + * - `@e2b/code-interpreter`, `puppeteer`, `playwright`, native bindings + * - `node:child_process`, heavy filesystem ops + * - `@trigger.dev/sdk` runtime (`task`, `schemaTask`, + * `chat.stream.writer`, etc. — pulls in the whole task runtime) + * - `turndown`, image processing libs, anything that pulls weight + * + * Heavy `execute` fns live in `src/trigger/chat-tools.ts` — that file + * imports these schemas and adds executes on top. The agent task + * picks up the executes when it runs; the route handler never sees + * them and never imports their deps. + * + * If you need to add a new tool to the chat.agent's schema-only set, + * declare its description + inputSchema here, then wire its execute + * fn in `src/trigger/chat-tools.ts`. + */ +import { tool } from "ai"; +import type { InferUITools, UIDataTypes, UIMessage } from "ai"; +import { z } from "zod"; + +export const inspectEnvironment = tool({ + description: + "Inspect the current execution environment. Returns runtime info (Node.js/Bun/Deno version), " + + "OS details, CPU architecture, memory usage, environment variables, and platform metadata.", + inputSchema: z.object({}), + // execute → src/trigger/chat-tools.ts +}); + +export const webFetch = tool({ + description: + "Fetch a URL and return the response as text. " + + "Use this to retrieve web pages, APIs, or any HTTP resource.", + inputSchema: z.object({ + url: z.string().url().describe("The URL to fetch"), + }), + // execute → src/trigger/chat-tools.ts (uses turndown) +}); + +export const deepResearch = tool({ + description: + "Research a topic by fetching multiple URLs and synthesizing the results. " + + "Streams progress updates to the chat as it works.", + inputSchema: z.object({ + query: z.string().describe("The research query or topic"), + urls: z.array(z.string().url()).describe("URLs to fetch and analyze"), + }), + // execute → src/trigger/chat-tools.ts (subtask via ai.toolExecute) +}); + +export const posthogQuery = tool({ + description: + "Query PostHog analytics using HogQL. Use this to answer questions about events, " + + "pageviews, user activity, feature flag usage, or any product analytics question. " + + "Write a HogQL query (SQL-like syntax over PostHog events).", + inputSchema: z.object({ + query: z + .string() + .describe( + "HogQL query, e.g. SELECT event, count() FROM events WHERE timestamp > now() - interval 1 day GROUP BY event ORDER BY count() DESC LIMIT 10" + ), + }), + // execute → src/trigger/chat-tools.ts (HTTP to PostHog) +}); + +export const executeCode = tool({ + description: + "Run code in an isolated E2B sandbox (Python by default; other languages supported by E2B). " + + "Use for calculations, data analysis, or transforming tool outputs (e.g. PostHog query results). " + + "The sandbox persists across turns in the same run until the chat idles and suspends.", + inputSchema: z.object({ + code: z.string().describe("Source code to execute in the sandbox"), + language: z + .string() + .optional() + .describe("Language id (e.g. python, javascript). Defaults to python."), + }), + // execute → src/trigger/chat-tools.ts (E2B sandbox — heavy native dep) +}); + +export const sendEmail = tool({ + description: + "Send an email to a recipient. Requires human approval before sending. " + + "Use when the user asks you to send, draft, or compose an email.", + inputSchema: z.object({ + to: z.string().describe("Recipient email address"), + subject: z.string().describe("Email subject line"), + body: z.string().describe("Email body text"), + }), + needsApproval: true, + // execute → src/trigger/chat-tools.ts +}); + +export const askUser = tool({ + description: + "Ask the user a question when you need clarification or input before proceeding. " + + "Present 2-4 options for the user to choose from. Use when uncertain about the user's intent.", + inputSchema: z.object({ + question: z.string().describe("The question to ask the user"), + options: z + .array( + z.object({ + id: z.string().describe("Unique option identifier"), + label: z.string().describe("Short option title"), + description: z.string().optional().describe("Longer explanation"), + }) + ) + .min(2) + .max(4), + }), + // No execute by design — round-tripped through the frontend's addToolOutput. +}); + +export const getCurrentTime = tool({ + description: + "Get the current wall-clock date and time. Returns ISO timestamp, " + + "human-readable strings, and the system timezone. Use when the user " + + "asks 'what time is it', for date math, or to anchor 'recent' / 'today'.", + inputSchema: z.object({}), + // execute → src/trigger/chat-tools.ts +}); + +export const searchHackerNews = tool({ + description: + "Search Hacker News for stories matching a query, or fetch the current top stories. " + + "Returns title, points, comment count, author, posted-at, and URL for up to 10 results. " + + "Use for tech news, trending topics, or 'what's everyone talking about'.", + inputSchema: z.object({ + query: z + .string() + .optional() + .describe( + "Search query. If omitted, returns the current top stories instead of doing a search." + ), + limit: z.number().int().min(1).max(10).optional().describe("Max results (1-10, default 5)"), + }), + // execute → src/trigger/chat-tools.ts +}); + +export const createGithubIssue = tool({ + description: + "Create a GitHub issue tracking action items, bugs, or follow-ups. " + + "Requires human approval before creation. Use when the user asks " + + "to file an issue, track a bug, or open a ticket.", + inputSchema: z.object({ + repo: z + .string() + .describe("Repository in 'owner/name' form (e.g. 'triggerdotdev/trigger.dev')"), + title: z.string().describe("Issue title"), + body: z.string().describe("Issue body in Markdown"), + labels: z.array(z.string()).optional().describe("Labels to apply (e.g. ['bug', 'p1'])"), + }), + needsApproval: true, + // execute → src/trigger/chat-tools.ts +}); + +/** + * The schema-only tool set passed to `chat.headStart`'s `streamText` + * call. The agent task imports each schema individually and adds the + * matching `execute` fn — see `src/trigger/chat-tools.ts`. + */ +export const headStartTools = { + inspectEnvironment, + webFetch, + deepResearch, + posthogQuery, + executeCode, + sendEmail, + askUser, + getCurrentTime, + searchHackerNews, + createGithubIssue, +}; + +type ChatToolSet = typeof headStartTools; +export type ChatUiTools = InferUITools; +export type ChatUiMessage = UIMessage; diff --git a/references/ai-chat/src/lib/chat-tools.ts b/references/ai-chat/src/lib/chat-tools.ts new file mode 100644 index 00000000000..8277ca4d9fa --- /dev/null +++ b/references/ai-chat/src/lib/chat-tools.ts @@ -0,0 +1,337 @@ +import { ai, chat } from "@trigger.dev/sdk/ai"; +import { schemaTask } from "@trigger.dev/sdk"; +import { tool, generateId } from "ai"; +import type { InferUITools, UIDataTypes, UIMessage } from "ai"; +import { z } from "zod"; +import os from "node:os"; +import TurndownService from "turndown"; +import { codeSandboxRun, runWithCodeSandbox } from "@/lib/code-sandbox"; + +const turndown = new TurndownService(); + +// Silence TS errors for Bun/Deno global checks +declare const Bun: unknown; +declare const Deno: unknown; + +export const inspectEnvironment = tool({ + description: + "Inspect the current execution environment. Returns runtime info (Node.js/Bun/Deno version), " + + "OS details, CPU architecture, memory usage, environment variables, and platform metadata.", + inputSchema: z.object({}), + execute: async () => { + const memUsage = process.memoryUsage(); + + return { + runtime: { + name: typeof Bun !== "undefined" ? "bun" : typeof Deno !== "undefined" ? "deno" : "node", + version: process.version, + versions: { + v8: process.versions.v8, + openssl: process.versions.openssl, + modules: process.versions.modules, + }, + }, + os: { + platform: process.platform, + arch: process.arch, + release: os.release(), + type: os.type(), + hostname: os.hostname(), + uptime: `${Math.floor(os.uptime())}s`, + }, + cpus: { + count: os.cpus().length, + model: os.cpus()[0]?.model, + }, + memory: { + total: `${Math.round(os.totalmem() / 1024 / 1024)}MB`, + free: `${Math.round(os.freemem() / 1024 / 1024)}MB`, + process: { + rss: `${Math.round(memUsage.rss / 1024 / 1024)}MB`, + heapUsed: `${Math.round(memUsage.heapUsed / 1024 / 1024)}MB`, + heapTotal: `${Math.round(memUsage.heapTotal / 1024 / 1024)}MB`, + }, + }, + env: { + NODE_ENV: process.env.NODE_ENV, + TZ: process.env.TZ ?? Intl.DateTimeFormat().resolvedOptions().timeZone, + LANG: process.env.LANG, + }, + process: { + pid: process.pid, + cwd: process.cwd(), + execPath: process.execPath, + argv: process.argv.slice(0, 3), + }, + }; + }, +}); + +export const webFetch = tool({ + description: + "Fetch a URL and return the response as text. " + + "Use this to retrieve web pages, APIs, or any HTTP resource.", + inputSchema: z.object({ + url: z.string().url().describe("The URL to fetch"), + }), + execute: async ({ url }) => { + const latency = Number(process.env.WEBFETCH_LATENCY_MS); + if (latency > 0) { + await new Promise((r) => setTimeout(r, latency)); + } + + const response = await fetch(url); + let text = await response.text(); + const contentType = response.headers.get("content-type") ?? ""; + + if (contentType.includes("html")) { + text = turndown.turndown(text); + } + + return { + status: response.status, + contentType, + body: text.slice(0, 2000), + truncated: text.length > 2000, + }; + }, +}); + +const deepResearchTask = schemaTask({ + id: "deep-research", + description: + "Research a topic by fetching multiple URLs and synthesizing the results. " + + "Streams progress updates to the chat as it works.", + schema: z.object({ + query: z.string().describe("The research query or topic"), + urls: z.array(z.string().url()).describe("URLs to fetch and analyze"), + }), + run: async ({ query, urls }) => { + const partId = generateId(); + const results: { url: string; status: number; snippet: string }[] = []; + + for (let i = 0; i < urls.length; i++) { + const url = urls[i]!; + + const { waitUntilComplete } = chat.stream.writer({ + target: "root", + execute: ({ write }) => { + write({ + type: "data-research-progress", + id: partId, + data: { + status: "fetching" as const, + query, + current: i + 1, + total: urls.length, + currentUrl: url, + completedUrls: results.map((r) => r.url), + }, + }); + }, + }); + await waitUntilComplete(); + + try { + const response = await fetch(url); + let text = await response.text(); + const contentType = response.headers.get("content-type") ?? ""; + + if (contentType.includes("html")) { + text = turndown.turndown(text); + } + + results.push({ + url, + status: response.status, + snippet: text.slice(0, 500), + }); + } catch (err) { + results.push({ + url, + status: 0, + snippet: `Error: ${err instanceof Error ? err.message : String(err)}`, + }); + } + } + + const { waitUntilComplete: waitForDone } = chat.stream.writer({ + target: "root", + execute: ({ write }) => { + write({ + type: "data-research-progress", + id: partId, + data: { + status: "done" as const, + query, + current: urls.length, + total: urls.length, + completedUrls: results.map((r) => r.url), + }, + }); + }, + }); + await waitForDone(); + + return { query, results }; + }, +}); + +/** Task-backed tool: AI SDK `tool()` for shape/types; `ai.toolExecute` for Trigger subtask + metadata. */ +export const deepResearch = tool({ + description: deepResearchTask.description ?? "", + inputSchema: deepResearchTask.schema!, + execute: ai.toolExecute(deepResearchTask), +}); + +const POSTHOG_API_KEY = process.env.POSTHOG_API_KEY; +const POSTHOG_PROJECT_ID = process.env.POSTHOG_PROJECT_ID; +const POSTHOG_HOST = process.env.POSTHOG_HOST ?? "https://eu.posthog.com"; + +export const posthogQuery = tool({ + description: + "Query PostHog analytics using HogQL. Use this to answer questions about events, " + + "pageviews, user activity, feature flag usage, or any product analytics question. " + + "Write a HogQL query (SQL-like syntax over PostHog events).", + inputSchema: z.object({ + query: z + .string() + .describe( + "HogQL query, e.g. SELECT event, count() FROM events WHERE timestamp > now() - interval 1 day GROUP BY event ORDER BY count() DESC LIMIT 10" + ), + }), + execute: async ({ query }) => { + if (!POSTHOG_API_KEY || !POSTHOG_PROJECT_ID) { + return { error: "PostHog not configured. Set POSTHOG_API_KEY and POSTHOG_PROJECT_ID." }; + } + const response = await fetch(`${POSTHOG_HOST}/api/projects/${POSTHOG_PROJECT_ID}/query/`, { + method: "POST", + headers: { + Authorization: `Bearer ${POSTHOG_API_KEY}`, + "Content-Type": "application/json", + }, + body: JSON.stringify({ query: { kind: "HogQLQuery", query } }), + }); + + if (!response.ok) { + const text = await response.text(); + return { error: `PostHog API error ${response.status}: ${text.slice(0, 500)}` }; + } + + const data = await response.json(); + return { + columns: data.columns, + results: data.results?.slice(0, 50), + rowCount: data.results?.length ?? 0, + }; + }, +}); + +export const executeCode = tool({ + description: + "Run code in an isolated E2B sandbox (Python by default; other languages supported by E2B). " + + "Use for calculations, data analysis, or transforming tool outputs (e.g. PostHog query results). " + + "The sandbox persists across turns in the same run until the chat idles and suspends.", + inputSchema: z.object({ + code: z.string().describe("Source code to execute in the sandbox"), + language: z + .string() + .optional() + .describe("Language id (e.g. python, javascript). Defaults to python."), + }), + execute: async function executeCodeExecute({ code, language }) { + const runId = codeSandboxRun.runId; + if (!runId?.trim()) { + return { + error: + "Code sandbox run id is not set yet (call from the chat task after onTurnStart), or this tool is not wired to that task.", + }; + } + + const out = await runWithCodeSandbox(runId, async function runInSandbox(sandbox) { + const execution = await sandbox.runCode(code, { + ...(language?.trim() ? { language: language.trim() } : {}), + timeoutMs: 60_000, + }); + + if (execution.error) { + return { + error: `${execution.error.name}: ${execution.error.value}`, + traceback: execution.error.traceback, + stdout: execution.logs.stdout.join("\n"), + stderr: execution.logs.stderr.join("\n"), + }; + } + + const mainText = execution.text; + const resultSnippets = execution.results + .map(function mapResult(r) { + return r.text ?? r.markdown ?? r.json; + }) + .filter(Boolean) + .slice(0, 5); + + return { + text: mainText, + results: resultSnippets, + stdout: execution.logs.stdout.join("\n"), + stderr: execution.logs.stderr.join("\n"), + }; + }); + + return out; + }, +}); + +export const sendEmail = tool({ + description: + "Send an email to a recipient. Requires human approval before sending. " + + "Use when the user asks you to send, draft, or compose an email.", + inputSchema: z.object({ + to: z.string().describe("Recipient email address"), + subject: z.string().describe("Email subject line"), + body: z.string().describe("Email body text"), + }), + needsApproval: true, + execute: async ({ to, subject, body }) => { + // Simulated — in a real app this would call an email API + return { sent: true, to, subject, preview: body.slice(0, 100) }; + }, +}); + +export const askUser = tool({ + description: + "Ask the user a question when you need clarification or input before proceeding. " + + "Present 2-4 options for the user to choose from. Use when uncertain about the user's intent.", + inputSchema: z.object({ + question: z.string().describe("The question to ask the user"), + options: z + .array( + z.object({ + id: z.string().describe("Unique option identifier"), + label: z.string().describe("Short option title"), + description: z.string().optional().describe("Longer explanation"), + }) + ) + .min(2) + .max(4), + }), + // No execute function — streamText ends, turn completes, + // frontend sends the answer via addToolOutput +}); + +/** Tool set passed to `streamText` for the main `chat.agent` run (includes PostHog). */ +export const chatTools = { + inspectEnvironment, + webFetch, + deepResearch, + posthogQuery, + executeCode, + sendEmail, + askUser, +}; + +type ChatToolSet = typeof chatTools; + +export type ChatUiTools = InferUITools; +export type ChatUiMessage = UIMessage; diff --git a/references/ai-chat/src/lib/code-sandbox.ts b/references/ai-chat/src/lib/code-sandbox.ts new file mode 100644 index 00000000000..5a3e48cd6df --- /dev/null +++ b/references/ai-chat/src/lib/code-sandbox.ts @@ -0,0 +1,57 @@ +/** + * E2B sandboxes keyed by Trigger run id. + * + * - Warmed from `chat.agent` `onTurnStart` (non-blocking) so the first `executeCode` tool call is faster. + * - Disposed in `onChatSuspend` before the run suspends waiting for the next message. + * - `onComplete` disposes any leftover sandbox if the run ends without hitting another suspend. + * + * No extra SDK hook is required beyond `onChatSuspend` and `onComplete`. + */ +import { chat } from "@trigger.dev/sdk/ai"; +import { Sandbox } from "@e2b/code-interpreter"; + +const sandboxPromises = new Map>(); + +/** Run id for the active chat turn — set from `onTurnStart` so tools can key the sandbox without `taskContext`. */ +export const codeSandboxRun = chat.local<{ runId: string }>({ id: "codeSandboxRun" }); + +export function warmCodeSandbox(runId: string): void { + codeSandboxRun.init({ runId }); + if (!process.env.E2B_API_KEY?.trim()) return; + if (sandboxPromises.has(runId)) return; + sandboxPromises.set(runId, Sandbox.create()); +} + +export async function runWithCodeSandbox( + runId: string, + runner: (sandbox: Sandbox) => Promise +): Promise { + if (!process.env.E2B_API_KEY?.trim()) { + return { error: "Code sandbox not configured. Set E2B_API_KEY in the Trigger environment." }; + } + + let promise = sandboxPromises.get(runId); + if (!promise) { + promise = Sandbox.create(); + sandboxPromises.set(runId, promise); + } + + try { + const sandbox = await promise; + return await runner(sandbox); + } catch (err) { + return { error: err instanceof Error ? err.message : String(err) }; + } +} + +export async function disposeCodeSandboxForRun(runId: string): Promise { + const promise = sandboxPromises.get(runId); + if (!promise) return; + sandboxPromises.delete(runId); + try { + const sandbox = await promise; + await sandbox.kill(); + } catch { + /* best-effort cleanup */ + } +} diff --git a/references/ai-chat/src/lib/models.ts b/references/ai-chat/src/lib/models.ts new file mode 100644 index 00000000000..77c2ea1621d --- /dev/null +++ b/references/ai-chat/src/lib/models.ts @@ -0,0 +1,10 @@ +export const MODEL_OPTIONS = [ + "gpt-4o-mini", + "gpt-4o", + "claude-sonnet-4-6", + "claude-opus-4-6", +]; + +export const DEFAULT_MODEL = "claude-sonnet-4-6"; + +export const REASONING_MODELS = new Set(["claude-opus-4-6"]); diff --git a/references/ai-chat/src/lib/pr-review-helpers.ts b/references/ai-chat/src/lib/pr-review-helpers.ts new file mode 100644 index 00000000000..1bfb082f13d --- /dev/null +++ b/references/ai-chat/src/lib/pr-review-helpers.ts @@ -0,0 +1,81 @@ +import { execFile } from "node:child_process"; +import { promisify } from "node:util"; +import { rm } from "node:fs/promises"; +import { logger } from "@trigger.dev/sdk"; + +const execFileAsync = promisify(execFile); + +// #region git helper +export async function git(cwd: string, ...args: string[]): Promise { + const { stdout } = await execFileAsync("git", args, { + cwd, + maxBuffer: 10 * 1024 * 1024, // 10MB for large diffs + timeout: 30_000, + }); + return stdout.trim(); +} +// #endregion + +// #region GitHub API helper +export async function githubApi(path: string, token?: string | null): Promise { + const res = await fetch(`https://api.github.com${path}`, { + headers: { + Accept: "application/vnd.github.v3+json", + ...(token ? { Authorization: `Bearer ${token}` } : {}), + }, + }); + if (!res.ok) { + const text = await res.text(); + throw new Error(`GitHub API ${res.status}: ${text.slice(0, 500)}`); + } + return res.json() as Promise; +} +// #endregion + +// #region URL parser +export function parseGitHubUrl(url: string): { owner: string; repo: string } { + const match = url.match(/github\.com\/([^/]+)\/([^/]+)/); + if (!match) throw new Error(`Invalid GitHub URL: ${url}`); + return { owner: match[1]!, repo: match[2]!.replace(/\.git$/, "") }; +} +// #endregion + +// #region Clone repo +export async function cloneRepo({ + owner, + repo, + clonePath, + token, +}: { + owner: string; + repo: string; + clonePath: string; + token?: string | null; +}): Promise { + async function runClone(): Promise { + const cloneUrl = token + ? `https://x-access-token:${token}@github.com/${owner}/${repo}.git` + : `https://github.com/${owner}/${repo}.git`; + + await execFileAsync("git", ["clone", "--depth=1", cloneUrl, clonePath], { + timeout: 60_000, + }); + } + + await logger.trace("cloneRepo", runClone, { + icon: "tabler-brand-github", + }); +} +// #endregion + +// #region Cleanup +export async function cleanupClone(clonePath: string | undefined): Promise { + if (!clonePath) return; + try { + await rm(clonePath, { recursive: true, force: true }); + logger.info("Cleaned up clone directory", { clonePath }); + } catch { + /* best-effort */ + } +} +// #endregion diff --git a/references/ai-chat/src/lib/pr-review-tools.ts b/references/ai-chat/src/lib/pr-review-tools.ts new file mode 100644 index 00000000000..31efe335588 --- /dev/null +++ b/references/ai-chat/src/lib/pr-review-tools.ts @@ -0,0 +1,191 @@ +import { chat } from "@trigger.dev/sdk/ai"; +import { logger } from "@trigger.dev/sdk"; +import { tool } from "ai"; +import type { InferUITools, UIDataTypes, UIMessage } from "ai"; +import { z } from "zod"; +import { resolve } from "node:path"; +import { readFile as fsReadFile } from "node:fs/promises"; +import { git, githubApi } from "@/lib/pr-review-helpers"; + +// #region Repo context — shared across tools, survives snapshot/restore +export const repo = chat.local<{ + cwd: string; + owner: string; + repo: string; + githubToken: string | null; + openPRs: Array<{ + number: number; + title: string; + author: string; + headBranch: string; + }>; + activePR: { number: number; headBranch: string } | null; +}>({ id: "repo" }); +// #endregion + +// #region Tool: Fetch PR +export const fetchPR = tool({ + description: + "Fetch a pull request by number. Checks out the PR branch in the local clone " + + "and returns metadata, the diff against the base branch, and the list of changed files. " + + "Always call this before reviewing a PR.", + inputSchema: z.object({ + prNumber: z.number().describe("The PR number to fetch"), + }), + execute: async ({ prNumber }) => { + const { cwd, owner, repo: repoName, githubToken } = repo; + + logger.info("fetchPR: fetching metadata", { owner, repo: repoName, prNumber }); + + // 1. Fetch PR metadata from GitHub API + const pr = await githubApi<{ + title: string; + body: string | null; + head: { ref: string; sha: string }; + base: { ref: string }; + user: { login: string }; + additions: number; + deletions: number; + changed_files: number; + }>(`/repos/${owner}/${repoName}/pulls/${prNumber}`, githubToken); + + logger.info("fetchPR: got PR metadata", { + title: pr.title, + head: pr.head.ref, + base: pr.base.ref, + author: pr.user.login, + }); + + // 2. Fetch the PR branch and check it out (must happen before fetching + // the base branch, since base is currently checked out after clone) + logger.info("fetchPR: fetching head branch", { branch: pr.head.ref, cwd }); + await git(cwd, "fetch", "origin", `${pr.head.ref}:${pr.head.ref}`); + + logger.info("fetchPR: checking out head branch", { branch: pr.head.ref }); + await git(cwd, "checkout", pr.head.ref); + + // 3. Now fetch the base branch (safe because we're no longer on it) + logger.info("fetchPR: fetching base branch", { branch: pr.base.ref }); + await git(cwd, "fetch", "origin", `${pr.base.ref}:${pr.base.ref}`); + + // 4. Get the diff + logger.info("fetchPR: computing diff", { range: `${pr.base.ref}...${pr.head.ref}` }); + const diff = await git( + cwd, + "diff", + `${pr.base.ref}...${pr.head.ref}` + ); + + // 5. Get changed files list + const filesRaw = await git( + cwd, + "diff", + "--name-status", + `${pr.base.ref}...${pr.head.ref}` + ); + const changedFiles = filesRaw + .split("\n") + .filter(Boolean) + .map((line) => { + const [status, ...pathParts] = line.split("\t"); + return { status: status!, path: pathParts.join("\t") }; + }); + + // 6. Update active PR state + repo.activePR = { number: prNumber, headBranch: pr.head.ref }; + + logger.info("fetchPR: done", { + changedFileCount: changedFiles.length, + diffLength: diff.length, + diffTruncated: diff.length > 50_000, + }); + + // 7. Truncate diff if too large + const maxDiffLength = 50_000; + const truncated = diff.length > maxDiffLength; + + return { + number: prNumber, + title: pr.title, + body: pr.body?.slice(0, 2000) ?? "(no description)", + author: pr.user.login, + headBranch: pr.head.ref, + baseBranch: pr.base.ref, + additions: pr.additions, + deletions: pr.deletions, + changedFileCount: pr.changed_files, + changedFiles, + diff: truncated ? diff.slice(0, maxDiffLength) : diff, + diffTruncated: truncated, + }; + }, +}); +// #endregion + +// #region Tool: Read File +export const readFile = tool({ + description: + "Read a file from the cloned repository. Use this to see full file context " + + "beyond what the diff shows — essential for understanding surrounding code, " + + "imports, type definitions, and related functions.", + inputSchema: z.object({ + path: z.string().describe("File path relative to the repo root"), + startLine: z + .number() + .optional() + .describe("Start reading from this line (1-indexed)"), + endLine: z + .number() + .optional() + .describe("Stop reading at this line (inclusive)"), + }), + execute: async ({ path: filePath, startLine, endLine }) => { + const { cwd } = repo; + const fullPath = `${cwd}/${filePath}`; + + // Security: ensure path doesn't escape the clone directory + const resolved = resolve(fullPath); + if (!resolved.startsWith(resolve(cwd))) { + return { error: "Path traversal not allowed" }; + } + + try { + const content = await fsReadFile(resolved, "utf-8"); + const lines = content.split("\n"); + + if (startLine || endLine) { + const start = (startLine ?? 1) - 1; + const end = endLine ?? lines.length; + const slice = lines.slice(start, end); + return { + path: filePath, + startLine: start + 1, + endLine: Math.min(end, lines.length), + totalLines: lines.length, + content: slice.join("\n"), + }; + } + + const maxLines = 500; + return { + path: filePath, + totalLines: lines.length, + content: lines.slice(0, maxLines).join("\n"), + truncated: lines.length > maxLines, + }; + } catch (err) { + return { + error: `Failed to read ${filePath}: ${err instanceof Error ? err.message : String(err)}`, + }; + } + }, +}); +// #endregion + +// #region Exports +export const prReviewTools = { fetchPR, readFile }; + +type PRReviewToolSet = typeof prReviewTools; +export type PRReviewUiTools = InferUITools; +export type PRReviewUiMessage = UIMessage; +// #endregion diff --git a/references/ai-chat/src/lib/prisma.ts b/references/ai-chat/src/lib/prisma.ts new file mode 100644 index 00000000000..5e78334aa82 --- /dev/null +++ b/references/ai-chat/src/lib/prisma.ts @@ -0,0 +1,15 @@ +import { PrismaPg } from "@prisma/adapter-pg"; +import { PrismaClient } from "../../lib/generated/prisma/client"; + +const globalForPrisma = globalThis as unknown as { prisma: PrismaClient | undefined }; + +function createClient() { + const adapter = new PrismaPg({ connectionString: process.env.DATABASE_URL! }); + return new PrismaClient({ adapter }); +} + +export const prisma = globalForPrisma.prisma ?? createClient(); + +if (process.env.NODE_ENV !== "production") { + globalForPrisma.prisma = prisma; +} diff --git a/references/ai-chat/src/trigger/chat-client-test.ts b/references/ai-chat/src/trigger/chat-client-test.ts new file mode 100644 index 00000000000..742246eca2a --- /dev/null +++ b/references/ai-chat/src/trigger/chat-client-test.ts @@ -0,0 +1,449 @@ +/** + * Test tasks demonstrating the AgentChat and ChatStream APIs + * for server-side agent interaction. + */ +import { task, logger } from "@trigger.dev/sdk"; +import { chat } from "@trigger.dev/sdk/ai"; +import { AgentChat, ChatStream } from "@trigger.dev/sdk/chat"; +import type { aiChat, upgradeTestAgent } from "./chat"; +import type { prReviewChat } from "./pr-review"; + +// ─── Example 1: Simple multi-turn conversation ───────────────────── + +export const chatClientTest = task({ + id: "chat-client-test", + run: async (payload: { message: string; followUp?: string }) => { + const chat = new AgentChat({ + agent: "ai-chat", + clientData: { userId: "chat-client-test", model: "gpt-4o-mini" }, + }); + + await chat.preload(); + + // Send and get text back + const text = await (await chat.sendMessage(payload.message)).text(); + logger.info("Response", { preview: text.slice(0, 200) }); + + // Follow-up reuses the same run + if (payload.followUp) { + const { text: followUp, toolCalls } = await (await chat.sendMessage(payload.followUp)).result(); + logger.info("Follow-up", { + preview: followUp.slice(0, 200), + toolCalls: toolCalls.map((tc) => tc.toolName), + }); + } + + await chat.close(); + return { chatId: chat.id, text: text.slice(0, 500) }; + }, +}); + +// ─── Example 2: Streaming chunks ─────────────────────────────────── + +export const streamingTest = task({ + id: "chat-client-streaming-test", + run: async (payload: { message: string }) => { + const chat = new AgentChat({ + agent: "ai-chat", + clientData: { userId: "streaming-test", model: "gpt-4o-mini" }, + }); + + await chat.preload(); + + const stream = await chat.sendMessage(payload.message); + + let charCount = 0; + const toolsUsed: string[] = []; + + for await (const chunk of stream) { + if (chunk.type === "text-delta") { + charCount += chunk.delta.length; + } + if (chunk.type === "tool-input-available") { + toolsUsed.push(chunk.toolName); + logger.info("Agent using tool", { tool: chunk.toolName, input: chunk.input }); + } + if (chunk.type === "tool-output-available") { + logger.info("Tool output", { toolCallId: chunk.toolCallId }); + } + } + + await chat.close(); + return { charCount, toolsUsed }; + }, +}); + +// ─── Example 3: PR review agent (typed clientData) ───────────────── + +export const prReviewTest = task({ + id: "chat-client-pr-review-test", + run: async (payload: { prNumber: number }) => { + const chat = new AgentChat({ + agent: "pr-review", + id: `pr-review-${payload.prNumber}`, + clientData: { + userId: "ci-bot", + githubUrl: "https://github.com/ericallam/definitely-safe-ai", + }, + }); + + await chat.preload(); + + const review = await (await chat.sendMessage(`Review PR #${payload.prNumber}`)).result(); + logger.info("Review complete", { + textLength: review.text.length, + toolCalls: review.toolCalls.map((tc) => `${tc.toolName}(${JSON.stringify(tc.input)})`), + }); + + const fix = await ( + await chat.sendMessage("Can you suggest a fix for the most critical issue and verify it works?") + ).result(); + + await chat.close(); + + return { + reviewPreview: review.text.slice(0, 500), + fixPreview: fix.text.slice(0, 500), + toolsUsed: [ + ...review.toolCalls.map((tc) => tc.toolName), + ...fix.toolCalls.map((tc) => tc.toolName), + ], + }; + }, +}); + +// ─── Example 4: Low-level sendRaw + ChatStream ───────────────────── + +export const lowLevelTest = task({ + id: "chat-client-low-level-test", + run: async (payload: { message: string }) => { + const chat = new AgentChat({ + agent: "ai-chat", + clientData: { userId: "low-level-test", model: "gpt-4o-mini" }, + }); + + await chat.preload(); + + // sendRaw for full control over the UIMessage shape + const rawStream = await chat.sendRaw([ + { + id: `msg-${Date.now()}`, + role: "user", + parts: [{ type: "text", text: payload.message }], + }, + ]); + + const stream = new ChatStream(rawStream); + const { text, toolCalls } = await stream.result(); + + await chat.close(); + return { text: text.slice(0, 500), toolCalls: toolCalls.map((tc) => tc.toolName) }; + }, +}); + +// ─── Example 5: Agent-to-agent orchestration ─────────────────────── + +export const orchestratorTest = task({ + id: "chat-client-orchestrator-test", + run: async (payload: { topic: string }) => { + const researcher = new AgentChat({ + agent: "ai-chat", + clientData: { userId: "orchestrator", model: "gpt-4o-mini" }, + }); + + await researcher.preload(); + + const research = await ( + await researcher.sendMessage(`Research this topic and summarize key findings: ${payload.topic}`) + ).text(); + + const analysis = await ( + await researcher.sendMessage( + "Based on your research, what are the top 3 actionable recommendations?" + ) + ).text(); + + await researcher.close(); + + return { + research: research.slice(0, 500), + analysis: analysis.slice(0, 500), + }; + }, +}); + +// ─── Example 6: Single-turn sub-agent tool ───────────────────────── + +import { tool as aiTool, streamText, stepCountIs } from "ai"; +import { anthropic } from "@ai-sdk/anthropic"; +import { z } from "zod"; + +const prReviewTool = aiTool({ + description: "Delegate a PR review to the PR review agent.", + inputSchema: z.object({ + prNumber: z.number().describe("The PR number to review"), + repo: z.string().describe("The GitHub repo URL"), + }), + execute: async function* ({ prNumber, repo }, { abortSignal }) { + const chat = new AgentChat({ + agent: "pr-review", + id: `sub-review-${prNumber}`, + clientData: { userId: "parent-agent", githubUrl: repo }, + }); + + await chat.preload(); + const stream = await chat.sendMessage(`Review PR #${prNumber}`, { abortSignal }); + yield* stream.messages(); + await chat.close(); + }, + toModelOutput: ({ output: message }) => { + const lastText = message?.parts?.findLast( + (p: { type: string }) => p.type === "text" + ) as { text?: string } | undefined; + return { type: "text" as const, value: lastText?.text ?? "Review complete." }; + }, +}); + +// ─── Example 7: Multi-turn sub-agent (LLM-driven, cross-turn) ────── + +export const orchestratorAgent = chat + .withClientData({ + schema: z.object({ userId: z.string() }), + }) + .customAgent({ + id: "orchestrator-agent", + run: async (payload, { signal: runSignal }) => { + let currentPayload: typeof payload = payload; + + // Sub-agent instances live in the run closure — survive across turns + const subAgents = new Map>(); + + const researchAgentTool = aiTool({ + description: + "Talk to a research agent. Use the same conversationId to continue " + + "an existing conversation — the agent remembers full context.", + inputSchema: z.object({ + conversationId: z.string().describe("Reuse to continue a conversation."), + message: z.string().describe("Your message to the research agent"), + }), + execute: async function* ({ conversationId, message }, { abortSignal }) { + let agent = subAgents.get(conversationId); + if (!agent) { + agent = new AgentChat({ + agent: "ai-chat", + id: conversationId, + clientData: { + userId: currentPayload.metadata?.userId ?? "orchestrator", + model: "gpt-4o-mini", + }, + }); + await agent.preload(); + subAgents.set(conversationId, agent); + } + + const stream = await agent.sendMessage(message, { abortSignal }); + yield* stream.messages(); + }, + toModelOutput: ({ output: message }) => { + const lastText = message?.parts?.findLast( + (p: { type: string }) => p.type === "text" + ) as { text?: string } | undefined; + return { type: "text" as const, value: lastText?.text ?? "Research complete." }; + }, + }); + + // Preload handling + if (currentPayload.trigger === "preload") { + const result = await chat.messages.waitWithIdleTimeout({ + idleTimeoutInSeconds: 120, + timeout: "1h", + spanName: "waiting for first message", + }); + if (!result.ok) return; + currentPayload = result.output as typeof payload; + } + + if (currentPayload.trigger === "close") return; + + const stop = chat.createStopSignal(); + const conversation = new chat.MessageAccumulator(); + + for (let turn = 0; turn < 50; turn++) { + stop.reset(); + + const messages = await conversation.addIncoming( + currentPayload.messages, + currentPayload.trigger, + turn + ); + + const combinedSignal = AbortSignal.any([runSignal, stop.signal]); + + const result = streamText({ + model: anthropic("claude-sonnet-4-6"), + system: + "You are an orchestrator that delegates research to a sub-agent. " + + "Use the researchAgent tool with a conversationId to start or continue " + + "a research thread.", + messages, + tools: { researchAgent: researchAgentTool }, + stopWhen: stepCountIs(15), + abortSignal: combinedSignal, + }); + + let response; + try { + response = await chat.pipeAndCapture(result, { signal: combinedSignal }); + } catch (error) { + if (error instanceof Error && error.name === "AbortError") { + if (runSignal.aborted) break; + } else { + throw error; + } + } + + if (response) { + if (stop.signal.aborted && !runSignal.aborted) { + await conversation.addResponse(chat.cleanupAbortedParts(response)); + } else { + await conversation.addResponse(response); + } + } + + if (runSignal.aborted) break; + + await chat.writeTurnComplete(); + + const next = await chat.messages.waitWithIdleTimeout({ + idleTimeoutInSeconds: 120, + timeout: "1h", + spanName: "waiting for next message", + }); + if (!next.ok) break; + currentPayload = next.output as typeof payload; + if (currentPayload.trigger === "close") break; + } + + // Cleanup sub-agents + const closePromises = Array.from(subAgents.values()).map((a) => + a.close().catch(() => { }) + ); + await Promise.all(closePromises); + + stop.cleanup(); + }, + }); + +// ─── Example 8: chat.requestUpgrade() test ──────────────────────── + +export const upgradeTest = task({ + id: "chat-client-upgrade-test", + run: async () => { + const agentChat = new AgentChat({ + agent: "upgrade-test", + }); + + const results: { turn: number; text: string; runId?: string }[] = []; + + // Send 6 messages — the agent requests an upgrade after turn 3 (0-indexed), + // so the run exits after the 4th response. The 5th message triggers a + // continuation on a new run, and the 6th message continues on that run. + for (let i = 0; i < 6; i++) { + const stream = await agentChat.sendMessage(`This is message ${i + 1}. What turn are you on?`); + const text = await stream.text(); + + // If we get an empty response, the run just exited — wait a moment + // for it to fully complete, then retry (triggers continuation) + if (text === "" && i > 0) { + logger.info(`Turn ${i}: empty response, retrying after run completes`); + await new Promise((r) => setTimeout(r, 2000)); + const retryStream = await agentChat.sendMessage( + `This is message ${i + 1} (retry). What turn are you on?` + ); + const retryText = await retryStream.text(); + results.push({ + turn: i, + text: retryText.slice(0, 200), + runId: agentChat.id, + }); + logger.info(`Turn ${i} (retry)`, { + text: retryText.slice(0, 200), + runId: agentChat.id, + }); + continue; + } + + results.push({ + turn: i, + text: text.slice(0, 200), + runId: agentChat.id, + }); + logger.info(`Turn ${i}`, { text: text.slice(0, 200), runId: agentChat.id }); + } + + await agentChat.close(); + + // Check that a continuation happened — runId should change + const runIds = [...new Set(results.map((r) => r.runId))]; + logger.info("Upgrade test complete", { + totalTurns: results.length, + uniqueRuns: runIds.length, + runIds, + }); + + return { + turns: results, + uniqueRuns: runIds.length, + upgraded: runIds.length > 1, + }; + }, +}); + +// ─── Example 9: Quick-fire burst test ────────────────────────────── +// +// Fire N messages back-to-back without awaiting between sends. The agent's +// session.in queues records in arrival order; the per-turn loop processes +// the first one, and the rest land as pendingMessages mid-stream (or queue +// up for the next turn). Validates: (1) no records dropped at the dedup +// cutoff, (2) ordering preserved, (3) no race between snapshot.write of +// turn N and boot of turn N+1, (4) all responses eventually arrive. + +export const burstTest = task({ + id: "chat-client-burst-test", + run: async (payload: { count?: number }) => { + const count = payload.count ?? 5; + const agentChat = new AgentChat({ + agent: "ai-chat", + clientData: { userId: "burst-test", model: "gpt-4o-mini" }, + }); + await agentChat.preload(); + + const start = Date.now(); + + // Fire all N concurrently. Each call POSTs to /in/append immediately; + // the agent dequeues in arrival order and processes sequentially. + const sends = Array.from({ length: count }, (_, i) => + agentChat + .sendMessage(`Reply with the single word: msg-${i + 1}.`) + .then((stream) => stream.text()) + .then((text) => ({ idx: i + 1, text: text.slice(0, 80), error: null as string | null })) + .catch((err) => ({ idx: i + 1, text: "", error: String(err?.message || err) })) + ); + + const results = await Promise.all(sends); + const elapsedMs = Date.now() - start; + + await agentChat.close(); + + return { + chatId: agentChat.id, + count, + elapsedMs, + results, + anyErrors: results.filter((r) => r.error).length, + orderedTextsContainingIndex: results.map((r) => + r.text.toLowerCase().includes(`msg-${r.idx}`) ? "ok" : "miss" + ), + }; + }, +}); diff --git a/references/ai-chat/src/trigger/chat-tools.ts b/references/ai-chat/src/trigger/chat-tools.ts new file mode 100644 index 00000000000..dfb7e89e97e --- /dev/null +++ b/references/ai-chat/src/trigger/chat-tools.ts @@ -0,0 +1,402 @@ +/** + * Tool executes for the trigger.dev agent task. + * + * These tools wrap the schema-only definitions from + * `@/lib/chat-tools-schemas` with their heavy `execute` fns. This + * file is ONLY imported from inside the trigger task module + * (`src/trigger/chat.ts`); it must NOT be imported from anything that + * runs in the Next.js process (route handlers, components, server + * actions, etc.). + * + * See `src/lib/chat-tools-schemas.ts` for why this split matters — + * the bundle-isolation constraint is what makes `chat.handover`'s + * cold-start win possible. + */ +import { ai, chat } from "@trigger.dev/sdk/ai"; +import { schemaTask } from "@trigger.dev/sdk"; +import { tool, generateId } from "ai"; +import { z } from "zod"; +import os from "node:os"; +import TurndownService from "turndown"; +import { codeSandboxRun, runWithCodeSandbox } from "@/lib/code-sandbox"; +import { + inspectEnvironment as inspectEnvironmentSchema, + webFetch as webFetchSchema, + deepResearch as deepResearchSchema, + posthogQuery as posthogQuerySchema, + executeCode as executeCodeSchema, + sendEmail as sendEmailSchema, + askUser as askUserSchema, + getCurrentTime as getCurrentTimeSchema, + searchHackerNews as searchHackerNewsSchema, + createGithubIssue as createGithubIssueSchema, +} from "@/lib/chat-tools-schemas"; + +const turndown = new TurndownService(); + +declare const Bun: unknown; +declare const Deno: unknown; + +export const inspectEnvironment = tool({ + ...inspectEnvironmentSchema, + execute: async () => { + const memUsage = process.memoryUsage(); + return { + runtime: { + name: typeof Bun !== "undefined" ? "bun" : typeof Deno !== "undefined" ? "deno" : "node", + version: process.version, + versions: { + v8: process.versions.v8, + openssl: process.versions.openssl, + modules: process.versions.modules, + }, + }, + os: { + platform: process.platform, + arch: process.arch, + release: os.release(), + type: os.type(), + hostname: os.hostname(), + uptime: `${Math.floor(os.uptime())}s`, + }, + cpus: { + count: os.cpus().length, + model: os.cpus()[0]?.model, + }, + memory: { + total: `${Math.round(os.totalmem() / 1024 / 1024)}MB`, + free: `${Math.round(os.freemem() / 1024 / 1024)}MB`, + process: { + rss: `${Math.round(memUsage.rss / 1024 / 1024)}MB`, + heapUsed: `${Math.round(memUsage.heapUsed / 1024 / 1024)}MB`, + heapTotal: `${Math.round(memUsage.heapTotal / 1024 / 1024)}MB`, + }, + }, + env: { + NODE_ENV: process.env.NODE_ENV, + TZ: process.env.TZ ?? Intl.DateTimeFormat().resolvedOptions().timeZone, + LANG: process.env.LANG, + }, + process: { + pid: process.pid, + cwd: process.cwd(), + execPath: process.execPath, + argv: process.argv.slice(0, 3), + }, + }; + }, +}); + +export const webFetch = tool({ + ...webFetchSchema, + execute: async ({ url }) => { + const latency = Number(process.env.WEBFETCH_LATENCY_MS); + if (latency > 0) { + await new Promise((r) => setTimeout(r, latency)); + } + + const response = await fetch(url); + let text = await response.text(); + const contentType = response.headers.get("content-type") ?? ""; + + if (contentType.includes("html")) { + text = turndown.turndown(text); + } + + return { + status: response.status, + contentType, + body: text.slice(0, 2000), + truncated: text.length > 2000, + }; + }, +}); + +const deepResearchTask = schemaTask({ + id: "deep-research", + description: + "Research a topic by fetching multiple URLs and synthesizing the results. " + + "Streams progress updates to the chat as it works.", + schema: z.object({ + query: z.string().describe("The research query or topic"), + urls: z.array(z.string().url()).describe("URLs to fetch and analyze"), + }), + run: async ({ query, urls }) => { + const partId = generateId(); + const results: { url: string; status: number; snippet: string }[] = []; + + for (let i = 0; i < urls.length; i++) { + const url = urls[i]!; + + const { waitUntilComplete } = chat.stream.writer({ + target: "root", + execute: ({ write }) => { + write({ + type: "data-research-progress", + id: partId, + data: { + status: "fetching" as const, + query, + current: i + 1, + total: urls.length, + currentUrl: url, + completedUrls: results.map((r) => r.url), + }, + }); + }, + }); + await waitUntilComplete(); + + try { + const response = await fetch(url); + let text = await response.text(); + const contentType = response.headers.get("content-type") ?? ""; + + if (contentType.includes("html")) { + text = turndown.turndown(text); + } + + results.push({ + url, + status: response.status, + snippet: text.slice(0, 500), + }); + } catch (err) { + results.push({ + url, + status: 0, + snippet: `Error: ${err instanceof Error ? err.message : String(err)}`, + }); + } + } + + const { waitUntilComplete: waitForDone } = chat.stream.writer({ + target: "root", + execute: ({ write }) => { + write({ + type: "data-research-progress", + id: partId, + data: { + status: "done" as const, + query, + current: urls.length, + total: urls.length, + completedUrls: results.map((r) => r.url), + }, + }); + }, + }); + await waitForDone(); + + return { query, results }; + }, +}); + +/** Task-backed tool: AI SDK `tool()` for shape/types; `ai.toolExecute` for Trigger subtask + metadata. */ +export const deepResearch = tool({ + ...deepResearchSchema, + execute: ai.toolExecute(deepResearchTask), +}); + +const POSTHOG_API_KEY = process.env.POSTHOG_API_KEY; +const POSTHOG_PROJECT_ID = process.env.POSTHOG_PROJECT_ID; +const POSTHOG_HOST = process.env.POSTHOG_HOST ?? "https://eu.posthog.com"; + +export const posthogQuery = tool({ + ...posthogQuerySchema, + execute: async ({ query }) => { + if (!POSTHOG_API_KEY || !POSTHOG_PROJECT_ID) { + return { error: "PostHog not configured. Set POSTHOG_API_KEY and POSTHOG_PROJECT_ID." }; + } + const response = await fetch(`${POSTHOG_HOST}/api/projects/${POSTHOG_PROJECT_ID}/query/`, { + method: "POST", + headers: { + Authorization: `Bearer ${POSTHOG_API_KEY}`, + "Content-Type": "application/json", + }, + body: JSON.stringify({ query: { kind: "HogQLQuery", query } }), + }); + + if (!response.ok) { + const text = await response.text(); + return { error: `PostHog API error ${response.status}: ${text.slice(0, 500)}` }; + } + + const data = await response.json(); + return { + columns: data.columns, + results: data.results?.slice(0, 50), + rowCount: data.results?.length ?? 0, + }; + }, +}); + +export const executeCode = tool({ + ...executeCodeSchema, + execute: async function executeCodeExecute({ code, language }) { + const runId = codeSandboxRun.runId; + if (!runId?.trim()) { + return { + error: + "Code sandbox run id is not set yet (call from the chat task after onTurnStart), or this tool is not wired to that task.", + }; + } + + const out = await runWithCodeSandbox(runId, async function runInSandbox(sandbox) { + const execution = await sandbox.runCode(code, { + ...(language?.trim() ? { language: language.trim() } : {}), + timeoutMs: 60_000, + }); + + if (execution.error) { + return { + error: `${execution.error.name}: ${execution.error.value}`, + traceback: execution.error.traceback, + stdout: execution.logs.stdout.join("\n"), + stderr: execution.logs.stderr.join("\n"), + }; + } + + const mainText = execution.text; + const resultSnippets = execution.results + .map(function mapResult(r) { + return r.text ?? r.markdown ?? r.json; + }) + .filter(Boolean) + .slice(0, 5); + + return { + text: mainText, + results: resultSnippets, + stdout: execution.logs.stdout.join("\n"), + stderr: execution.logs.stderr.join("\n"), + }; + }); + + return out; + }, +}); + +export const sendEmail = tool({ + ...sendEmailSchema, + execute: async ({ to, subject, body }) => { + // Simulated — in a real app this would call an email API + return { sent: true, to, subject, preview: body.slice(0, 100) }; + }, +}); + +// askUser has no execute by design — round-tripped via addToolOutput. +export const askUser = askUserSchema; + +export const getCurrentTime = tool({ + ...getCurrentTimeSchema, + execute: async () => { + const now = new Date(); + const tz = Intl.DateTimeFormat().resolvedOptions().timeZone; + return { + iso: now.toISOString(), + unixMs: now.getTime(), + timezone: tz, + local: now.toLocaleString("en-US", { timeZone: tz, dateStyle: "full", timeStyle: "long" }), + utc: now.toUTCString(), + dayOfWeek: now.toLocaleDateString("en-US", { weekday: "long" }), + }; + }, +}); + +export const searchHackerNews = tool({ + ...searchHackerNewsSchema, + execute: async ({ query, limit = 5 }) => { + if (query) { + // Algolia HN search — story type only, sorted by points + const url = new URL("https://hn.algolia.com/api/v1/search"); + url.searchParams.set("query", query); + url.searchParams.set("tags", "story"); + url.searchParams.set("hitsPerPage", String(limit)); + const res = await fetch(url); + if (!res.ok) return { error: `Algolia error ${res.status}` }; + const json = (await res.json()) as { + hits: Array<{ + objectID: string; + title?: string; + url?: string; + author: string; + points?: number; + num_comments?: number; + created_at: string; + }>; + }; + return { + query, + results: json.hits.map((h) => ({ + title: h.title ?? "(no title)", + url: h.url ?? `https://news.ycombinator.com/item?id=${h.objectID}`, + author: h.author, + points: h.points ?? 0, + comments: h.num_comments ?? 0, + createdAt: h.created_at, + })), + }; + } + // Top stories — first /topstories.json then per-item lookups + const idsRes = await fetch("https://hacker-news.firebaseio.com/v0/topstories.json"); + if (!idsRes.ok) return { error: `HN error ${idsRes.status}` }; + const ids = (await idsRes.json()) as number[]; + const top = ids.slice(0, limit); + const items = await Promise.all( + top.map(async (id) => { + const r = await fetch(`https://hacker-news.firebaseio.com/v0/item/${id}.json`); + if (!r.ok) return null; + const it = (await r.json()) as { + id: number; + title?: string; + url?: string; + by: string; + score?: number; + descendants?: number; + time: number; + }; + return { + title: it.title ?? "(no title)", + url: it.url ?? `https://news.ycombinator.com/item?id=${it.id}`, + author: it.by, + points: it.score ?? 0, + comments: it.descendants ?? 0, + createdAt: new Date(it.time * 1000).toISOString(), + }; + }) + ); + return { topStories: items.filter((x) => x !== null) }; + }, +}); + +export const createGithubIssue = tool({ + ...createGithubIssueSchema, + execute: async ({ repo, title, body, labels }) => { + // Simulated — in a real app this would call the GitHub API + const issueNumber = Math.floor(Math.random() * 9000) + 1000; + return { + created: true, + repo, + issueNumber, + url: `https://github.com/${repo}/issues/${issueNumber}`, + title, + labels: labels ?? [], + preview: body.slice(0, 120), + }; + }, +}); + +/** Tool set passed to `streamText` for the main `chat.agent` run. */ +export const chatTools = { + inspectEnvironment, + webFetch, + deepResearch, + posthogQuery, + executeCode, + sendEmail, + askUser, + getCurrentTime, + searchHackerNews, + createGithubIssue, +}; diff --git a/references/ai-chat/src/trigger/chat.ts b/references/ai-chat/src/trigger/chat.ts new file mode 100644 index 00000000000..b58b5c99e20 --- /dev/null +++ b/references/ai-chat/src/trigger/chat.ts @@ -0,0 +1,1000 @@ +import { chat, type ChatTaskWirePayload } from "@trigger.dev/sdk/ai"; +import { logger, prompts, skills } from "@trigger.dev/sdk"; + +import { + streamText, + generateText, + generateObject, + stepCountIs, + generateId, + createProviderRegistry, + validateUIMessages, +} from "ai"; +import type { LanguageModel, LanguageModelUsage, UIMessage } from "ai"; +import { openai } from "@ai-sdk/openai"; +import { anthropic } from "@ai-sdk/anthropic"; +import { z } from "zod"; +import { PrismaPg } from "@prisma/adapter-pg"; +import { PrismaClient } from "../../lib/generated/prisma/client"; +import { + chatTools, + deepResearch, + inspectEnvironment, + webFetch, +} from "./chat-tools"; +import type { ChatUiMessage } from "@/lib/chat-tools-schemas"; +import { disposeCodeSandboxForRun, warmCodeSandbox } from "@/lib/code-sandbox"; + +const adapter = new PrismaPg({ connectionString: process.env.DATABASE_URL! }); +const prisma = new PrismaClient({ adapter }); + +/** Prisma `messages` JSON column — use write-side type for updates (not `JsonValue` from reads). */ +export type ChatMessagesForWrite = NonNullable< + Parameters[0]["data"] +>["messages"]; + +import { DEFAULT_MODEL, REASONING_MODELS } from "@/lib/models"; + +function textFromFirstPart(message: UIMessage): string { + const p = message.parts?.[0]; + return p?.type === "text" ? p.text : ""; +} +const COMPACT_AFTER_TOKENS = Number(process.env.COMPACT_AFTER_TOKENS) || 80_000; + +const registry = createProviderRegistry({ openai, anthropic }); + +type RegistryLanguageModelId = Parameters[0]; + +function registryLanguageModel( + id: string | undefined, + fallback: RegistryLanguageModelId +): LanguageModel { + return registry.languageModel((id ?? fallback) as RegistryLanguageModelId); +} + +// #region Managed prompts — versioned, overridable from dashboard +const compactionPrompt = prompts.define({ + id: "ai-chat-compaction", + model: "openai:gpt-4o-mini" satisfies RegistryLanguageModelId, + content: `You are a conversation compactor. You will receive a transcript of a multi-turn conversation between a user and an assistant. + +Produce a concise summary that captures: +- The topics discussed and questions asked +- Any key facts, answers, or decisions reached +- Important context needed to continue the conversation naturally + +Write in third person (e.g. "The user asked about..." / "The assistant explained..."). +Keep it under 300 words. Do not include greetings or filler.`, +}); + +const systemPrompt = prompts.define({ + id: "ai-chat-system", + model: "openai:gpt-4o" satisfies RegistryLanguageModelId, + config: { temperature: 0.7 }, + variables: z.object({ name: z.string(), plan: z.string() }), + content: `You are a helpful AI assistant for {{name}} on the {{plan}} plan. + +## Guidelines +- Be concise and friendly. Prefer short, direct answers unless the user asks for detail. +- When using tools, explain what you're doing briefly before invoking them. +- If you don't know something, say so — don't make things up. + +## Capabilities +You can inspect the execution environment, fetch web pages, perform multi-URL deep research, +query PostHog with HogQL, and run short code snippets in an isolated sandbox (e.g. to analyze query results). +When the user asks you to research a topic, use the deep research tool with relevant URLs. + +## Tone +- Match the user's formality level. If they're casual, be casual back. +- Use markdown formatting for code blocks, lists, and structured output. +- Keep responses under a few paragraphs unless the user asks for more.`, +}); + +const timeUtilsSkill = skills.define({ + id: "time-utils", + path: "./skills/time-utils", +}); + +const selfReviewPrompt = prompts.define({ + id: "ai-chat-self-review", + model: "openai:gpt-4o-mini" satisfies RegistryLanguageModelId, + content: `You are a conversation quality reviewer. Analyze the assistant's most recent response and provide structured feedback. + +Focus on: +- Whether the response actually answered the user's question +- Missed opportunities to use tools or provide more detail +- Tone mismatches (too formal, too casual, etc.) +- Factual claims that should have been verified with tools + +Be concise. Only flag issues worth fixing — don't nitpick.`, +}); +// #endregion + +// #region Models and helpers +const MODELS: Record LanguageModel> = { + "gpt-4o-mini": () => openai("gpt-4o-mini"), + "gpt-4o": () => openai("gpt-4o"), + "claude-sonnet-4-6": () => anthropic("claude-sonnet-4-6"), + "claude-opus-4-6": () => anthropic("claude-opus-4-6"), +}; + +function getModel(modelId?: string): LanguageModel { + const factory = MODELS[modelId ?? DEFAULT_MODEL]; + if (!factory) return MODELS[DEFAULT_MODEL]!(); + return factory(); +} + +const DEFAULT_REGISTRY_MODEL_ID = "anthropic:claude-sonnet-4-6" as const satisfies RegistryLanguageModelId; + +function languageModelForChatTurn(modelOverride: string | null | undefined): LanguageModel { + if (modelOverride) { + return getModel(modelOverride); + } + return registryLanguageModel(chat.prompt().model, DEFAULT_REGISTRY_MODEL_ID); +} + +function useExtendedThinking(modelOverride: string | null | undefined): boolean { + if (modelOverride && REASONING_MODELS.has(modelOverride)) { + return true; + } + const promptModel = chat.prompt().model; + return promptModel != null && promptModel.includes("claude-opus-4-6"); +} +// #endregion + +// #region Per-run state — chat.local persists across turns in the same run +const userContext = chat.local<{ + userId: string; + name: string; + plan: "free" | "pro"; + preferredModel: string | null; + messageCount: number; +}>({ id: "userContext" }); +// #endregion + +// ============================================================================ +// chat.agent — the main chat agent +// ============================================================================ + +export const aiChat = chat + .withUIMessage({ + streamOptions: { + sendReasoning: true, + onError: (error) => { + logger.error("Stream error", { error }); + if (error instanceof Error && error.message.includes("rate limit")) { + return "Rate limited — please wait a moment and try again."; + } + return "Something went wrong. Please try again."; + }, + }, + }) + .withClientData({ + schema: z.object({ model: z.string().optional(), userId: z.string() }), + }) + .onChatSuspend(async ({ phase, ctx }) => { + logger.debug("Chat suspending", { phase, runId: ctx.run.id }); + await disposeCodeSandboxForRun(ctx.run.id); + }) + .onChatResume(async ({ phase, ctx }) => { + logger.debug("Chat resumed", { phase, runId: ctx.run.id }); + }) + .agent({ + id: "ai-chat", + idleTimeoutInSeconds: 60, + chatAccessTokenTTL: "1h", + + // #region Compaction — automatic context window management + compaction: { + shouldCompact: ({ totalTokens }) => (totalTokens ?? 0) > COMPACT_AFTER_TOKENS, + summarize: async ({ messages }) => { + const resolved = await compactionPrompt.resolve({}); + return generateText({ + model: registryLanguageModel(resolved.model, "openai:gpt-4o-mini"), + messages: [...messages, { role: "user" as const, content: resolved.text }], + ...resolved.toAISDKTelemetry(), + }).then((r) => r.text); + }, + compactUIMessages: ({ uiMessages, summary }) => { + return [ + { + id: generateId(), + role: "assistant" as const, + parts: [{ type: "text" as const, text: `[Conversation summary]\n\n${summary}` }], + }, + ...uiMessages.slice(-2), + ]; + }, + }, + // #endregion + + // #region Pending messages — user can steer the agent mid-response + pendingMessages: { + shouldInject: ({ steps }) => steps.length > 0, + prepare: ({ messages }) => + messages.length === 1 + ? [{ role: "user" as const, content: textFromFirstPart(messages[0]!) }] + : [ + { + role: "user" as const, + content: `The user sent ${messages.length + } messages while you were working:\n\n${messages + .map((m, i) => `${i + 1}. ${textFromFirstPart(m)}`) + .join("\n")}`, + }, + ], + }, + // #endregion + + // #region onValidateMessages — validate UIMessages before model conversion + onValidateMessages: async ({ messages, turn }) => { + logger.info("Validating UI messages", { + turn, + count: messages.length, + }); + // Cast: `chatTools` has executes (output types are real), but + // `ChatUiMessage` is derived from the schema-only set in + // `chat-tools-schemas.ts` so its tools have `output: never`. + // `validateUIMessages` only reads `inputSchema` at runtime, so + // the type narrowing is safely sidestepped. + return validateUIMessages({ + messages, + tools: chatTools as unknown as Parameters[0]["tools"], + }); + }, + // #endregion + + // #region prepareMessages — runs before every LLM call + prepareMessages: ({ messages, reason }) => { + // Add Anthropic cache breaks to the last message for prompt caching. + if (messages.length === 0) return messages; + const last = messages[messages.length - 1]!; + return [ + ...messages.slice(0, -1), + { + ...last, + providerOptions: { + ...last.providerOptions, + anthropic: { + ...(last.providerOptions?.anthropic as Record | undefined), + cacheControl: { type: "ephemeral" }, + }, + }, + }, + ]; + }, + // #endregion + + // --- Lifecycle hooks --- + + // #region onBoot — per-process setup that runs on EVERY fresh worker + // + // Fires for the initial run, preloaded runs, AND reactive continuation + // runs (post-cancel/crash/endRun/upgrade). The single place to initialize + // `chat.local` and per-process resources so they're ready in `run()` + // regardless of how the run was triggered. + onBoot: async ({ clientData }) => { + const user = await prisma.user.upsert({ + where: { id: clientData.userId }, + create: { id: clientData.userId, name: "User" }, + update: {}, + }); + userContext.init({ + userId: user.id, + name: user.name, + plan: user.plan as "free" | "pro", + preferredModel: user.preferredModel, + messageCount: user.messageCount, + }); + + const resolved = await systemPrompt.resolve({ + name: user.name, + plan: user.plan as string, + }); + chat.prompt.set(resolved); + chat.skills.set([await timeUtilsSkill.local()]); + }, + // #endregion + + // #region onPreload — eagerly create chat/session DB rows before the first message + onPreload: async ({ chatId, chatAccessToken, clientData }) => { + if (!clientData) return; + await prisma.chat.upsert({ + where: { id: chatId }, + create: { + id: chatId, + title: "New chat", + userId: clientData.userId, + model: clientData?.model ?? DEFAULT_MODEL, + }, + update: {}, + }); + await prisma.chatSession.upsert({ + where: { id: chatId }, + create: { id: chatId, publicAccessToken: chatAccessToken }, + update: { publicAccessToken: chatAccessToken }, + }); + }, + // #endregion + + // #region onChatStart — first-message chat/session DB rows when not preloaded + // + // Fires once per chat (on the very first message of the chat's lifetime). + // Per-process state initialization lives in `onBoot`; this hook is only + // for chat-scoped DB work that's a no-op on continuation runs. + onChatStart: async ({ chatId, chatAccessToken, clientData, preloaded }) => { + if (preloaded) return; + + await prisma.chat.upsert({ + where: { id: chatId }, + create: { + id: chatId, + title: "New chat", + userId: clientData.userId, + model: clientData.model ?? DEFAULT_MODEL, + }, + update: {}, + }); + await prisma.chatSession.upsert({ + where: { id: chatId }, + create: { id: chatId, publicAccessToken: chatAccessToken }, + update: { publicAccessToken: chatAccessToken }, + }); + }, + // #endregion + + // #region onCompacted + onCompacted: async ({ summary, totalTokens, messageCount, chatId, turn }) => { + logger.info("Conversation compacted", { + chatId, + turn, + totalTokens, + messageCount, + summaryLength: summary.length, + }); + }, + // #endregion + + // #region onTurnStart — persist messages + write status via writer + onTurnStart: async ({ chatId, uiMessages, writer, runId }) => { + warmCodeSandbox(runId); + writer.write({ type: "data-turn-status", data: { status: "preparing" }, transient: true }); + // Awaited (not chat.defer) so the user message is durable before + // streaming begins. A mid-stream page refresh reads from DB; if the + // write is still in flight, getChatMessages returns [] and the + // resumed SSE stream rebuilds an assistant-only conversation, + // dropping the user message from the UI. + await prisma.chat.update({ + where: { id: chatId }, + data: { messages: uiMessages as unknown as ChatMessagesForWrite }, + }); + }, + // #endregion + + onComplete: async ({ ctx }) => { + await disposeCodeSandboxForRun(ctx.run.id); + }, + + // #region onBeforeTurnComplete — add a persistent data part to test chat.response + onBeforeTurnComplete: async ({ writer, turn }) => { + writer.write({ + type: "data-turn-metadata", + data: { turn, timestamp: Date.now(), source: "onBeforeTurnComplete" }, + }); + }, + // #endregion + + // #region actionSchema + onAction — typed actions for state-only mutations + // Actions are not turns: only `hydrateMessages` and `onAction` fire, + // no `run()` invocation, no model call. The `undo` action drops the + // last user/assistant exchange so the next message turn sees a + // truncated history. + actionSchema: z.discriminatedUnion("type", [ + z.object({ type: z.literal("undo") }), + ]), + onAction: async ({ action }) => { + if (action.type === "undo") { + chat.history.slice(0, -2); + } + }, + // #endregion + + // #region onTurnComplete — persist + background self-review via chat.inject() + onTurnComplete: async ({ + chatId, + uiMessages, + messages, + responseMessage, + runId, + chatAccessToken, + lastEventId, + }) => { + // Log responseMessage parts for debugging TRI-8556 + const partTypes = responseMessage?.parts?.map((p: any) => p.type) ?? []; + const toolParts = responseMessage?.parts?.filter((p: any) => p.type?.startsWith("tool-")) ?? []; + logger.info("onTurnComplete responseMessage", { + hasResponseMessage: !!responseMessage, + responseMessageId: responseMessage?.id, + totalParts: responseMessage?.parts?.length ?? 0, + partTypes, + toolPartsCount: toolParts.length, + toolParts: toolParts.map((p: any) => ({ type: p.type, state: p.state, toolCallId: p.toolCallId })), + }); + // Atomic so the page-load `Promise.all([getChatMessages, getSessionForChat])` + // can't observe a state where messages are post-write but lastEventId is + // still pre-write — that race causes resume to replay this turn's chunks + // on top of the persisted assistant message and duplicates the render. + await prisma.$transaction([ + prisma.chat.update({ + where: { id: chatId }, + data: { messages: uiMessages as unknown as ChatMessagesForWrite }, + }), + prisma.chatSession.upsert({ + where: { id: chatId }, + create: { id: chatId, publicAccessToken: chatAccessToken, lastEventId }, + update: { publicAccessToken: chatAccessToken, lastEventId }, + }), + ]); + + // Background self-review — a cheap model critiques the response and + // injects coaching into the conversation before the next user message. + chat.defer( + (async () => { + const resolved = await selfReviewPrompt.resolve({}); + + const review = await generateObject({ + model: registryLanguageModel(resolved.model, "openai:gpt-4o-mini"), + ...resolved.toAISDKTelemetry(), + system: resolved.text, + prompt: `Here is the conversation to review:\n\n${messages + .filter((m) => m.role === "user" || m.role === "assistant") + .map( + (m) => + `${m.role}: ${typeof m.content === "string" + ? m.content + : Array.isArray(m.content) + ? m.content + .filter((p: any) => p.type === "text") + .map((p: any) => p.text) + .join("") + : "" + }` + ) + .join("\n\n")}`, + schema: z.object({ + needsImprovement: z.boolean().describe("Whether the response needs improvement"), + suggestions: z + .array(z.string()) + .describe("Specific actionable suggestions for the next response"), + missedTools: z + .array(z.string()) + .describe("Tool names the assistant should have used but didn't"), + }), + }); + + const parts = []; + if (review.object.suggestions.length > 0) { + parts.push( + `Suggestions:\n${review.object.suggestions.map((s) => `- ${s}`).join("\n")}` + ); + } + if (review.object.missedTools.length > 0) { + parts.push(`Consider using: ${review.object.missedTools.join(", ")}`); + } + + chat.inject([ + { + role: "user" as const, + content: review.object.needsImprovement + ? `[Self-review of your previous response]\n\n${parts.join( + "\n\n" + )}\n\nApply these improvements naturally in your next response.` + : `[Self-review of your previous response]\n\nYour previous response was good. No changes needed.`, + }, + ]); + })() + ); + }, + // #endregion + + // #region run — just return streamText(), chat.agent handles everything else + run: async ({ messages, clientData, stopSignal }) => { + userContext.messageCount++; + if (clientData?.model) { + userContext.preferredModel = clientData.model; + } + + const modelOverride = clientData?.model ?? userContext.preferredModel ?? undefined; + const useReasoning = useExtendedThinking(modelOverride); + + return streamText({ + ...chat.toStreamTextOptions({ + registry, + telemetry: clientData?.userId ? { userId: clientData.userId } : undefined, + tools: chatTools, + }), + model: languageModelForChatTurn(modelOverride), + messages: messages, + stopWhen: stepCountIs(10), + abortSignal: stopSignal, + providerOptions: { + openai: { user: clientData?.userId }, + anthropic: { + metadata: { user_id: clientData?.userId }, + ...(useReasoning ? { thinking: { type: "enabled", budgetTokens: 10000 } } : {}), + }, + }, + }); + }, + // #endregion + }); + +// #region Raw task variant — same functionality using composable primitives +async function initUserContext(userId: string, chatId: string, model?: string) { + const user = await prisma.user.upsert({ + where: { id: userId }, + create: { id: userId, name: "User" }, + update: {}, + }); + userContext.init({ + userId: user.id, + name: user.name, + plan: user.plan as "free" | "pro", + preferredModel: user.preferredModel, + messageCount: user.messageCount, + }); + + const resolved = await systemPrompt.resolve({ + name: user.name, + plan: user.plan as string, + }); + chat.prompt.set(resolved); + + await prisma.chat.upsert({ + where: { id: chatId }, + create: { id: chatId, title: "New chat", userId: user.id, model: model ?? DEFAULT_MODEL }, + update: {}, + }); +} + +export const aiChatRaw = chat.customAgent({ + id: "ai-chat-raw", + run: async (payload: ChatTaskWirePayload, { signal: runSignal }) => { + let currentPayload = payload; + const clientData = payload.metadata as { userId: string; model?: string } | undefined; + + if (currentPayload.trigger === "preload") { + if (clientData) { + await initUserContext(clientData.userId, currentPayload.chatId, clientData.model); + } + + const result = await chat.messages.waitWithIdleTimeout({ + idleTimeoutInSeconds: payload.idleTimeoutInSeconds ?? 60, + timeout: "1h", + spanName: "waiting for first message", + }); + if (!result.ok) return; + currentPayload = result.output; + } + + const currentClientData = (currentPayload.metadata ?? clientData) as + | { userId: string; model?: string } + | undefined; + + if (!userContext.userId && currentClientData) { + await initUserContext( + currentClientData.userId, + currentPayload.chatId, + currentClientData.model + ); + } + + const stop = chat.createStopSignal(); + const conversation = new chat.MessageAccumulator({ + compaction: { + shouldCompact: ({ totalTokens }) => (totalTokens ?? 0) > COMPACT_AFTER_TOKENS, + summarize: async ({ messages: msgs }) => { + const resolved = await compactionPrompt.resolve({}); + return generateText({ + model: registryLanguageModel(resolved.model, "openai:gpt-4o-mini"), + ...resolved.toAISDKTelemetry(), + messages: [...msgs, { role: "user" as const, content: resolved.text }], + }).then((r) => r.text); + }, + compactUIMessages: ({ summary }) => [ + { + id: generateId(), + role: "assistant" as const, + parts: [{ type: "text" as const, text: `[Summary]\n\n${summary}` }], + }, + ], + }, + pendingMessages: { + shouldInject: () => true, + prepare: ({ messages }) => [ + { + role: "user" as const, + content: [ + { + type: "text" as const, + text: `[User sent ${messages.length} message(s) while you were working]:\n${messages + .map((m) => textFromFirstPart(m)) + .join("\n")}`, + }, + ], + }, + ], + }, + }); + + for (let turn = 0; turn < 100; turn++) { + stop.reset(); + + const messages = await conversation.addIncoming( + currentPayload.messages, + currentPayload.trigger, + turn + ); + + const turnClientData = (currentPayload.metadata ?? currentClientData) as + | { userId: string; model?: string } + | undefined; + + userContext.messageCount++; + if (turnClientData?.model) { + userContext.preferredModel = turnClientData.model; + } + + const modelOverride = turnClientData?.model ?? userContext.preferredModel ?? undefined; + const useReasoning = useExtendedThinking(modelOverride); + const combinedSignal = AbortSignal.any([runSignal, stop.signal]); + + const steeringSub = chat.messages.on(async (msg) => { + const lastMsg = msg.messages?.[msg.messages.length - 1]; + if (lastMsg) await conversation.steerAsync(lastMsg); + }); + + const result = streamText({ + ...chat.toStreamTextOptions({ registry }), + model: languageModelForChatTurn(modelOverride), + messages: messages, + tools: { + inspectEnvironment, + webFetch, + deepResearch, + }, + stopWhen: stepCountIs(10), + abortSignal: combinedSignal, + providerOptions: { + openai: { user: turnClientData?.userId }, + anthropic: { + metadata: { user_id: turnClientData?.userId }, + ...(useReasoning ? { thinking: { type: "enabled", budgetTokens: 10000 } } : {}), + }, + }, + prepareStep: conversation.prepareStep(), + }); + + let response: UIMessage | undefined; + try { + response = await chat.pipeAndCapture(result, { signal: combinedSignal }); + } catch (error) { + if (error instanceof Error && error.name === "AbortError") { + if (runSignal.aborted) break; + } else { + throw error; + } + } finally { + steeringSub.off(); + } + + if (response) { + if (stop.signal.aborted && !runSignal.aborted) { + await conversation.addResponse(chat.cleanupAbortedParts(response)); + } else { + await conversation.addResponse(response); + } + } + + if (runSignal.aborted) break; + + let turnUsage: LanguageModelUsage | undefined; + try { + turnUsage = await result.totalUsage; + } catch { + /* non-fatal */ + } + await conversation.compactIfNeeded(turnUsage, { + chatId: currentPayload.chatId, + turn, + }); + + await prisma.chat.update({ + where: { id: currentPayload.chatId }, + data: { messages: conversation.uiMessages as unknown as ChatMessagesForWrite }, + }); + + if (userContext.hasChanged()) { + await prisma.user.update({ + where: { id: userContext.userId }, + data: { + messageCount: userContext.messageCount, + preferredModel: userContext.preferredModel, + }, + }); + } + + await chat.writeTurnComplete(); + + const next = await chat.messages.waitWithIdleTimeout({ + idleTimeoutInSeconds: 60, + timeout: "1h", + spanName: "waiting for next message", + }); + if (!next.ok) break; + currentPayload = next.output; + } + + stop.cleanup(); + }, +}); + +export const aiChatSession = chat + .withClientData({ + schema: z.object({ userId: z.string(), model: z.string().optional() }), + }) + .customAgent({ + id: "ai-chat-session", + run: async (payload: ChatTaskWirePayload, { signal }) => { + const clientData = payload.metadata as { userId: string; model?: string } | undefined; + + if (clientData) { + await initUserContext(clientData.userId, payload.chatId, clientData.model); + } + + const session = chat.createSession(payload, { + signal, + idleTimeoutInSeconds: payload.idleTimeoutInSeconds ?? 60, + timeout: "1h", + compaction: { + shouldCompact: ({ totalTokens }) => (totalTokens ?? 0) > COMPACT_AFTER_TOKENS, + summarize: async ({ messages: msgs }) => { + const resolved = await compactionPrompt.resolve({}); + return generateText({ + model: registryLanguageModel(resolved.model, "openai:gpt-4o-mini"), + ...resolved.toAISDKTelemetry(), + messages: [...msgs, { role: "user" as const, content: resolved.text }], + }).then((r) => r.text); + }, + compactUIMessages: ({ uiMessages, summary }) => [ + { + id: generateId(), + role: "assistant" as const, + parts: [{ type: "text" as const, text: `[Conversation summary]\n\n${summary}` }], + }, + ...uiMessages.slice(-4), + ], + }, + pendingMessages: { + shouldInject: () => true, + }, + }); + + for await (const turn of session) { + const turnClientData = (turn.clientData ?? clientData) as + | { userId: string; model?: string } + | undefined; + + userContext.messageCount++; + if (turnClientData?.model) userContext.preferredModel = turnClientData.model; + + const modelOverride = turnClientData?.model ?? userContext.preferredModel ?? undefined; + const useReasoning = useExtendedThinking(modelOverride); + + const result = streamText({ + ...chat.toStreamTextOptions({ registry }), + model: languageModelForChatTurn(modelOverride), + messages: turn.messages, + tools: { + inspectEnvironment, + webFetch, + deepResearch, + }, + stopWhen: stepCountIs(10), + abortSignal: turn.signal, + providerOptions: { + openai: { user: turnClientData?.userId }, + anthropic: { + metadata: { user_id: turnClientData?.userId }, + ...(useReasoning ? { thinking: { type: "enabled", budgetTokens: 10000 } } : {}), + }, + }, + }); + + await turn.complete(result); + + await prisma.chat.update({ + where: { id: turn.chatId }, + data: { messages: turn.uiMessages as unknown as ChatMessagesForWrite }, + }); + + if (userContext.hasChanged()) { + await prisma.user.update({ + where: { id: userContext.userId }, + data: { + messageCount: userContext.messageCount, + preferredModel: userContext.preferredModel, + }, + }); + } + } + }, +}); +// #endregion + +// ============================================================================ +// Hydrated agent — backend is source of truth for message history +// ============================================================================ +// +// Demonstrates three features: +// +// 1. `hydrateMessages` — backend loads message history from the DB on every +// turn instead of trusting the frontend. Prevents fabricated history. +// +// 2. `actionSchema` + `onAction` — typed custom actions (undo, rollback) +// sent via transport.sendAction(). The agent modifies history via +// chat.history.*, then the LLM responds to the updated state. +// +// 3. `chat.history` — imperative mutations used inside onAction to +// implement undo (slice off last exchange) and rollback (truncate to +// a specific message). +// + +export const aiChatHydrated = chat + .withClientData({ + schema: z.object({ model: z.string().optional(), userId: z.string() }), + }) + .agent({ + id: "ai-chat-hydrated", + idleTimeoutInSeconds: 60, + + // Load message history from the database on every turn. + // The frontend's accumulated messages are ignored — the DB is the + // single source of truth. New user messages arrive in `incomingMessages` + // and are appended + persisted before returning. + hydrateMessages: async ({ chatId, trigger, incomingMessages }) => { + const record = await prisma.chat.findUnique({ where: { id: chatId } }); + const stored = (record?.messages as unknown as UIMessage[]) ?? []; + + if (trigger === "submit-message" && incomingMessages.length > 0) { + const newMsg = incomingMessages[incomingMessages.length - 1]!; + stored.push(newMsg); + await prisma.chat.update({ + where: { id: chatId }, + data: { messages: stored as unknown as ChatMessagesForWrite }, + }); + } + + return stored; + }, + + // Typed actions the frontend can send via transport.sendAction() + actionSchema: z.discriminatedUnion("type", [ + z.object({ type: z.literal("undo") }), + z.object({ type: z.literal("rollback"), targetMessageId: z.string() }), + z.object({ type: z.literal("remove"), messageId: z.string() }), + z.object({ + type: z.literal("replace"), + messageId: z.string(), + text: z.string(), + }), + ]), + + onAction: async ({ action, chatId }) => { + switch (action.type) { + case "undo": + // Remove the last user message + assistant response + chat.history.slice(0, -2); + break; + case "rollback": + // Keep messages up to and including the target + chat.history.rollbackTo(action.targetMessageId); + break; + case "remove": + chat.history.remove(action.messageId); + break; + case "replace": + // Build a new UIMessage with the updated text + chat.history.replace(action.messageId, { + id: action.messageId, + role: "user" as const, + parts: [{ type: "text" as const, text: action.text }], + }); + break; + } + // Hydrate-mode task: `chat.history.*` mutations live in the + // in-memory accumulator for this turn only. The NEXT turn's + // `hydrateMessages` reads from Postgres, so any action that + // mutates history must also be persisted back to the DB or it'll + // be overwritten on the next message. Write the mutated chain + // through here. + await prisma.chat.update({ + where: { id: chatId }, + data: { + messages: chat.history.all() as unknown as ChatMessagesForWrite, + }, + }); + }, + + onChatStart: async ({ chatId, runId, chatAccessToken, clientData, preloaded }) => { + if (preloaded) return; + await initUserContext(clientData.userId, chatId, clientData.model); + await prisma.chatSession.upsert({ + where: { id: chatId }, + create: { id: chatId, publicAccessToken: chatAccessToken }, + update: { publicAccessToken: chatAccessToken }, + }); + }, + + onPreload: async ({ chatId, runId, chatAccessToken, clientData }) => { + if (!clientData) return; + await initUserContext(clientData.userId, chatId, clientData.model); + await prisma.chatSession.upsert({ + where: { id: chatId }, + create: { id: chatId, publicAccessToken: chatAccessToken }, + update: { publicAccessToken: chatAccessToken }, + }); + }, + + onTurnComplete: async ({ chatId, uiMessages, runId, chatAccessToken, lastEventId }) => { + // See aiChat.onTurnComplete — atomic to avoid the resume-replay race. + await prisma.$transaction([ + prisma.chat.update({ + where: { id: chatId }, + data: { messages: uiMessages as unknown as ChatMessagesForWrite }, + }), + prisma.chatSession.upsert({ + where: { id: chatId }, + create: { id: chatId, publicAccessToken: chatAccessToken, lastEventId }, + update: { publicAccessToken: chatAccessToken, lastEventId }, + }), + ]); + }, + + run: async ({ messages, clientData, stopSignal }) => { + return streamText({ + ...chat.toStreamTextOptions(), + model: languageModelForChatTurn( + clientData?.model ?? userContext.preferredModel ?? undefined + ), + messages, + abortSignal: stopSignal, + }); + }, + }); + +// ============================================================================ +// Upgrade test agent — calls chat.requestUpgrade() after 3 turns +// ============================================================================ + +export const upgradeTestAgent = chat.agent({ + id: "upgrade-test", + idleTimeoutInSeconds: 60, + onTurnStart: async ({ turn, ctx }) => { + logger.info("Upgrade test turn", { turn, version: ctx.run.version }); + if (turn >= 3) { + logger.info("Requesting upgrade after 3 turns"); + chat.requestUpgrade(); + } + }, + run: async ({ messages, signal }) => { + return streamText({ + model: openai("gpt-4o-mini"), + system: + "You are a helpful test assistant. Keep responses short (1-2 sentences). " + + "Always mention what turn number you think you're on based on the conversation history.", + messages, + abortSignal: signal, + }); + }, +}); diff --git a/references/ai-chat/src/trigger/pr-review.ts b/references/ai-chat/src/trigger/pr-review.ts new file mode 100644 index 00000000000..ae69107f595 --- /dev/null +++ b/references/ai-chat/src/trigger/pr-review.ts @@ -0,0 +1,293 @@ +import { chat } from "@trigger.dev/sdk/ai"; +import { logger, prompts } from "@trigger.dev/sdk"; +import { + streamText, + generateObject, + stepCountIs, + createProviderRegistry, +} from "ai"; +import { openai } from "@ai-sdk/openai"; +import { anthropic } from "@ai-sdk/anthropic"; +import { z } from "zod"; +import { PrismaPg } from "@prisma/adapter-pg"; +import { PrismaClient } from "../../lib/generated/prisma/client"; +import { + parseGitHubUrl, + cloneRepo, + cleanupClone, + githubApi, +} from "@/lib/pr-review-helpers"; +import { + repo, + prReviewTools, + type PRReviewUiMessage, +} from "@/lib/pr-review-tools"; + +const adapter = new PrismaPg({ connectionString: process.env.DATABASE_URL! }); +const prisma = new PrismaClient({ adapter }); + +const registry = createProviderRegistry({ openai, anthropic }); + +// #region System prompt +const prReviewSystemPrompt = prompts.define({ + id: "pr-review-system", + model: "anthropic:claude-sonnet-4-6", + content: `You are an expert code reviewer with deep knowledge of software engineering best practices, security vulnerabilities, performance patterns, and clean code principles. + +## Your workflow +1. When the user asks to review a PR, ALWAYS use the fetchPR tool first to load the PR data. +2. Read the diff carefully. For any file where the diff is unclear, use readFile to see the full context. +3. When you spot a potential issue, USE the executeCode tool to verify your claim before stating it. Don't say "this might fail" — prove it. +4. When suggesting a fix, use executeCode to verify the fix works before presenting it. + +## Review format +Structure your review as: + +### Summary +One paragraph overview of the PR's purpose and scope. + +### Findings +For each issue, use severity markers: +- 🔴 **Bug**: Definite or highly likely bugs, data loss risks, security vulnerabilities +- 🟡 **Suggestion**: Improvements to readability, performance, maintainability +- 🟢 **Nitpick**: Style preferences, naming, minor improvements + +Format each finding as: +**[severity] filename:line — Brief title** +Description of the issue. Reference exact code from the diff. + +### Overall Assessment +Is this PR ready to merge, needs minor changes, or needs significant rework? + +## Rules +- Be specific. Always cite filenames and line numbers from the diff. +- Be constructive. Explain WHY something is a problem and suggest a fix. +- Don't flag intentional patterns as bugs — use readFile to check context first. +- Don't hallucinate line numbers. Use the diff hunks or readFile output. +- If the diff is truncated, tell the user and offer to read specific files. +- Verify non-obvious claims with executeCode before including them.`, +}); +// #endregion + +// #region Self-review prompt +const prSelfReviewPrompt = prompts.define({ + id: "pr-review-self-review", + model: "anthropic:claude-haiku-4-5", + content: `You are a code review quality checker. Analyze the reviewer's comments for accuracy. + +Focus on: +- False positive bugs (code flagged as buggy that is actually correct) +- Incorrect assumptions about the code's behavior +- Claims that weren't verified by running code +- Overstated severity levels + +Be concise. Only flag genuine issues.`, +}); +// #endregion + +// #region Shared init helper +async function initRepo(chatId: string, userId: string, githubUrl: string) { + // 1. Look up user and their GitHub token from the database + const user = await prisma.user.findUnique({ where: { id: userId } }); + const githubToken = user?.githubToken ?? null; + + // 2. Parse the GitHub URL and clone the repo + const { owner, repo: repoName } = parseGitHubUrl(githubUrl); + const cwd = `/tmp/pr-review-${chatId}`; + + await cloneRepo({ owner, repo: repoName, clonePath: cwd, token: githubToken }); + + // 3. Fetch open PRs from GitHub API + const prs = await githubApi< + Array<{ + number: number; + title: string; + user: { login: string }; + head: { ref: string }; + }> + >(`/repos/${owner}/${repoName}/pulls?state=open&per_page=20&sort=updated`, githubToken); + + const openPRs = prs.map((pr) => ({ + number: pr.number, + title: pr.title, + author: pr.user.login, + headBranch: pr.head.ref, + })); + + // 4. Initialize per-run state + repo.init({ + cwd, + owner, + repo: repoName, + githubToken, + openPRs, + activePR: null, + }); + + // 5. Resolve and set system prompt + const resolved = await prReviewSystemPrompt.resolve({}); + chat.prompt.set(resolved); + + logger.info("PR review state initialized", { + owner, + repo: repoName, + cwd, + openPRCount: openPRs.length, + hasToken: !!githubToken, + }); +} +// #endregion + +// #region Agent definition +export const prReviewChat = chat + .withUIMessage({ + streamOptions: { + sendReasoning: true, + onError: (error) => { + logger.error("PR review stream error", { error }); + return "Something went wrong during review. Please try again."; + }, + }, + }) + .withClientData({ + schema: z.object({ + userId: z.string(), + githubUrl: z.string().url(), + }), + }) + .agent({ + id: "pr-review", + idleTimeoutInSeconds: 10, + preloadIdleTimeoutInSeconds: 10, + chatAccessTokenTTL: "60m", + + // #region onPreload — clone repo + fetch PRs before first message + onPreload: async ({ chatId, clientData }) => { + if (!clientData) return; + await initRepo(chatId, clientData.userId, clientData.githubUrl); + }, + // #endregion + + // #region onChatStart — fallback init when not preloaded + onChatStart: async ({ chatId, clientData, preloaded }) => { + if (preloaded) return; + await initRepo(chatId, clientData.userId, clientData.githubUrl); + }, + // #endregion + + // #region onTurnComplete — self-review for false positives + onTurnComplete: async ({ messages }) => { + chat.defer( + (async () => { + const resolved = await prSelfReviewPrompt.resolve({}); + + const review = await generateObject({ + model: anthropic("claude-haiku-4-5-20251001"), + ...resolved.toAISDKTelemetry(), + system: resolved.text, + prompt: `Review the code reviewer's latest response for accuracy:\n\n${messages + .filter((m) => m.role === "user" || m.role === "assistant") + .slice(-4) + .map( + (m) => + `${m.role}: ${ + typeof m.content === "string" + ? m.content + : Array.isArray(m.content) + ? m.content + .filter((p: any) => p.type === "text") + .map((p: any) => p.text) + .join("") + : "" + }` + ) + .join("\n\n")}`, + schema: z.object({ + hasFalsePositives: z + .boolean() + .describe("Whether the review contains false positives"), + corrections: z.array( + z.object({ + originalClaim: z + .string() + .describe("The claim from the review"), + correction: z + .string() + .describe("What should be corrected"), + severity: z.enum([ + "false-positive-bug", + "overstated-severity", + "missing-context", + ]), + }) + ), + }), + }); + + if ( + review.object.hasFalsePositives && + review.object.corrections.length > 0 + ) { + const correctionText = review.object.corrections + .map( + (c) => + `- ${c.severity}: "${c.originalClaim}" → ${c.correction}` + ) + .join("\n"); + + chat.inject([ + { + role: "user" as const, + content: `[Self-review correction]\n\nYour previous review may contain inaccuracies:\n${correctionText}\n\nIncorporate these corrections naturally if the user asks follow-up questions.`, + }, + ]); + } + })() + ); + }, + // #endregion + + // #region onComplete — cleanup clone directory + onComplete: async () => { + await cleanupClone(repo.cwd); + }, + // #endregion + + // #region run — stream code review response + run: async ({ messages, stopSignal }) => { + // Inject open PR list as context so the agent knows what's available + const prListContext = + repo.openPRs.length > 0 + ? `Open PRs for ${repo.owner}/${repo.repo}:\n${repo.openPRs + .map( + (pr) => + ` #${pr.number} — ${pr.title} (by ${pr.author}, branch: ${pr.headBranch})` + ) + .join("\n")}` + : ""; + + return streamText({ + ...chat.toStreamTextOptions({ registry }), + model: anthropic("claude-sonnet-4-6"), + messages: prListContext + ? [ + { + role: "user" as const, + content: `[Context] ${prListContext}`, + }, + ...messages, + ] + : messages, + tools: prReviewTools, + stopWhen: stepCountIs(15), + abortSignal: stopSignal, + providerOptions: { + anthropic: { + thinking: { type: "enabled", budgetTokens: 10000 }, + }, + }, + }); + }, + // #endregion + }); +// #endregion diff --git a/references/ai-chat/src/trigger/skills/time-utils/SKILL.md b/references/ai-chat/src/trigger/skills/time-utils/SKILL.md new file mode 100644 index 00000000000..b9200574e77 --- /dev/null +++ b/references/ai-chat/src/trigger/skills/time-utils/SKILL.md @@ -0,0 +1,37 @@ +--- +name: time-utils +description: Compute and format dates/times in arbitrary timezones using a small set of bundled bash scripts. Use when the user asks about "what time is it", "current time in ", date math, or timezone conversions. +--- + +# Time utilities + +This skill bundles small bash scripts that shell out to `date` for timezone-aware answers without the model having to reason about offsets. + +## When to use + +- The user asks for the current time in a specific timezone (e.g. "what time is it in Tokyo?") +- The user wants a date formatted in a specific way +- The user needs a relative time (e.g. "what's the date 3 days from now?") + +## Scripts + +### `scripts/now.sh [TZ]` + +Prints the current time in the given IANA timezone (default `UTC`). Example: + +``` +bash scripts/now.sh America/Los_Angeles +``` + +### `scripts/add.sh DAYS [TZ]` + +Prints a date `DAYS` days from now in the given timezone. `DAYS` can be negative. Example: + +``` +bash scripts/add.sh 3 Europe/London +``` + +## Tips + +- IANA timezone names only (`America/New_York`, not `EST`). +- See `references/timezones.txt` for a short cheat-sheet of common zones. diff --git a/references/ai-chat/src/trigger/skills/time-utils/references/timezones.txt b/references/ai-chat/src/trigger/skills/time-utils/references/timezones.txt new file mode 100644 index 00000000000..dcbe5b31011 --- /dev/null +++ b/references/ai-chat/src/trigger/skills/time-utils/references/timezones.txt @@ -0,0 +1,30 @@ +Common IANA timezones: + +North America: + America/New_York (US Eastern) + America/Chicago (US Central) + America/Denver (US Mountain) + America/Los_Angeles (US Pacific) + America/Toronto + America/Mexico_City + +Europe: + Europe/London + Europe/Paris + Europe/Berlin + Europe/Amsterdam + Europe/Madrid + Europe/Dublin + +Asia & Pacific: + Asia/Tokyo + Asia/Shanghai + Asia/Singapore + Asia/Kolkata (India, UTC+5:30) + Australia/Sydney + Pacific/Auckland + +Others: + UTC + America/Sao_Paulo + Africa/Johannesburg diff --git a/references/ai-chat/src/trigger/skills/time-utils/scripts/add.sh b/references/ai-chat/src/trigger/skills/time-utils/scripts/add.sh new file mode 100755 index 00000000000..14470c0ac73 --- /dev/null +++ b/references/ai-chat/src/trigger/skills/time-utils/scripts/add.sh @@ -0,0 +1,6 @@ +#!/usr/bin/env bash +set -euo pipefail +DAYS="${1:?days argument required}" +TZ="${2:-UTC}" +TZ="$TZ" date -d "${DAYS} days" '+%Y-%m-%d %H:%M:%S %Z' 2>/dev/null \ + || TZ="$TZ" date -v"${DAYS}d" '+%Y-%m-%d %H:%M:%S %Z' diff --git a/references/ai-chat/src/trigger/skills/time-utils/scripts/now.sh b/references/ai-chat/src/trigger/skills/time-utils/scripts/now.sh new file mode 100755 index 00000000000..836665f7921 --- /dev/null +++ b/references/ai-chat/src/trigger/skills/time-utils/scripts/now.sh @@ -0,0 +1,4 @@ +#!/usr/bin/env bash +set -euo pipefail +TZ="${1:-UTC}" +TZ="$TZ" date -u '+%Y-%m-%d %H:%M:%S %Z' 2>/dev/null || TZ="$TZ" date '+%Y-%m-%d %H:%M:%S %Z' diff --git a/references/ai-chat/src/trigger/stress-emit.ts b/references/ai-chat/src/trigger/stress-emit.ts new file mode 100644 index 00000000000..b9300c6ae25 --- /dev/null +++ b/references/ai-chat/src/trigger/stress-emit.ts @@ -0,0 +1,99 @@ +// Stress-test chat.agent. Emits a configurable number of `text-delta` +// chunks of a configurable size — no LLM call, no tokens spent. Lets us +// stress the dashboard's session detail view (rendered conversation + +// raw stream tabs) with deterministic load. +// +// Config is parsed from the last user message's text. Two formats: +// "1000 10" → chunkCount=1000, chunkSize=10 +// "1000 10 messages" → chunkCount messages of one delta each +// +// Defaults: 1000 chunks × 10 chars, single message. + +import { chat } from "@trigger.dev/sdk/ai"; +import { type UIMessage, simulateReadableStream, streamText } from "ai"; +import { MockLanguageModelV3 } from "ai/test"; +import type { LanguageModelV3StreamPart } from "@ai-sdk/provider"; + +type StressConfig = { + chunkCount: number; + chunkSize: number; + manyMessages: boolean; +}; + +function parseConfig(messages: UIMessage[]): StressConfig { + const lastUser = [...messages].reverse().find((m) => m.role === "user"); + const text = + lastUser?.parts?.[0]?.type === "text" ? lastUser.parts[0].text.trim() : ""; + const parts = text.split(/\s+/); + const chunkCount = Number(parts[0]); + const chunkSize = Number(parts[1]); + const manyMessages = parts[2] === "messages"; + return { + chunkCount: Number.isFinite(chunkCount) && chunkCount > 0 ? chunkCount : 1000, + chunkSize: Number.isFinite(chunkSize) && chunkSize > 0 ? chunkSize : 10, + manyMessages, + }; +} + +function buildModelStream(config: StressConfig): LanguageModelV3StreamPart[] { + const delta = "x".repeat(config.chunkSize); + // Each `text-start`/`text-end` pair maps to a separate assistant message + // in the AI SDK pipeline when `manyMessages` is set; without it, all + // deltas accumulate into a single message. + if (config.manyMessages) { + const stream: LanguageModelV3StreamPart[] = []; + for (let i = 0; i < config.chunkCount; i++) { + const id = `t${i}`; + stream.push({ type: "text-start", id }); + stream.push({ type: "text-delta", id, delta }); + stream.push({ type: "text-end", id }); + } + stream.push({ + type: "finish", + finishReason: { unified: "stop", raw: "stop" }, + usage: { + inputTokens: { total: 0, noCache: 0, cacheRead: undefined, cacheWrite: undefined }, + outputTokens: { + total: config.chunkCount, + text: config.chunkCount, + reasoning: undefined, + }, + }, + }); + return stream; + } + + const stream: LanguageModelV3StreamPart[] = [{ type: "text-start", id: "t1" }]; + for (let i = 0; i < config.chunkCount; i++) { + stream.push({ type: "text-delta", id: "t1", delta }); + } + stream.push({ type: "text-end", id: "t1" }); + stream.push({ + type: "finish", + finishReason: { unified: "stop", raw: "stop" }, + usage: { + inputTokens: { total: 0, noCache: 0, cacheRead: undefined, cacheWrite: undefined }, + outputTokens: { + total: config.chunkCount, + text: config.chunkCount, + reasoning: undefined, + }, + }, + }); + return stream; +} + +export const stressEmit = chat.agent({ + id: "stress-emit", + run: async ({ messages, signal }) => { + const config = parseConfig(messages); + const chunks = buildModelStream(config); + return streamText({ + model: new MockLanguageModelV3({ + doStream: async () => ({ stream: simulateReadableStream({ chunks }) }), + }), + messages, + abortSignal: signal, + }); + }, +}); diff --git a/references/ai-chat/src/trigger/test-chat.test.ts b/references/ai-chat/src/trigger/test-chat.test.ts new file mode 100644 index 00000000000..a0dbf45cba3 --- /dev/null +++ b/references/ai-chat/src/trigger/test-chat.test.ts @@ -0,0 +1,232 @@ +// Import the test harness FIRST so the resource catalog is installed +// before the agent module is loaded (which registers the task). +import { mockChatAgent } from "@trigger.dev/sdk/ai/test"; + +import { describe, expect, it } from "vitest"; +import { MockLanguageModelV3 } from "ai/test"; +import { simulateReadableStream, type UIMessage, type UIMessageChunk } from "ai"; +import type { LanguageModelV3StreamPart } from "@ai-sdk/provider"; +import { testChatAgent } from "./test-chat.js"; + +// ── Helpers ──────────────────────────────────────────────────────────── + +let msgCounter = 0; +function userMessage(text: string): UIMessage { + return { + id: `u-${++msgCounter}`, + role: "user", + parts: [{ type: "text", text }], + }; +} + +function assistantMessage(id: string, text: string): UIMessage { + return { + id, + role: "assistant", + parts: [{ type: "text", text }], + }; +} + +function modelWithText(text: string) { + const chunks: LanguageModelV3StreamPart[] = [ + { type: "text-start", id: "t1" }, + { type: "text-delta", id: "t1", delta: text }, + { type: "text-end", id: "t1" }, + { + type: "finish", + finishReason: { unified: "stop", raw: "stop" }, + usage: { + inputTokens: { total: 10, noCache: 10, cacheRead: undefined, cacheWrite: undefined }, + outputTokens: { total: 10, text: 10, reasoning: undefined }, + }, + }, + ]; + return new MockLanguageModelV3({ + doStream: async () => ({ stream: simulateReadableStream({ chunks }) }), + }); +} + +function collectText(chunks: UIMessageChunk[]): string { + return chunks + .filter((c) => c.type === "text-delta") + .map((c) => (c as { delta: string }).delta) + .join(""); +} + +// ── Tests ────────────────────────────────────────────────────────────── + +describe("testChatAgent", () => { + describe("basic flow", () => { + it("streams the model's response on a single turn", async () => { + const model = modelWithText("hello world"); + const harness = mockChatAgent(testChatAgent, { + chatId: "test-basic", + clientData: { model }, + }); + + try { + const turn = await harness.sendMessage(userMessage("hi there")); + expect(collectText(turn.chunks)).toBe("hello world"); + } finally { + await harness.close(); + } + }); + + it("handles multiple turns with the same harness", async () => { + const model = modelWithText("ok"); + const harness = mockChatAgent(testChatAgent, { + chatId: "test-multi", + clientData: { model }, + }); + + try { + await harness.sendMessage(userMessage("first")); + await harness.sendMessage(userMessage("second")); + + // Both turns should produce model output chunks + const turn1Chunks = harness.allChunks.filter((c) => c.type === "text-delta"); + expect(turn1Chunks.length).toBeGreaterThanOrEqual(2); + } finally { + await harness.close(); + } + }); + }); + + describe("onValidateMessages (content filter)", () => { + it("blocks messages containing the forbidden phrase", async () => { + const model = modelWithText("should never reach here"); + const harness = mockChatAgent(testChatAgent, { + chatId: "test-block", + clientData: { model }, + }); + + try { + const turn = await harness.sendMessage(userMessage("hello blocked-word here")); + + // The turn completes with an error chunk, not a text chunk + expect(collectText(turn.chunks)).toBe(""); + // The turn-complete wire chunk still arrives via rawChunks + expect(turn.rawChunks.some((c) => { + return typeof c === "object" && c !== null && + (c as { type?: string }).type === "trigger:turn-complete"; + })).toBe(true); + } finally { + await harness.close(); + } + }); + + it("allows clean messages through", async () => { + const model = modelWithText("alright"); + const harness = mockChatAgent(testChatAgent, { + chatId: "test-allow", + clientData: { model }, + }); + + try { + const turn = await harness.sendMessage(userMessage("hello there")); + expect(collectText(turn.chunks)).toBe("alright"); + } finally { + await harness.close(); + } + }); + }); + + describe("hydrateMessages", () => { + it("uses clientData.hydrated as the source of truth when provided", async () => { + const model = modelWithText("ok"); + // Pre-seed the hydrated set with a prior exchange + const hydrated: UIMessage[] = [ + { id: "h1", role: "user", parts: [{ type: "text", text: "prior question" }] }, + { id: "h2", role: "assistant", parts: [{ type: "text", text: "prior answer" }] }, + ]; + + const harness = mockChatAgent(testChatAgent, { + chatId: "test-hydrate", + clientData: { model, hydrated: [...hydrated, userMessage("follow up")] }, + }); + + try { + await harness.sendMessage(userMessage("follow up")); + + // Model should have been called with the hydrated context + expect(model.doStreamCalls).toHaveLength(1); + const modelMessages = model.doStreamCalls[0]!.prompt; + expect(modelMessages.length).toBeGreaterThanOrEqual(3); + } finally { + await harness.close(); + } + }); + }); + + describe("actions", () => { + it("handles the undo action via chat.history.slice", async () => { + const model = modelWithText("ok"); + const harness = mockChatAgent(testChatAgent, { + chatId: "test-undo", + clientData: { model }, + }); + + try { + await harness.sendMessage(userMessage("first")); + await harness.sendMessage(userMessage("second")); + + // Undo — should pop the last user+assistant exchange + const undoTurn = await harness.sendAction({ type: "undo" }); + + // The turn completes normally — undo + re-respond + expect(undoTurn.rawChunks.some((c) => { + return typeof c === "object" && c !== null && + (c as { type?: string }).type === "trigger:turn-complete"; + })).toBe(true); + } finally { + await harness.close(); + } + }); + + it("rejects invalid actions", async () => { + const model = modelWithText("ok"); + const harness = mockChatAgent(testChatAgent, { + chatId: "test-invalid", + clientData: { model }, + }); + + try { + await harness.sendMessage(userMessage("hi")); + + // Send an action that doesn't match the schema + const turn = await harness.sendAction({ type: "not-a-real-action" }); + + // An error chunk should be emitted instead of a clean turn + const errorChunks = turn.rawChunks.filter((c) => { + return typeof c === "object" && c !== null && + (c as { type?: string }).type === "error"; + }); + expect(errorChunks.length).toBeGreaterThan(0); + } finally { + await harness.close(); + } + }); + }); + + describe("model interaction", () => { + it("forwards the user message to the language model", async () => { + const model = modelWithText("echo"); + const harness = mockChatAgent(testChatAgent, { + chatId: "test-forward", + clientData: { model }, + }); + + try { + await harness.sendMessage(userMessage("the quick brown fox")); + + expect(model.doStreamCalls).toHaveLength(1); + const call = model.doStreamCalls[0]!; + // The model should have received a user message with our text + const userMessages = call.prompt.filter((m) => m.role === "user"); + expect(userMessages).toHaveLength(1); + } finally { + await harness.close(); + } + }); + }); +}); diff --git a/references/ai-chat/src/trigger/test-chat.ts b/references/ai-chat/src/trigger/test-chat.ts new file mode 100644 index 00000000000..c314c941d43 --- /dev/null +++ b/references/ai-chat/src/trigger/test-chat.ts @@ -0,0 +1,77 @@ +// A focused chat.agent built for offline testing. +// +// Real agents (aiChat, aiChatHydrated, etc.) depend on Prisma, the OpenAI +// provider registry, prompts, and the deployed environment. Those are +// integration concerns. For unit tests we want a minimal agent that +// exercises the turn loop + hooks without external dependencies. +// +// The model is pulled from clientData so tests can inject a MockLanguageModelV3. + +import { chat } from "@trigger.dev/sdk/ai"; +import { streamText, type LanguageModel, type UIMessage } from "ai"; +import { z } from "zod"; + +type TestClientData = { + /** The language model to use for this turn. Tests inject MockLanguageModelV3 here. */ + model: LanguageModel; + /** Optional pre-seeded messages returned by hydrateMessages. If absent, we use whatever the frontend sent. */ + hydrated?: UIMessage[]; +}; + +function textFromFirstPart(message: UIMessage): string { + const p = message.parts?.[0]; + return p?.type === "text" ? p.text : ""; +} + +export const testChatAgent = chat + .withClientData({ + schema: z.custom((v) => !!v && typeof v === "object" && "model" in (v as object)), + }) + .agent({ + id: "test-chat", + + // Validate messages: reject anything that looks like profanity. + // A realistic content-filter example. + onValidateMessages: async ({ messages }) => { + for (const m of messages) { + if (m.role === "user") { + const text = textFromFirstPart(m).toLowerCase(); + if (text.includes("blocked-word")) { + throw new Error("Message blocked by content filter"); + } + } + } + return messages; + }, + + // Hydrate from clientData if provided — simulates loading from DB. + hydrateMessages: async ({ clientData, incomingMessages }) => { + if (clientData?.hydrated) { + return clientData.hydrated; + } + return incomingMessages; + }, + + // Custom actions: undo and rollback. + actionSchema: z.discriminatedUnion("type", [ + z.object({ type: z.literal("undo") }), + z.object({ type: z.literal("rollback"), targetMessageId: z.string() }), + ]), + + onAction: async ({ action }) => { + if (action.type === "undo") { + // Slice off the last exchange (user + assistant) + chat.history.slice(0, -2); + } else if (action.type === "rollback") { + chat.history.rollbackTo(action.targetMessageId); + } + }, + + run: async ({ messages, clientData, signal }) => { + return streamText({ + model: clientData?.model ?? "openai/gpt-4o-mini", + messages, + abortSignal: signal, + }); + }, + }); diff --git a/references/ai-chat/trigger.config.ts b/references/ai-chat/trigger.config.ts new file mode 100644 index 00000000000..47592c760b1 --- /dev/null +++ b/references/ai-chat/trigger.config.ts @@ -0,0 +1,21 @@ +import { defineConfig } from "@trigger.dev/sdk"; +import { prismaExtension } from "@trigger.dev/build/extensions/prisma"; + +export default defineConfig({ + project: process.env.TRIGGER_PROJECT_REF!, + dirs: ["./src/trigger"], + maxDuration: 3600, + runtime: "node-22", + processKeepAlive: { + enabled: true, + maxExecutionsPerProcess: 50, + }, + build: { + extensions: [ + prismaExtension({ + mode: "modern", + }), + ], + keepNames: false, + }, +}); diff --git a/references/ai-chat/tsconfig.json b/references/ai-chat/tsconfig.json new file mode 100644 index 00000000000..c1334095f87 --- /dev/null +++ b/references/ai-chat/tsconfig.json @@ -0,0 +1,27 @@ +{ + "compilerOptions": { + "target": "ES2017", + "lib": ["dom", "dom.iterable", "esnext"], + "allowJs": true, + "skipLibCheck": true, + "strict": true, + "noEmit": true, + "esModuleInterop": true, + "module": "esnext", + "moduleResolution": "bundler", + "resolveJsonModule": true, + "isolatedModules": true, + "jsx": "preserve", + "incremental": true, + "plugins": [ + { + "name": "next" + } + ], + "paths": { + "@/*": ["./src/*"] + } + }, + "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"], + "exclude": ["node_modules"] +} diff --git a/references/ai-chat/vitest.config.ts b/references/ai-chat/vitest.config.ts new file mode 100644 index 00000000000..ecd0650cac7 --- /dev/null +++ b/references/ai-chat/vitest.config.ts @@ -0,0 +1,18 @@ +import { defineConfig } from "vitest/config"; +import path from "node:path"; + +export default defineConfig({ + test: { + include: ["src/**/*.test.ts"], + globals: true, + // The ai-chat reference app has Next.js + React code that we don't + // want vitest trying to transform for these pure-logic tests. Keep + // the env on `node` (default) and let users opt into jsdom per-file. + environment: "node", + }, + resolve: { + alias: { + "@": path.resolve(__dirname, "./src"), + }, + }, +}); diff --git a/references/hello-world/src/trigger/chatAgent.ts b/references/hello-world/src/trigger/chatAgent.ts new file mode 100644 index 00000000000..da0a2af077e --- /dev/null +++ b/references/hello-world/src/trigger/chatAgent.ts @@ -0,0 +1,56 @@ +import { chat } from "@trigger.dev/sdk/ai"; +import { prompts } from "@trigger.dev/sdk"; +import { streamText, createProviderRegistry } from "ai"; +import { openai } from "@ai-sdk/openai"; +import { z } from "zod"; + +const registry = createProviderRegistry({ openai }); + +type RegistryModelId = Parameters[0]; + +const systemPrompt = prompts.define({ + id: "test-agent-system", + model: "openai:gpt-4o-mini" satisfies RegistryModelId, + config: { temperature: 0.7 }, + variables: z.object({ userId: z.string() }), + content: `You are a helpful AI assistant in the Trigger.dev playground. +The current user is {{userId}}. + +## Guidelines +- Be concise and friendly. Prefer short, direct answers. +- Use markdown formatting for code blocks and lists. +- If you don't know something, say so.`, +}); + +export const testAgent = chat + .withClientData({ + schema: z.object({ + userId: z.string().optional().default("anonymous"), + model: z.string().optional().default("openai:gpt-4o-mini"), + }), + }) + .onChatStart(async ({ clientData }) => { + const resolved = await systemPrompt.resolve({ + userId: clientData?.userId ?? "anonymous", + }); + chat.prompt.set(resolved); + }) + .agent({ + id: "test-agent", + run: async ({ messages, clientData, signal }) => { + // chat.toStreamTextOptions({ registry }) resolves the prompt's model via + // the registry and injects system prompt + telemetry automatically + const model = registry.languageModel(clientData?.model ? (clientData.model as RegistryModelId) : "openai:gpt-4o-mini") + + if (!model) { + throw new Error("Model not found"); + } + + return streamText({ + ...chat.toStreamTextOptions({ registry }), + model, + messages, + abortSignal: signal, + }); + }, + }); diff --git a/references/hello-world/src/trigger/triggerAndSubscribe.ts b/references/hello-world/src/trigger/triggerAndSubscribe.ts new file mode 100644 index 00000000000..347319159ce --- /dev/null +++ b/references/hello-world/src/trigger/triggerAndSubscribe.ts @@ -0,0 +1,276 @@ +import { logger, schemaTask, task, tasks } from "@trigger.dev/sdk"; +import { z } from "zod"; +import { setTimeout } from "timers/promises"; + +// A simple child task that does some work and returns a result +const childWork = schemaTask({ + id: "child-work", + schema: z.object({ + label: z.string(), + delayMs: z.number().default(1000), + shouldFail: z.boolean().default(false), + }), + run: async ({ label, delayMs, shouldFail }) => { + logger.info(`Child task "${label}" starting`, { delayMs, shouldFail }); + await setTimeout(delayMs); + if (shouldFail) { + throw new Error(`Child task "${label}" intentionally failed`); + } + logger.info(`Child task "${label}" done`); + return { label, completedAt: new Date().toISOString() }; + }, +}); + +// Test 1: Basic triggerAndSubscribe — single child task +export const testTriggerAndSubscribe = task({ + id: "test-trigger-and-subscribe", + run: async () => { + logger.info("Starting single triggerAndSubscribe test"); + + const result = await childWork + .triggerAndSubscribe({ label: "single", delayMs: 2000 }) + .unwrap(); + + logger.info("Got result", { result }); + return result; + }, +}); + +// Test 2: Parallel triggerAndSubscribe — multiple children concurrently +export const testParallelSubscribe = task({ + id: "test-parallel-subscribe", + run: async () => { + logger.info("Starting parallel triggerAndSubscribe test"); + + // This would fail with triggerAndWait due to preventMultipleWaits + const [result1, result2, result3] = await Promise.all([ + childWork.triggerAndSubscribe({ label: "parallel-1", delayMs: 2000 }).unwrap(), + childWork.triggerAndSubscribe({ label: "parallel-2", delayMs: 3000 }).unwrap(), + childWork.triggerAndSubscribe({ label: "parallel-3", delayMs: 1000 }).unwrap(), + ]); + + logger.info("All parallel tasks complete", { result1, result2, result3 }); + return { result1, result2, result3 }; + }, +}); + +// Test 3: Abort with cancelOnAbort: true (default) — child run gets cancelled +export const testAbortWithCancel = task({ + id: "test-abort-with-cancel", + run: async () => { + logger.info("Starting abort test (cancelOnAbort: true) — child should be cancelled"); + + const controller = new AbortController(); + + // Abort after 2 seconds + setTimeout(2000).then(() => { + logger.info("Firing abort signal"); + controller.abort(); + }); + + try { + const result = await childWork + .triggerAndSubscribe( + { label: "will-be-cancelled", delayMs: 10000 }, + { signal: controller.signal } + ) + .unwrap(); + + logger.error("Unexpected: task completed without being cancelled", { result }); + return { aborted: false, childCancelled: false, result }; + } catch (error) { + logger.info("Expected: subscription aborted and child cancelled", { + error: error instanceof Error ? error.message : String(error), + }); + return { aborted: true, childCancelled: true }; + } + }, +}); + +// Test 4: Abort with cancelOnAbort: false — child run keeps running +export const testAbortWithoutCancel = task({ + id: "test-abort-without-cancel", + run: async () => { + logger.info("Starting abort test (cancelOnAbort: false) — child should keep running"); + + const controller = new AbortController(); + + // Abort after 2 seconds + setTimeout(2000).then(() => { + logger.info("Firing abort signal"); + controller.abort(); + }); + + try { + const result = await childWork + .triggerAndSubscribe( + { label: "keeps-running", delayMs: 5000 }, + { signal: controller.signal, cancelOnAbort: false } + ) + .unwrap(); + + logger.error("Unexpected: task completed (subscription should have been aborted)", { + result, + }); + return { aborted: false, result }; + } catch (error) { + logger.info("Expected: subscription aborted but child still running", { + error: error instanceof Error ? error.message : String(error), + }); + // The child task should still complete on its own — we just stopped listening + return { aborted: true, childCancelled: false }; + } + }, +}); + +// Test 5: Abort signal already aborted before calling triggerAndSubscribe +export const testAbortAlreadyAborted = task({ + id: "test-abort-already-aborted", + run: async () => { + logger.info("Starting pre-aborted signal test"); + + const controller = new AbortController(); + controller.abort("pre-aborted"); + + try { + const result = await childWork + .triggerAndSubscribe( + { label: "should-not-run", delayMs: 1000 }, + { signal: controller.signal } + ) + .unwrap(); + + logger.error("Unexpected: task completed", { result }); + return { aborted: false }; + } catch (error) { + logger.info("Expected: immediately aborted", { + error: error instanceof Error ? error.message : String(error), + }); + return { aborted: true }; + } + }, +}); + +// Test 6: Standalone tasks.triggerAndSubscribe +export const testStandaloneSubscribe = task({ + id: "test-standalone-subscribe", + run: async () => { + logger.info("Starting standalone triggerAndSubscribe test"); + + const result = await tasks + .triggerAndSubscribe("child-work", { + label: "standalone", + delayMs: 1500, + }) + .unwrap(); + + logger.info("Got result", { result }); + return result; + }, +}); + +// Test 7: Result object without .unwrap() — success case +export const testResultSuccess = task({ + id: "test-result-success", + run: async () => { + const result = await childWork.triggerAndSubscribe({ + label: "result-success", + delayMs: 1000, + }); + + logger.info("Result object", { + ok: result.ok, + id: result.id, + taskIdentifier: result.taskIdentifier, + }); + + if (result.ok) { + logger.info("Success output", { output: result.output }); + return { ok: true, output: result.output, id: result.id }; + } else { + logger.error("Unexpected failure", { error: result.error }); + return { ok: false, error: String(result.error) }; + } + }, +}); + +// Test 8: Result object without .unwrap() — failure case +export const testResultFailure = task({ + id: "test-result-failure", + retry: { maxAttempts: 1 }, + run: async () => { + const result = await childWork.triggerAndSubscribe({ + label: "result-failure", + delayMs: 500, + shouldFail: true, + }); + + logger.info("Result object", { + ok: result.ok, + id: result.id, + taskIdentifier: result.taskIdentifier, + }); + + if (result.ok) { + logger.error("Unexpected success", { output: result.output }); + return { ok: true, output: result.output }; + } else { + logger.info("Expected failure", { error: String(result.error) }); + return { ok: false, error: String(result.error), id: result.id }; + } + }, +}); + +// Test 9: .unwrap() on a failed child — should throw SubtaskUnwrapError +export const testUnwrapFailure = task({ + id: "test-unwrap-failure", + retry: { maxAttempts: 1 }, + run: async () => { + try { + const output = await childWork + .triggerAndSubscribe({ + label: "unwrap-failure", + delayMs: 500, + shouldFail: true, + }) + .unwrap(); + + logger.error("Unexpected: unwrap succeeded", { output }); + return { threw: false, output }; + } catch (error) { + logger.info("Expected: unwrap threw", { + name: error instanceof Error ? error.name : "unknown", + message: error instanceof Error ? error.message : String(error), + }); + return { + threw: true, + errorName: error instanceof Error ? error.name : "unknown", + errorMessage: error instanceof Error ? error.message : String(error), + }; + } + }, +}); + +// Test 10: Parallel with mixed success/failure +export const testParallelMixed = task({ + id: "test-parallel-mixed", + retry: { maxAttempts: 1 }, + run: async () => { + const [success, failure] = await Promise.all([ + childWork.triggerAndSubscribe({ label: "mixed-success", delayMs: 1000 }), + childWork.triggerAndSubscribe({ label: "mixed-failure", delayMs: 500, shouldFail: true }), + ]); + + logger.info("Results", { + success: { ok: success.ok, output: success.ok ? success.output : null }, + failure: { ok: failure.ok, error: !failure.ok ? String(failure.error) : null }, + }); + + return { + successOk: success.ok, + successOutput: success.ok ? success.output : null, + failureOk: failure.ok, + failureError: !failure.ok ? String(failure.error) : null, + }; + }, +});