feat(chat): hand run() a streamText with the managed options already applied - #4884
Conversation
The seed from payload.headStartMessages had no coverage for agents that do not register hydrateMessages, and it reads unreachable: it sits inside if (!hydrateMessages && couldHavePriorState), and couldHavePriorState is false on a head-start run. It does fire, and this pins that. Records the shape a persisting app has to handle, which is the part that actually bites: by onTurnStart the accumulator is already ['user','assistant'], because the warm route's partial is spliced in before the hook, so the incoming user message is not the last one.
drainSteeringQueue used the injected uiMessage for span attributes, the injection-confirmation chunk, the injected-ids set and onInjected — never the accumulator. So the message reached the model and the browser, appeared in neither uiMessages nor newUIMessages, and an app persisting from onTurnComplete never learned it existed. The user steers, the agent obeys, the user reloads, and their instruction is gone from the transcript and from every later turn's context. The asymmetry is the tell: a message that finds no step boundary falls back to becoming its own turn and is accumulated normally. Only the path that worked lost data. Appended at injection time rather than turn end, so the order matches what happened: after the message that started the turn, before the response that answers it. Deduplicated by id, since a boundary can drain more than once. The injection path had no test coverage at all — shouldInject appeared only in ai.ts — because the harness had no way to deliver a message mid-turn. Adds harness.sendPendingMessage() for that, which is also what a customer needs to test steering in their own suite.
The snapshot is written on the turn-complete path, and an action is not a turn — the block literally ends 'if (!isAction)'. So a chat.history mutation from onAction lived only in the running worker's memory. Undo worked while that worker stayed warm, then the next continuation booted from a snapshot still holding the undone exchange and the messages came back. onAction is exactly where the docs tell you to call rollbackTo, so this is the documented path silently not persisting. Writes the snapshot right after the action's override is applied, awaited for the same reason as the turn-complete write: the agent may suspend straight after, and in-flight promises do not reliably survive that. An action has no turn cursor, so the write reuses the last one rather than writing undefined — that would drop the resume point and make the next boot replay from further back to rebuild what it could have read.
…ation Returning a StreamTextResult from onAction piped it to the browser and stopped there. The accumulator never saw it, no snapshot recorded it, and actions fire no onTurnComplete — so the user read a good answer that the model had no memory of, and the next turn carried on from the answer regenerate had just replaced. The disagreement between the screen and the conversation was invisible until that next turn contradicted it. The action branch now captures what it pipes, using the pipeChatAndCapture that already existed for exactly this, and appends the message to the accumulator. Persistence beyond the snapshot is still the app's job, since an action fires no turn hook — pipeAndCapture hands back the same message for that. Also folds the snapshot write added for rolled-back history into one helper used by both action paths, so a regenerate that both rolls back and answers writes once rather than twice, and the cursor-preservation rule lives in one place. The two fixes needed each other: with the rollback persisted but the response dropped, a regenerate left the snapshot empty rather than stale — still wrong, just differently.
chat.inject with role 'system' put the message into the conversation, which ai@7 rejects for every provider: standardizePrompt throws before any provider is called. The next turn died with an error chunk reading 'An error occurred.' and persisted an assistant message with no parts, so from the app's side the agent had simply stopped answering. The error message names the fix — use the instructions option — and Instructions is string | SystemModelMessage | Array<SystemModelMessage>, so an injected system block has a correct home. It is appended after the base prompt, which keeps the prompt's position for caching and reads as a later amendment. This makes the documented examples right rather than rewriting them to a workaround. It also answers whether trusted mid-conversation context is supportable: it is, and only this way. A message injected as 'user' is untrusted by construction, and a well-aligned model says so and re-derives the answer from tools instead. The docs now state which lane to use for facts and which for directives. A new instruction block changes the cached prefix, so the first call carrying it misses the prompt cache. Only turns that actually injected pay it.
… paths Record only the messages a steering drain actually claimed. The loop used the offered batch, so a record another consumer took while shouldInject() awaited was written into the accumulator for a turn it was never part of. Drain the injected instructions once applied, matching the conversational lane. Left in place they were re-applied by every later toStreamTextOptions() call in the run, growing the prompt and changing its cached prefix each turn. Clean a stopped action's partial response before it is committed, and skip committing at all once the run is cancelled.
…finished pipeChatAndCapture returns a stream failure rather than throwing it, so a mid-stream failure in a response returned from onAction was committed as a complete answer, snapshotted, and followed by a normal turn-complete with no error — the browser saw the stream stop and the next turn built on the truncated text. The partial is still kept; the failure is now surfaced with it. Document that the instructions lane is delivered by chat.toStreamTextOptions(), and that an injection applies to the next inference call only.
…n in the changesets
The actions page said only that persistence was your responsibility inside onAction, which is now wrong for platform-managed agents (the runtime writes the snapshot) and too vague for app-owned ones, where a rollback and a streamed replacement both need storing and there is no onTurnComplete to do it in.
The example saved the regenerated message without removing the one it replaced, so a linear store would keep both and the next hydration would return the pair. The undo branch already deleted; the regenerate branch now does too, with a note that a history mutation is invisible to your database.
Drops the banned trivializing words, replaces future tense and "there is" throat-clearing, and removes a "two things" lead-in that sat above three bullets. Merges the two bullets that stated the same prompt-cache fact, and stops claiming the injected block is appended as an array when it is merged into a single instruction.
Recast each one as a comma, colon, parentheses, or two sentences rather than swapping in a hyphen. Also removes a stray "simply", a future tense, and a "had just been replaced" the previous pass missed in the changesets.
…ction pages Covers the prose these pages already had, not only the new sections: the frontmatter descriptions, code comments, the message-role table cell, the injection-point list, and the see-also link descriptions. Each recast as a colon, comma, parentheses, or two sentences.
Both stay patch. The double write only bites code that worked around a lost message, and the silent action completion was the bug it now reports, so neither is new functionality or an API break. The version cannot carry either signal, so the changelog entries name them instead. Also documents sendPendingMessage in the testing harness table, which listed every other send method.
Draining the lane on read handed the injection to whichever chat.toStreamTextOptions() call ran first and dropped it from the rest. A run() that builds options twice, a classifier pass and then the answer, sent the instruction to nobody if it passed the second one to streamText, with no error anywhere. Consumption is now keyed on the turn, so every build in the turn carries the same instructions and the turn after it carries none. A hand-rolled loop with no turn context still drains on read.
Consuming the instructions lane marked the blocks read but left them in it, so an injection made in that turn's onTurnComplete queued behind them and the next turn's clear destroyed both. Turn 1 carried its instruction and every turn after it silently carried none, which is worse than the per-read draining it replaced. The consumed blocks now move to turn-scoped state, so a second options build in the same turn still sees them while the lane holds only what is pending. Also guards the stash lookup: outside a turn both sides of the turn comparison are undefined, so the optional-chained check matched and dereferenced nothing.
🦋 Changeset detectedLatest commit: bf66457 The changes in this PR will be included in the next version bump. This PR includes changesets to release 27 packages
Not sure what this means? Click here to learn what changesets are. Click here if you're a maintainer who wants to add another changeset to this PR |
|
Note Reviews pausedIt looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the Use the following commands to manage reviews:
Use the checkboxes below for quick actions:
No actionable comments were generated in the recent review. 🎉 ℹ️ Recent review info⚙️ Run configurationConfiguration used: Repository UI Review profile: CHILL Plan: Team Run ID: 📒 Files selected for processing (1)
Included review availability: Your plan provides up to 10 included reviews per hour; 8 remain after this review. 📜 Recent review details⏰ Context from checks skipped due to timeout. (48)
🧰 Additional context used🪛 LanguageTool.changeset/managed-streamtext-in-run.md[style] ~5-~5: To strengthen your wording, consider replacing the phrasal verb “leave out”. (OMIT_EXCLUDE) [style] ~12-~12: ‘by accident’ might be wordy. Consider a shorter alternative. (EN_WORDINESS_PREMIUM_BY_ACCIDENT) [style] ~14-~14: To form a complete sentence, be sure to include a subject. (MISSING_IT_THERE) [grammar] ~18-~18: Ensure spelling is correct (QB_NEW_EN_ORTHOGRAPHY_ERROR_IDS_1) 🔇 Additional comments (1)
WalkthroughThe SDK now supplies managed Merge Risk: 🟡 Moderate · up to The managed streamText behavior is documented more broadly, but several published examples remain inaccurate. Most can cause incorrect integration behavior; the backend example may encourage exposing another user’s data to a model provider without server-side authorization. 🚥 Pre-merge checks | ✅ 4 | ❌ 1❌ Failed checks (1 warning)
✅ Passed checks (4 passed)
Full details: Docstring CoverageExplanation Docstring coverage is 20.00% which is insufficient. The required threshold is 80.00%. Docstring coverage is scoped to functions touched by this diff. Analyzed 5 functions across 6 files. (1 skipped: 1 unsupported.)
✨ Finishing Touches🧪 Generate unit tests (beta)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
4984f70 to
f36bb10
Compare
@trigger.dev/build
trigger.dev
@trigger.dev/core
@trigger.dev/python
@trigger.dev/react-hooks
@trigger.dev/redis-worker
@trigger.dev/rsc
@trigger.dev/schema-to-json
@trigger.dev/sdk
commit: |
|
@coderabbitai review |
✅ Action performedReview finished.
|
f36bb10 to
57bb078
Compare
The UI and model accumulators are maintained separately, and a drained message was appended to the UI one only. The model saw it through the prepareStep return value, which is per-step, so the model lane never learned it existed and every later turn of the run answered without it while the browser, the snapshot and chat.history.* all still showed it. The drain now marks the model lane stale and it is rebuilt from the UI lane at the end of the turn. Flips the it.fails repro in steering-injection.test.ts to a passing test.
1a1da14 to
45bafb0
Compare
3203120 to
b1fdca8
Compare
sendAction with per-action metadata replaced the transport defaults instead of merging them, so required default fields vanished for direct callers. sendMessages already merged before routing; sendAction now merges itself, and its docstring describes the chat.turn() model.
b1fdca8 to
871f86c
Compare
The onAction event never carried streamText or tools on main, so the removal belongs to no release note; sendAction did change (an options argument and metadata merge); and the regenerate replacement path the compaction note mentioned no longer exists.
Spreading chat.toStreamTextOptions() is the integration point for six things:
the managed prompt and its cache control, the resolved model, the prompt's
sampling config, telemetry, the skill tools, and the prepareStep that delivers
steering, compaction and injected context. Forgetting the spread drops all six
in silence, and spread order decides whether passing your own tools or
prepareStep clobbers the managed ones.
run() now receives a streamText with those options applied, so the managed
state cannot be lost by omission and the merge happens inside rather than at
the call site: tools go into the helper so skills survive, a caller system
becomes the base the prompt and injections append to, and a caller prepareStep
composes after the managed one instead of replacing it.
The signature is borrowed with typeof import("ai").streamText rather than
restated, so it resolves to whichever of ai v5/v6/v7 the user installed. The
runtime value rides the existing ESM/CJS shim that already isolates value
imports from ai.
PROTOTYPE. Typechecks and passes the suite on ai@6.0.116 and ai@7.0.66, but
adds a public registry option, does not settle what happens when caller and
managed system are both structured, and has no test for the composed
prepareStep.
An onAction handler has no tools in scope the way run() does, so a
regenerated answer built with the bound streamText could call nothing.
Omitting tools now falls back to chat.agent({ tools }); naming tools
still replaces the set for that call. onAction also receives tools.
chat.agent({ system, registry, cacheControl, systemProviderOptions })
reached only the bound streamText; the documented spread form ran without
them. The options are published for the run and toStreamTextOptions
defaults from them, caller options winning.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AxuSksX18bj1yhnLpkcQ6a
An action no longer calls the model itself; one that returns chat.turn() is followed by run(), which already receives the bound streamText and the agent's tools. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01AxuSksX18bj1yhnLpkcQ6a
onAction returns nothing or chat.turn(); the section on returning a response from an action is replaced, the frontend sends actions through useChat, and the reference drops the onAction streamText argument. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01AxuSksX18bj1yhnLpkcQ6a
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01AxuSksX18bj1yhnLpkcQ6a
…align the Head Start docs chat.toStreamTextOptions() read the agent-level system, registry, cacheControl and systemProviderOptions from a key set only inside the snapshot boot block, which a hydrateMessages agent skips, so the spread form dropped them there. The key is set on every boot now, with a test for the hydrateMessages case. Docs: the actions gating example returns chat.turn() instead of a streamText call onAction no longer receives; the extracted-loop examples import ChatStreamText, stepCountIs and ModelMessage; the Head Start pages and the chat-server docstrings list prompt among the owned options and describe tools as caller-supplied.
…ool statements The actions lifecycle flow only covered the edit-only path; it now says what happens when onAction returns chat.turn(). The Head Start step said the bound streamText already carried the caller's tools, and the migration example kept a spread comment for a call with no spread.
c9336c5 to
bf66457
Compare
…ation (triggerdotdev#4816) ## Summary **A steering message sent while the agent was answering** ```ts onTurnComplete: async ({ newUIMessages }) => { await db.saveMessages(newUIMessages); }, ``` Before: the steer reached the model for the answer it steered, and reached the browser, but never `uiMessages` or `newUIMessages`, so it was never saved and it disappeared on reload. Now it is in both. The model also forgot it from the next turn onwards. `chat.agent` keeps a UI accumulator and a model accumulator, and the drain appended to the UI one only; the model saw the message through the `prepareStep` return value, which is per-step. The model lane is advanced by appending each turn's delta, so it never learned the message existed: ``` turn 1 accumulator → [user, steer, assistant] the UI, the snapshot and chat.history.* all have it turn 2 model prompt → [user, next-user] the model answers as though it was never sent ``` The drain now hands back what it claimed and the model lane is appended to before the response is, so the order stays steer-then-answer. Appended rather than rebuilt from the UI lane: compaction replaces the model lane with a summary and deliberately leaves the UI lane whole, so a rebuild restores every message the summary had replaced. A first version of this fix did exactly that, caught in review; the steer was present in the next prompt and so was the whole pre-compaction transcript. This is also the surface disagreement the QA lane reported: a recap in the same run recalled a mid-turn steer while the managed loop denied it. The recap was reading the persisted snapshot, which is written from the UI lane. Both now agree. **The same on `chat.createSession()` and `chat.MessageAccumulator`.** Those keep their own accumulator, and the drain recorded what it claimed by pushing into a locals array only `chat.agent` populates, so there the push was a silent no-op. A mid-turn steer shaped that turn's answer and then existed nowhere: not in `turn.uiMessages`, not in `turn.messages`, and not queued as its own turn either. The drain now returns what it claimed and each surface records it in both of its lanes, appending for the same reason as above. **A steer on a turn that then fails.** The error path built `newUIMessages` from the wire message and the partial only, so a turn that failed after a steer reported everything except the steer. It is now seeded from the per-turn list. This only affected a stream that rejects (a transport failure); an AI SDK error part completes the stream and was never affected. **An undo, edit, or regenerate** ```ts onAction: async ({ action }) => { if (action.type === "undo") chat.history.slice(0, -2); }, ``` Before: the rollback lived only in the running worker. It held while that worker stayed warm, then the next continuation booted from a snapshot that still contained the undone messages. They came back, minutes later, with no error. Now the action writes the snapshot. **The rollback fix is for platform-managed persistence.** With `hydrateMessages` the runtime deliberately does not write, because your store is the source of truth, so a rollback is still yours to save, and the answer that follows `chat.turn()` reaches your store through `onTurnComplete` like any turn's. The actions page now covers both models; it previously said only that persistence was your responsibility. **Injected system context** ```ts chat.inject([{ role: "system", content: "The user just upgraded to Pro." }]); ``` Before, on AI SDK 7: every provider rejected it (`AI_InvalidPromptError` from `standardizePrompt`, thrown before any provider call). The turn ended in the app's error fallback and persisted an assistant message with no parts, so the agent looked like it had stopped answering. Now it is appended to the model's instructions, where it is also treated as trusted, which is the reason to inject context in the first place. Instructions are delivered by the helper, so a system-role injection needs it: ```ts run: async ({ messages, signal }) => streamText({ ...chat.toStreamTextOptions(), // without this, a system injection never arrives model, messages, abortSignal: signal, }), ``` The conversational lane has no such requirement. An injection also applies to the next turn only, rather than repeating on every turn after it, and within that turn it is consumed once rather than once per read, so a `run()` that builds options more than once sees the same instructions in every build. **An edit-only action is not a turn.** It used to share the turn's completion path, which fired `onTurnComplete`, kept the turn number, and consumed the one-shot instruction lane. The action branch now writes its own snapshot and completion, so the next real turn is still the next turn and still receives an instruction injected before the action. **The snapshot cursor after a failed turn.** The error path wrote its snapshot with the failed turn's completion cursor but never updated the shared cursor, so a later history-changing action, whose snapshot is cursor-neutral and reuses it, wrote the cursor from before the failed turn. A continuation would then resume from there and replay output the failed turn had superseded. The cursor moves on the error path now. This one has unit coverage only: the value is decided in-process before the upload, and the test reads the same write directly. **A steer transformed by `pendingMessages.prepare`.** The steered turn saw the transformed form; later turns saw the raw message reconverted. The pending list now carries the model messages the drain actually injected, and reconciliation appends those, on both surfaces. **A steer on a turn that then fails, in the model lane.** The previous round reported it to the hook's `newUIMessages`; it was still left pending in the model lane, so the failed turn's `messages` lacked it and the next turn received it one slot late. The catch path reconciles it now, before the partial is considered. **The steer in `onTurnComplete.newMessages`, and a history edit after a steer.** The per-turn model delta the hook reports never received the steer's model form, so append-only persistence from `newMessages` lost the model's view of it. And a `chat.history` edit after a steer was drained rebuilt the model lane from the UI lane, which already held the steer, then appended it again, so later turns received it twice. Reconciliation now writes the delta too and skips the lane append for anything a rebuild already placed. **A prepared steer after a history edit, and in a failed turn's delta.** A `chat.history` edit rebuilt the model lane from the UI lane, which put the steer's raw form back and, when a compaction override replaced that lane in the same turn, left the steer with no form at all. The rebuild now leaves consumed steers out and reconciliation appends the prepared form once; a steer the edit removed stays removed. The failed-turn delta is likewise built from the recorded forms rather than by converting the UI list, so `newMessages` reports the same form the lane holds. **An action can become a turn.** `onAction` is a state edit. To answer after the edit, return `chat.turn()`: a turn runs on the edited history with everything a turn has, the agent's system prompt and tools, steering, compaction, injected instructions, `onTurnStart` and `onTurnComplete`, numbering and persistence. Returning a `StreamTextResult`, `string` or `UIMessage` from `onAction` is no longer supported and fails with a pointer to `chat.turn()`. That path was a turn without a turn's guarantees, each of which had to be re-added by hand, and its delivery to the browser was unreliable. The edit is snapshotted before the turn starts, so a turn cut short continues from the edited history, and `run()` receives the turn with `trigger: "action-turn"`, so a handler that returns early on `"action"` still answers. Before, a regenerate handler produced the answer itself: ```ts onAction: async ({ action, streamText }) => { if (action.type === "regenerate") { chat.history.slice(0, -1); return streamText({ model, messages: await convertToModelMessages(chat.history.all()) }); } } ``` After, it edits and hands off: ```ts onAction: async ({ action }) => { if (action.type === "regenerate") { chat.history.slice(0, -1); return chat.turn(); } } ``` **Actions travel on `useChat`'s own request path.** `TriggerChatTransport` recognises `body.action` on a `useChat` request and sends it as an action, so `useChat` owns the response and a turn that follows the action renders like a message turn. `useChatActions({ sendMessage })` wraps `sendMessage(undefined, { body: { action } })`; `regenerate({ body: { action } })` works the same way. The frontend docs had said `useChat` consumed the stream `transport.sendAction` returns; it never did, so an action's answer was never rendered by an app following them. `transport.sendAction` is unchanged for callers outside `useChat`. **Approving a tool call no longer undoes compaction.** A tool-approval response arrives as an update to the existing assistant message, and that path rebuilt the model lane from the UI lane, at the start of the continuation and again when its response was committed. A chat that had been summarised to fit the context window was sent the whole transcript on the next call. The replaced message's run of model messages is now swapped in place, with a fallback to the old reconversion if the lane's tail does not match what that message contributed. ## Verification Each of the four has a test that fails without it, and each was run end to end against a deployed agent twice, once with the fix present and once with only that fix reverted, so the tests are known to fail in its absence rather than merely to pass in its presence. A 46-scenario sweep of the surrounding chat surface came back clean. One later fix, recording only the steering messages a drain actually claimed, has unit coverage only: reproducing it needs a second consumer taking a record while `shouldInject()` awaits, which the deployed harness cannot produce. The steering fix closes both halves: the durability one, and the model-context one that [triggerdotdev#4795](triggerdotdev#4795) left behind as an expected-fail test. That test is now a passing test, verified red first (turn 2's user prompts came back without the steer). The model-context fix, the `createSession` fix, the compaction interaction on both surfaces, and the failed-turn path were each run end to end against a deployed agent in both directions, with a runId guard confirming the later turns belonged to the same live run. One bundle carried the compaction regression on the `createSession` surface only: on it the compaction leg failed and the no-compaction steering leg passed, which is a direct demonstration that the earlier steering coverage was blind to the compaction interaction. The second review round's fixes (failed action, prepared steer form, failed-turn reconciliation) were run the same way, deployed in both directions. The snapshot-cursor fix has unit coverage only: the value is decided in-process before the upload. The third round (the steer in `newMessages`, and once after a history edit) was run deployed in both directions too; the duplicate count under the reverted build doubles as proof the history-edit rebuild path ran. The fourth round (a prepared steer through a history edit, with and without compaction, and in a failed turn's delta) was run deployed in both directions; the history-edit case was proven against two different reverts, since removing one half of the old code produces a duplicate and removing both makes the steer vanish. The fifth round (the tool-approval continuation) was run deployed in both directions too; the approval case was proven against each replace site separately, with a following-turn assertion that catches the response-commit site, which the continuation's own prompt cannot see. The action-to-turn path and the `useChat` routing were run deployed in both directions: a regenerate action renders its new answer through `useChat` at the timing that broke the old path, and reverting either the fall-through into the turn or the transport's `body.action` routing makes it fail. The action-reply legs from the earlier rounds are retired with the feature they tested. An action that lands while a turn is still streaming is still spliced into that turn's request stream (a pre-existing client race, not addressed here). The docs for the action model live on triggerdotdev#4884, since those pages also carry that branch's changes. --------- Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
There was a problem hiding this comment.
Devin Review found 2 new potential issues.
⚠️ 1 issue in files not directly in the diff
⚠️ Rejected actions consume a conversation turn
When onAction returns an unsupported value, this throw enters the shared turn-error path. It fires onTurnComplete and advances the turn counter. Later turns receive shifted numbers, while persistence or billing hooks record a turn that never ran.
| `actionSchema` validates; `onAction` mutates via `chat.history` (`slice`, `replace`, `rollbackTo`, | ||
| `remove`, `getPendingToolCalls`, `extractNewToolResults`). Actions fire `hydrateMessages` and | ||
| `onAction` only, never `run()` or the turn hooks. Return a `StreamTextResult`, string, or `UIMessage` | ||
| to also emit a model response. | ||
| to also emit a model response, built with the `streamText` from `onAction`'s own argument so it | ||
| carries the agent's prompt and tools like any other turn. | ||
|
|
||
| Persistence splits by model. Without `hydrateMessages` the runtime snapshots the conversation after | ||
| an action that changed it, so a rollback or a returned response survives the run ending. With | ||
| `hydrateMessages` your store is the source of truth and the runtime does not write, so mirror every | ||
| mutation yourself: a regenerate is a delete and an insert, and `chat.pipeAndCapture` hands back the | ||
| same assistant message the runtime would have captured. |
…ggerdotdev#4902) ## Summary The bundled `trigger-chat-agent-advanced` skill still told agents to answer from an action by returning a value, an API triggerdotdev#4816 removed. Code generated from it fails at runtime with the `chat.turn()` error. Before, the skill said: ```ts onAction: async ({ action, streamText }) => { if (action.type === "regenerate") { chat.history.slice(0, -1); return streamText({ model, messages }); } }, ``` After: ```ts onAction: async ({ action }) => { if (action.type === "undo") chat.history.slice(0, -2); // edit only if (action.type === "regenerate") { chat.history.slice(0, -1); return chat.turn(); // answer the edited history } }, ``` The section now covers edit-only actions, `chat.turn()` and the `action-turn` trigger, persistence for both the platform-managed and `hydrateMessages` models, and sending actions through `useChat` (`body.action` or `useChatActions`) so the answer renders, with `transport.sendAction` noted as the raw-stream path. Docs-only change to an SDK-bundled skill; no changeset, since the `chat.turn()` release note from triggerdotdev#4816 already covers the behavior. Raised by Devin on triggerdotdev#4884 after merge. 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_01AxuSksX18bj1yhnLpkcQ6a --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: ericallam <534+ericallam@users.noreply.github.com> Co-authored-by: claude[bot] <41898282+claude[bot]@users.noreply.github.com> Co-authored-by: Eric Allam <ericallam@users.noreply.github.com>
## Summary
5 new features, 37 improvements, 12 bug fixes.
## Breaking changes
- Reading a session's `.in` channel (`GET /realtime/v1/sessions/{id}/in`
and `/in/records`) now requires a secret key. Public tokens, including
`read:sessions:{id}`, get a 403; they can still read `.out` and append
to `.in`.
## Highlights
- A failed write to a realtime or chat session stream no longer crashes
the process running it, and a dropped chat session output write is now
logged instead of swallowed.
([`fb25c0149`](https://github.com/triggerdotdev/trigger.dev/commit/fb25c0149c6c734f942f6f41210b197ed4b1f736))
## Improvements
- Trigger.dev now uses Zod 4 by default. Projects using Zod 3.25.56 or
later 3.x releases remain supported.
([#4039](https://github.com/triggerdotdev/trigger.dev/pull/4039))
Zod remains a runtime dependency of packages that execute schemas, so
existing and new installations continue to receive it automatically. The
matching peer dependency range allows package managers to reuse either a
compatible Zod 3 or Zod 4 installation from your project.
- The `playwright` build extension now works with Playwright 1.58 and
later. 1.58 changed the `playwright install --dry-run` output, which
made deploy image builds fail while downloading the browsers.
([#4881](https://github.com/triggerdotdev/trigger.dev/pull/4881))
- Rename the dev error link to "Ask Trigger about this error"
([`f999516a0`](https://github.com/triggerdotdev/trigger.dev/commit/f999516a0d8ae2f3a19c76e11aae935e60c81d2c))
- Automatically archive up to three inactive development branches when
creating a branch at the plan limit. Connected and recently active
branches remain protected, and the CLI reports which branches were
archived.
([`dd55fdb5b`](https://github.com/triggerdotdev/trigger.dev/commit/dd55fdb5b821b7cb51d15cd102481c87659985ef))
- When the build log stream cannot be opened or disconnects during a
build server deploy, the CLI now explains that the deployment itself is
unaffected and exits immediately with a non-zero code, since it can no
longer confirm the outcome. Previously a disconnect printed the raw
stream error and left the process hanging.
([#4887](https://github.com/triggerdotdev/trigger.dev/pull/4887))
- Build logs no longer include docker's registry login output, most
notably the credential-storage warning on failed builds.
([#4909](https://github.com/triggerdotdev/trigger.dev/pull/4909))
- Reduce sensitive values in CLI and SDK diagnostics, secure files
created by `trigger env pull`, and remove credentials from collected Git
remote metadata.
([`ff05824c1`](https://github.com/triggerdotdev/trigger.dev/commit/ff05824c1bdf1c2276d202ed84328c948cc290a3))
- `chat.agent` persists a conversation through a `TranscriptStorage`: an
adapter with `load` and `save` that the runtime drives after every turn,
failed turn and history-changing action. The platform snapshot stays the
default; bring your own to write the conversation to your database as it
happens. Each save carries both the changes since the last one (so a row
store writes only what changed, and an undo is one `truncateAfter`) and
the whole transcript as it now stands (so a document store writes it
as-is with no state of its own).
([#4896](https://github.com/triggerdotdev/trigger.dev/pull/4896))
```ts
chat.agent({
id: "my-chat",
storage: myTranscriptStorage,
run: async ({ messages, signal, streamText }) =>
streamText({ model, messages, abortSignal: signal }),
});
```
`chat.createLoadTranscriptAction(storage)` and `useLoadTranscript` read
the conversation back the same way for every storage, and
`runTranscriptStorageTests` from `@trigger.dev/sdk/ai/test` checks an
implementation against the contract.
Compaction summaries and `chat.inject` context now survive a
continuation run, and crash recovery runs for every agent, including one
that owns its own context. `hydrateMessages` is deprecated in favour of
`loadContext` on a storage. The snapshot format is now version 2, which
older SDK versions cannot read.
- `chat.agent`: a run that recovers a session with more than one
in-flight user message no longer drops the unanswered ones if it
restarts mid-recovery. Recovered messages now hold the resume cursor
until each has been answered, so a restart re-answers the rest instead
of resuming past them. Previously the cursor could advance past messages
that were only held in memory, so a crash before they were dispatched
lost them.
([#4907](https://github.com/triggerdotdev/trigger.dev/pull/4907))
- End a chat conversation from inside the agent with `chat.close({
reason })`. The session row is closed, further sends are refused with
HTTP 409, and the run exits without scheduling a continuation, so a
budget cap, a completed goal, or a signed-out user can stop the
conversation rather than only the current run.
([`0a23814a0`](https://github.com/triggerdotdev/trigger.dev/commit/0a23814a0896205da227520bd417bd490a017379))
```ts
chat.agent({
id: "budgeted-agent",
run: async ({ messages, signal }) =>
streamText({ model: openai("gpt-4o"), messages, abortSignal: signal }),
onBeforeTurnComplete: async ({ chatId }) => {
if (await overBudget(chatId)) {
chat.close({ reason: "Monthly budget reached" });
}
},
});
```
The current turn still streams in full. Decide the close before the turn
ends (`run()`, `prepareStep`, `onBeforeTurnComplete`) so the closed
state rides out on that turn's final record and the user sees it as soon
as the answer finishes. `TriggerChatTransport` picks the close up from
the response stream or from a refused send, exposes it as
`transport.sessionStatus(chatId)` plus
`transport.sessionClosedReason(chatId)`, and stops sending and
reconnecting. Closing a session from outside with `sessions.close()` now
also reaches a live run, so an idle or suspended agent exits on its next
wake instead of waiting out its idle timeout. Writes to a closed
session's named side channels are refused with the same 409.
- Reading a page of a chat agent's conversation no longer downloads the
whole conversation. The saved transcript now carries an index, so asking
for the most recent messages fetches only those messages, and history
loads in roughly constant time however long the chat gets.
([`b7e86f2af`](https://github.com/triggerdotdev/trigger.dev/commit/b7e86f2afe1b1b4e38f1f2f00222eb172e2d0ee3))
A paged read also returns only the conversation itself. The model-side
context an agent keeps, its compacted history and any injected context,
is no longer included, so it cannot reach a browser through a
load-transcript server action.
The built-in storage is deliberately basic about long conversations:
once an agent has compacted, it keeps roughly the last hundred messages
and drops the rest, so what it rewrites each turn stops growing. A
conversation that never compacts is kept whole. If your app renders
history further back than that, give the agent your own transcript
storage.
The saved format has changed and an older SDK cannot read it, so a
deployment rolled back to an earlier version will not find a readable
transcript for conversations the newer version already saved, and those
conversations continue from the live stream tail instead. Roll forward
rather than back, or keep your own transcript storage.
- Adds the `GetDeploymentArtifactUrlResponseBody` schema for the
deployment artifact download URL endpoint.
([`1a5ad1e5f`](https://github.com/triggerdotdev/trigger.dev/commit/1a5ad1e5fbc54efbc1c61077ed966477e94a9849))
- Deployments now return the `--external-id` they were deployed under as
`externalId`, and a run can read its own from
`ctx.deployment.externalId`. Also fixes the deployments list failing
when one deployment had no git metadata.
([`879e8975b`](https://github.com/triggerdotdev/trigger.dev/commit/879e8975b13605fd7607c87bd906e640fca90755))
- Add an optional `appliedSchedulePolicy` field to the schedule API
response. It is present only when a non-overridable plan policy applies
a minimum window to a schedule (e.g. a free-plan schedule's minimum run
interval); the configured `window` continues to be returned separately
and unchanged.
([`2991bb48a`](https://github.com/triggerdotdev/trigger.dev/commit/2991bb48a284f8b0c140b7a3890f1e4ee73e224d))
- Triggering a task whose id cannot be represented in a URL (for example
an id containing an unpaired surrogate) now fails with a clear error
naming the task id, instead of a cryptic URI error.
([`ad821eaea`](https://github.com/triggerdotdev/trigger.dev/commit/ad821eaead317bfe60e6d4ca10c00b6fdcbbc5fd))
- Actions can now become turns. `onAction` edits history with
`chat.history`; to answer after the edit, return `chat.turn()` and a
turn runs on the edited history with everything a turn has: the agent's
system prompt and tools, steering, compaction, injected instructions,
`onTurnStart` and `onTurnComplete`, and persistence. A regenerate is
`chat.history.slice(0, -1); return chat.turn();`.
([#4816](https://github.com/triggerdotdev/trigger.dev/pull/4816))
```ts
onAction: async ({ action }) => {
if (action.type === "regenerate") {
chat.history.slice(0, -1);
return chat.turn();
}
if (action.type === "undo") chat.history.slice(0, -2); // edit only
},
```
Returning a `StreamTextResult`, `string` or `UIMessage` from `onAction`
is no longer supported and now fails with an error pointing to
`chat.turn()`. A response produced that way skipped every turn
guarantee, and its delivery to the browser was unreliable: the frontend
never read the stream `transport.sendAction` returned, so a regenerate
that appeared to work on the server did not render.
History edits made by an action are still persisted as before:
platform-managed snapshots are written after the edit, and apps with
their own store mirror the edit themselves.
- `run()` now receives a `streamText` with your agent's managed options
already applied, so they cannot be lost by leaving out the spread:
([#4884](https://github.com/triggerdotdev/trigger.dev/pull/4884))
```ts
run: async ({ messages, signal, streamText }) =>
streamText({ model, messages, abortSignal: signal });
```
Spreading `chat.toStreamTextOptions()` still works and is equivalent.
The difference is what happens when your options collide with the
managed ones. Passing `tools` after the spread replaces the skill tools,
and passing your own `prepareStep` replaces the managed one, which
silently switches off steering, compaction and injected context. The
managed `streamText` merges tools and composes `prepareStep` instead, so
neither can be turned off by accident.
`system` can be set at the call site, on `chat.agent({ system })`, or
through `chat.prompt.set()`, but only in one of them: setting it in two
places throws, because no single shape merges two system values across
every supported AI SDK version, and dropping one silently is the failure
this seam exists to prevent. Injected instructions append to whichever
one is in play.
`chat.agent()` also takes `registry`, `cacheControl` and
`systemProviderOptions` now, so a managed prompt's model and its cache
breakpoint no longer have to be passed at the call site.
`chat.toStreamTextOptions()` applies them as well, so spreading it into
the `streamText` imported from `ai` stays equivalent to the one `run()`
receives.
`chat.headStart` and `chat.startHeadStart` hand their `run` the same
thing, carrying the options the handover protocol depends on. There it
matters more: re-setting `messages`, `prompt`, `stopWhen` or
`abortSignal` after a spread breaks the handover rather than degrading a
feature, and nothing caught it. On the managed one those four keys are a
type error; `tools` is yours to pass.
- Actions are sent through `useChat` so a turn that follows one renders
like any turn. `TriggerChatTransport` recognises `body.action` on a
`useChat` request and sends it as an action, so `sendMessage(undefined,
{ body: { action } })` or `regenerate({ body: { action } })` sends the
action and `useChat` owns the response: it streams into the message
list, `status` and `error` behave as for a message, and `stop` works.
`useChatActions({ sendMessage })` in `@trigger.dev/sdk/chat/react` is a
two-line convenience over that.
([#4816](https://github.com/triggerdotdev/trigger.dev/pull/4816))
```tsx
const { sendMessage } = useChat({ id: chatId, transport });
const { sendAction } = useChatActions({ sendMessage });
sendAction({ type: "regenerate" });
```
Previously the frontend docs said `useChat` consumed the stream
`transport.sendAction` returns; it never did, so an action's answer was
never rendered by an app following them. `transport.sendAction` still
returns a stream that callers outside `useChat` must read, and now
accepts `{ abortSignal, metadata }`, with per-action metadata merged
over the transport's `clientData`.
- Chat sessions can now be pinned to a deployment, so a conversation
keeps talking to the agent version its release shipped with, and follows
the pin on its own when your app redeploys. Opt out with `triggerConfig:
{ externalDeploymentId: null }` or `versionSkew: "hold"`. Also fixes
`AgentChat` ignoring `maxDuration`, `region` and `lockToVersion`, and a
restored `AgentChat` session never picking up a new deployment id.
([`1133ad45e`](https://github.com/triggerdotdev/trigger.dev/commit/1133ad45e7c1edbc2ff053678e37146c155521ef))
- `chat.agent`: a `chat.history` edit made in `onTurnComplete` after a
failed turn is now kept. Previously the edit was applied only when the
turn succeeded, so a failure record or a card the hook closed on the
error path never reached the transcript.
([`3f67c71c0`](https://github.com/triggerdotdev/trigger.dev/commit/3f67c71c0c68ea53ba1859b4e31054cc5d52293a))
- `chat.agent`: after a Head Start turn whose handed-over tool call was
followed by more tool steps, the next turn no longer fails with
`tool_use ids must be unique`. The runtime kept the warm step's pending
tool call in the model context alongside the completed response that
already contained it.
([`8b72e6c06`](https://github.com/triggerdotdev/trigger.dev/commit/8b72e6c0616b1570d57f35b5e2a736791ad3c6f0))
- `chat.agent`: a continuation boot no longer re-dispatches the message
that resumed it, and a turn with no new user message no longer calls the
model. Previously a resumed run could answer the same message twice, and
the second attempt failed against providers that reject a trailing
assistant message, overwriting an answer that had already completed.
([`35e57e785`](https://github.com/triggerdotdev/trigger.dev/commit/35e57e785c6c80ee22329c597287ff88a3486e4e))
- `useTriggerChatTransport` now picks up changes to `accessToken`,
`startSession` and `fetch` on re-render, so a chat that stays mounted
while the surrounding page changes no longer keeps sending to the
endpoint captured on first render.
([`9ae9c1ae4`](https://github.com/triggerdotdev/trigger.dev/commit/9ae9c1ae43a91e21afcc6e2c97c4b63b9b0bff71))
- Steering messages are now kept in the conversation when you drive
turns yourself with `chat.createSession()` or `chat.MessageAccumulator`.
Previously a message that arrived mid-answer shaped that answer and then
existed nowhere: it was missing from `turn.uiMessages`, so an app
persisting from there never stored it, missing from `turn.messages`, so
every later turn answered as though it had never been sent, and it was
not queued as its own turn either. It now lands in both, the same way it
does on `chat.agent`.
([#4816](https://github.com/triggerdotdev/trigger.dev/pull/4816))
- Injected system context is merged into a single instruction block, so
it works on every supported AI SDK version. Note that a cached system
prompt gives up its cache entry for as long as an injection is live,
since the cached prefix has changed.
([#4816](https://github.com/triggerdotdev/trigger.dev/pull/4816))
- `chat.inject()` with `role: "system"` now works. It previously put the
system message into the conversation, which AI SDK 7 rejects for every
provider: the next turn died with a generic "An error occurred." and
persisted an empty assistant message, so the agent looked like it had
stopped answering. System-role context is now appended to the model's
instructions, which is also the only way to inject context the agent
treats as trusted.
([#4816](https://github.com/triggerdotdev/trigger.dev/pull/4816))
Two things to know. Instructions are delivered by
`chat.toStreamTextOptions()`, so a `run()` that calls `streamText`
without spreading it does not receive a system-role injection. The
conversational lane has no such requirement. And an injection applies to
the next turn only, rather than repeating on every turn that follows it.
Every inference call in that turn sees it, so a `run()` that builds
options more than once gets the same instructions each time. An
instruction injected after an action has run, and before the next
message, reaches that next turn rather than the one after it.
- Undo, edit and regenerate now survive a run ending. History rolled
back from `onAction` was only kept in the running worker's memory, so
the rollback held while that worker stayed warm and then reverted on the
next continuation. The undone messages came back, minutes later, with no
error. This also holds when the turn before the action failed: the
rollback used to be written against the cursor from before that turn, so
a continuation could replay output the failed turn had already
superseded.
([#4816](https://github.com/triggerdotdev/trigger.dev/pull/4816))
- Server-side `AgentChat` streams now reconnect when the connection
drops mid-turn instead of ending with a truncated reply, and a turn that
still cannot be resumed ends with an error rather than a silent
truncation.
([`8bf27a629`](https://github.com/triggerdotdev/trigger.dev/commit/8bf27a62937b5858f4963d65a9d7802982f1724e))
- Session public tokens can now be narrowed to one stream: `read: {
sessions: "chat_123:out" }` grants read access to that session's `.out`
channel only, without access to the session record or its other
channels.
([`33cf5701b`](https://github.com/triggerdotdev/trigger.dev/commit/33cf5701b4536012d45e365761c4a36067ea5f1d))
- Steering messages injected mid-answer are now part of the
conversation, both for your hooks and for the model on later turns.
Previously they reached the model for the answer they steered and
reached the browser, but nothing else: `onTurnComplete` never saw them,
so an app storing its own transcript lost the instruction the answer was
shaped by, and it vanished from the conversation on reload. The model
also forgot the instruction from the next turn onwards, answering as
though the message had never been sent, while the chat UI still showed
it. This holds when the steered turn fails part-way, and when
`pendingMessages.prepare` reshapes the message: later turns now see the
same form the steered turn did, not the original message.
([#4816](https://github.com/triggerdotdev/trigger.dev/pull/4816))
Approving a tool call no longer undoes compaction. A tool-approval
continuation used to rebuild the model's context from the full
conversation, so a chat that had been summarised to fit the context
window was sent the whole transcript again on the next call, and could
go over the limit it had just been compacted to avoid.
If you worked around this by saving steering messages as they arrive, in
`pendingMessages.onReceived` for example, that write now duplicates the
one you get from `newUIMessages`. Drop it, or skip messages you have
already stored.
- Reloading a chat while the agent is still answering now shows the
message being answered. Previously the incoming message was only
persisted once the turn finished, so a refresh mid-answer showed the
reply arriving with no question above it.
([`986811008`](https://github.com/triggerdotdev/trigger.dev/commit/9868110089cb08817801bc4e1026dd6c781be1be))
Adds `chat.deferBeforeOutput()` for app-owned writes that the next page
load has to see. Like `chat.defer()` the work is not awaited by the hook
that registers it, so it runs alongside the model and costs no time to
first token, but the answer is held until it lands. Use it for the
conversation or message write you previously had to `await` in
`onTurnStart`, as long as nothing else in the turn reads that write
back: it orders the write against what the frontend can see, not against
the model, so a tool that reads the same row still needs an awaited
write.
- chat.agent transcript fixes: a turn that errors before the model
produces any content no longer stores an empty assistant message, an
error thrown without a message now shows a generic error instead of a
blank one, and a custom transcript storage no longer needs to preserve
exact message JSON for a compaction to survive a continuation.
([#4910](https://github.com/triggerdotdev/trigger.dev/pull/4910))
## Bug fixes
- Fixes storage of large trigger payloads for task ids containing a
slash, which could fail the trigger with an "Invalid packet storage
path" error. It affected ids that started or ended with a slash,
contained two slashes in a row, or contained a `.` or `..` path
component. The storage path is now built from a generated id rather than
from the task id, so no task id can produce an unusable one, and
payloads that are already stored are still read from where they were
written.
([`ed37e19c9`](https://github.com/triggerdotdev/trigger.dev/commit/ed37e19c9f70495ba2a067245f8a4a83aaace2c3))
## Server changes
These changes affect the self-hosted Docker image and Trigger.dev Cloud:
- Projects that need a Node.js runtime update can now be handed to a
coding agent: the organization Projects settings page has a button that
copies a ready-to-paste prompt listing every project to update.
- Additional API keys are now enabled by default. New environments no
longer display root API keys, and existing environments can permanently
disable their visibility
- New schedules use a default CRON spread window when none is set,
distributing runs after their scheduled time instead of starting them
all at once. Set an explicit window to override the default.
- Schedules now support a configurable minimum spread window that
applies even when a smaller window is requested.
- The "Cancel in-progress runs when this limit is reached" option on the
billing limit form is now enabled by default when you first configure a
limit, so already-executing runs stop instead of continuing past the
limit. Organizations that have already saved a billing limit keep their
existing choice.
- Creating and archiving Development and Preview branches now requires
the branch management permission, which the Developer role has by
default.
- The assistant in the dashboard no longer has a monthly message limit,
so you can chat with it as much as you like.
Asking it to keep an eye on something and tell you when it happens is
rolling out gradually, so it isn't offered in every organization yet.
- Ask Trigger now opens as a floating window you can drag anywhere and
resize, and the chat header lets you switch it to a right-side panel or
fullscreen. Choose the position it opens in from your account settings.
- The Queues page Allocated tile now explains that it is the sum of your
queue concurrency limits, and no longer shows a warning color when those
add up to more than the environment limit, which is expected.
- Deleting a project now stops its pending runs. Runs that were waiting
on a `delay` or sitting in the queue are cancelled instead of executing
later, and a deleted project no longer sends task failure alerts.
- Fixes an intermittent "Invalid access token" failure caused by the
deployment log stream token expiring while a deploy was still in flight.
- Dashboard pages no longer keep polling for updates while their browser
tab is hidden, which could leave a tab you came back to showing a
connection error instead of your data. Pages refresh when you return to
the tab.
- Stop counting agent LLM calls twice. An agent framework emits a
wrapper span around the inference span that did the work, and both were
priced, so LLM cost aggregates and the AI metrics page reported roughly
double for agent workloads. Per-call figures in the run view were always
correct and are unchanged.
- Retried trigger and batch trigger requests are deduplicated again:
when the SDK automatically retries a request that the server had in fact
already accepted, you get the original run or batch back instead of a
duplicate one.
- Fix the Queues page showing "No activity" on the queue-metrics charts
for some organizations even though their metrics were being collected.
Those charts now display the collected data.
- The queue page's "Oldest wait" card now shows a single clear number
(how long the oldest waiting run has been waiting) with an explanatory
tooltip, and no longer shows a second "worst" figure that could
confusingly read lower than the headline.
- Run replication now recovers on its own after a Redis restart or
outage, in place of logging "Cannot extend an already-expired lock" and
holding the replication slot open until the server is restarted.
Deployments running under a process supervisor can set
`RUN_REPLICATION_MAX_RESUBSCRIBE_ATTEMPTS` to exit and be restarted when
a stream cannot recover.
- Reject waitpoint registrations that target a run outside the
authenticated environment
- Switching environments now keeps you on the current page when a task's
id contains a slash, instead of dropping you back to the list. The test
page for a webhook task whose id contains a slash also opens correctly
now.
- GitHub App installations are now linked only after the installing
GitHub user authorizes the App and is verified to have access to the
installation.
<details>
<summary>Raw changeset output</summary>
# Releases
## @trigger.dev/build@4.6.0
### Minor Changes
- Trigger.dev now uses Zod 4 by default. Projects using Zod 3.25.56 or
later 3.x releases remain supported.
([#4039](https://github.com/triggerdotdev/trigger.dev/pull/4039))
Zod remains a runtime dependency of packages that execute schemas, so
existing and new installations continue to receive it automatically. The
matching peer dependency range allows package managers to reuse either a
compatible Zod 3 or Zod 4 installation from your project.
### Patch Changes
- The `playwright` build extension now works with Playwright 1.58 and
later. 1.58 changed the `playwright install --dry-run` output, which
made deploy image builds fail while downloading the browsers.
([#4881](https://github.com/triggerdotdev/trigger.dev/pull/4881))
- Updated dependencies:
- `@trigger.dev/core@4.6.0`
## trigger.dev@4.6.0
### Minor Changes
- Trigger.dev now uses Zod 4 by default. Projects using Zod 3.25.56 or
later 3.x releases remain supported.
([#4039](https://github.com/triggerdotdev/trigger.dev/pull/4039))
Zod remains a runtime dependency of packages that execute schemas, so
existing and new installations continue to receive it automatically. The
matching peer dependency range allows package managers to reuse either a
compatible Zod 3 or Zod 4 installation from your project.
### Patch Changes
- Rename the dev error link to "Ask Trigger about this error"
([`f999516a0`](https://github.com/triggerdotdev/trigger.dev/commit/f999516a0d8ae2f3a19c76e11aae935e60c81d2c))
- Automatically archive up to three inactive development branches when
creating a branch at the plan limit. Connected and recently active
branches remain protected, and the CLI reports which branches were
archived.
([`dd55fdb5b`](https://github.com/triggerdotdev/trigger.dev/commit/dd55fdb5b821b7cb51d15cd102481c87659985ef))
- When the build log stream cannot be opened or disconnects during a
build server deploy, the CLI now explains that the deployment itself is
unaffected and exits immediately with a non-zero code, since it can no
longer confirm the outcome. Previously a disconnect printed the raw
stream error and left the process hanging.
([#4887](https://github.com/triggerdotdev/trigger.dev/pull/4887))
- Build logs no longer include docker's registry login output, most
notably the credential-storage warning on failed builds.
([#4909](https://github.com/triggerdotdev/trigger.dev/pull/4909))
- Reduce sensitive values in CLI and SDK diagnostics, secure files
created by `trigger env pull`, and remove credentials from collected Git
remote metadata.
([`ff05824c1`](https://github.com/triggerdotdev/trigger.dev/commit/ff05824c1bdf1c2276d202ed84328c948cc290a3))
- Updated dependencies:
- `@trigger.dev/core@4.6.0`
- `@trigger.dev/build@4.6.0`
- `@trigger.dev/schema-to-json@4.6.0`
## @trigger.dev/core@4.6.0
### Minor Changes
- `chat.agent` persists a conversation through a `TranscriptStorage`: an
adapter with `load` and `save` that the runtime drives after every turn,
failed turn and history-changing action. The platform snapshot stays the
default; bring your own to write the conversation to your database as it
happens. Each save carries both the changes since the last one (so a row
store writes only what changed, and an undo is one `truncateAfter`) and
the whole transcript as it now stands (so a document store writes it
as-is with no state of its own).
([#4896](https://github.com/triggerdotdev/trigger.dev/pull/4896))
```ts
chat.agent({
id: "my-chat",
storage: myTranscriptStorage,
run: async ({ messages, signal, streamText }) =>
streamText({ model, messages, abortSignal: signal }),
});
```
`chat.createLoadTranscriptAction(storage)` and `useLoadTranscript` read
the conversation back the same way for every storage, and
`runTranscriptStorageTests` from `@trigger.dev/sdk/ai/test` checks an
implementation against the contract.
Compaction summaries and `chat.inject` context now survive a
continuation run, and crash recovery runs for every agent, including one
that owns its own context. `hydrateMessages` is deprecated in favour of
`loadContext` on a storage. The snapshot format is now version 2, which
older SDK versions cannot read.
- Trigger.dev now uses Zod 4 by default. Projects using Zod 3.25.56 or
later 3.x releases remain supported.
([#4039](https://github.com/triggerdotdev/trigger.dev/pull/4039))
Zod remains a runtime dependency of packages that execute schemas, so
existing and new installations continue to receive it automatically. The
matching peer dependency range allows package managers to reuse either a
compatible Zod 3 or Zod 4 installation from your project.
### Patch Changes
- Automatically archive up to three inactive development branches when
creating a branch at the plan limit. Connected and recently active
branches remain protected, and the CLI reports which branches were
archived.
([`dd55fdb5b`](https://github.com/triggerdotdev/trigger.dev/commit/dd55fdb5b821b7cb51d15cd102481c87659985ef))
- `chat.agent`: a run that recovers a session with more than one
in-flight user message no longer drops the unanswered ones if it
restarts mid-recovery. Recovered messages now hold the resume cursor
until each has been answered, so a restart re-answers the rest instead
of resuming past them. Previously the cursor could advance past messages
that were only held in memory, so a crash before they were dispatched
lost them.
([#4907](https://github.com/triggerdotdev/trigger.dev/pull/4907))
- End a chat conversation from inside the agent with `chat.close({
reason })`. The session row is closed, further sends are refused with
HTTP 409, and the run exits without scheduling a continuation, so a
budget cap, a completed goal, or a signed-out user can stop the
conversation rather than only the current run.
([`0a23814a0`](https://github.com/triggerdotdev/trigger.dev/commit/0a23814a0896205da227520bd417bd490a017379))
```ts
chat.agent({
id: "budgeted-agent",
run: async ({ messages, signal }) =>
streamText({ model: openai("gpt-4o"), messages, abortSignal: signal }),
onBeforeTurnComplete: async ({ chatId }) => {
if (await overBudget(chatId)) {
chat.close({ reason: "Monthly budget reached" });
}
},
});
```
The current turn still streams in full. Decide the close before the turn
ends (`run()`, `prepareStep`, `onBeforeTurnComplete`) so the closed
state rides out on that turn's final record and the user sees it as soon
as the answer finishes. `TriggerChatTransport` picks the close up from
the response stream or from a refused send, exposes it as
`transport.sessionStatus(chatId)` plus
`transport.sessionClosedReason(chatId)`, and stops sending and
reconnecting. Closing a session from outside with `sessions.close()` now
also reaches a live run, so an idle or suspended agent exits on its next
wake instead of waiting out its idle timeout. Writes to a closed
session's named side channels are refused with the same 409.
- Reading a page of a chat agent's conversation no longer downloads the
whole conversation. The saved transcript now carries an index, so asking
for the most recent messages fetches only those messages, and history
loads in roughly constant time however long the chat gets.
([`b7e86f2af`](https://github.com/triggerdotdev/trigger.dev/commit/b7e86f2afe1b1b4e38f1f2f00222eb172e2d0ee3))
A paged read also returns only the conversation itself. The model-side
context an agent keeps, its compacted history and any injected context,
is no longer included, so it cannot reach a browser through a
load-transcript server action.
The built-in storage is deliberately basic about long conversations:
once an agent has compacted, it keeps roughly the last hundred messages
and drops the rest, so what it rewrites each turn stops growing. A
conversation that never compacts is kept whole. If your app renders
history further back than that, give the agent your own transcript
storage.
The saved format has changed and an older SDK cannot read it, so a
deployment rolled back to an earlier version will not find a readable
transcript for conversations the newer version already saved, and those
conversations continue from the live stream tail instead. Roll forward
rather than back, or keep your own transcript storage.
- Adds the `GetDeploymentArtifactUrlResponseBody` schema for the
deployment artifact download URL endpoint.
([`1a5ad1e5f`](https://github.com/triggerdotdev/trigger.dev/commit/1a5ad1e5fbc54efbc1c61077ed966477e94a9849))
- Deployments now return the `--external-id` they were deployed under as
`externalId`, and a run can read its own from
`ctx.deployment.externalId`. Also fixes the deployments list failing
when one deployment had no git metadata.
([`879e8975b`](https://github.com/triggerdotdev/trigger.dev/commit/879e8975b13605fd7607c87bd906e640fca90755))
- Add an optional `appliedSchedulePolicy` field to the schedule API
response. It is present only when a non-overridable plan policy applies
a minimum window to a schedule (e.g. a free-plan schedule's minimum run
interval); the configured `window` continues to be returned separately
and unchanged.
([`2991bb48a`](https://github.com/triggerdotdev/trigger.dev/commit/2991bb48a284f8b0c140b7a3890f1e4ee73e224d))
- A failed write to a realtime or chat session stream no longer crashes
the process running it, and a dropped chat session output write is now
logged instead of swallowed.
([`fb25c0149`](https://github.com/triggerdotdev/trigger.dev/commit/fb25c0149c6c734f942f6f41210b197ed4b1f736))
- Triggering a task whose id cannot be represented in a URL (for example
an id containing an unpaired surrogate) now fails with a clear error
naming the task id, instead of a cryptic URI error.
([`ad821eaea`](https://github.com/triggerdotdev/trigger.dev/commit/ad821eaead317bfe60e6d4ca10c00b6fdcbbc5fd))
## @trigger.dev/react-hooks@4.6.0
### Minor Changes
- Trigger.dev now uses Zod 4 by default. Projects using Zod 3.25.56 or
later 3.x releases remain supported.
([#4039](https://github.com/triggerdotdev/trigger.dev/pull/4039))
Zod remains a runtime dependency of packages that execute schemas, so
existing and new installations continue to receive it automatically. The
matching peer dependency range allows package managers to reuse either a
compatible Zod 3 or Zod 4 installation from your project.
### Patch Changes
- Updated dependencies:
- `@trigger.dev/core@4.6.0`
## @trigger.dev/redis-worker@4.6.0
### Minor Changes
- Trigger.dev now uses Zod 4 by default. Projects using Zod 3.25.56 or
later 3.x releases remain supported.
([#4039](https://github.com/triggerdotdev/trigger.dev/pull/4039))
Zod remains a runtime dependency of packages that execute schemas, so
existing and new installations continue to receive it automatically. The
matching peer dependency range allows package managers to reuse either a
compatible Zod 3 or Zod 4 installation from your project.
### Patch Changes
- Updated dependencies:
- `@trigger.dev/core@4.6.0`
## @trigger.dev/rsc@4.6.0
### Minor Changes
- Trigger.dev now uses Zod 4 by default. Projects using Zod 3.25.56 or
later 3.x releases remain supported.
([#4039](https://github.com/triggerdotdev/trigger.dev/pull/4039))
Zod remains a runtime dependency of packages that execute schemas, so
existing and new installations continue to receive it automatically. The
matching peer dependency range allows package managers to reuse either a
compatible Zod 3 or Zod 4 installation from your project.
### Patch Changes
- Updated dependencies:
- `@trigger.dev/core@4.6.0`
## @trigger.dev/schema-to-json@4.6.0
### Minor Changes
- Trigger.dev now uses Zod 4 by default. Projects using Zod 3.25.56 or
later 3.x releases remain supported.
([#4039](https://github.com/triggerdotdev/trigger.dev/pull/4039))
Zod remains a runtime dependency of packages that execute schemas, so
existing and new installations continue to receive it automatically. The
matching peer dependency range allows package managers to reuse either a
compatible Zod 3 or Zod 4 installation from your project.
### Patch Changes
- Updated dependencies:
- `@trigger.dev/core@4.6.0`
## @trigger.dev/sdk@4.6.0
### Minor Changes
- Actions can now become turns. `onAction` edits history with
`chat.history`; to answer after the edit, return `chat.turn()` and a
turn runs on the edited history with everything a turn has: the agent's
system prompt and tools, steering, compaction, injected instructions,
`onTurnStart` and `onTurnComplete`, and persistence. A regenerate is
`chat.history.slice(0, -1); return chat.turn();`.
([#4816](https://github.com/triggerdotdev/trigger.dev/pull/4816))
```ts
onAction: async ({ action }) => {
if (action.type === "regenerate") {
chat.history.slice(0, -1);
return chat.turn();
}
if (action.type === "undo") chat.history.slice(0, -2); // edit only
},
```
Returning a `StreamTextResult`, `string` or `UIMessage` from `onAction`
is no longer supported and now fails with an error pointing to
`chat.turn()`. A response produced that way skipped every turn
guarantee, and its delivery to the browser was unreliable: the frontend
never read the stream `transport.sendAction` returned, so a regenerate
that appeared to work on the server did not render.
History edits made by an action are still persisted as before:
platform-managed snapshots are written after the edit, and apps with
their own store mirror the edit themselves.
- End a chat conversation from inside the agent with `chat.close({
reason })`. The session row is closed, further sends are refused with
HTTP 409, and the run exits without scheduling a continuation, so a
budget cap, a completed goal, or a signed-out user can stop the
conversation rather than only the current run.
([`0a23814a0`](https://github.com/triggerdotdev/trigger.dev/commit/0a23814a0896205da227520bd417bd490a017379))
```ts
chat.agent({
id: "budgeted-agent",
run: async ({ messages, signal }) =>
streamText({ model: openai("gpt-4o"), messages, abortSignal: signal }),
onBeforeTurnComplete: async ({ chatId }) => {
if (await overBudget(chatId)) {
chat.close({ reason: "Monthly budget reached" });
}
},
});
```
The current turn still streams in full. Decide the close before the turn
ends (`run()`, `prepareStep`, `onBeforeTurnComplete`) so the closed
state rides out on that turn's final record and the user sees it as soon
as the answer finishes. `TriggerChatTransport` picks the close up from
the response stream or from a refused send, exposes it as
`transport.sessionStatus(chatId)` plus
`transport.sessionClosedReason(chatId)`, and stops sending and
reconnecting. Closing a session from outside with `sessions.close()` now
also reaches a live run, so an idle or suspended agent exits on its next
wake instead of waiting out its idle timeout. Writes to a closed
session's named side channels are refused with the same 409.
- `run()` now receives a `streamText` with your agent's managed options
already applied, so they cannot be lost by leaving out the spread:
([#4884](https://github.com/triggerdotdev/trigger.dev/pull/4884))
```ts
run: async ({ messages, signal, streamText }) =>
streamText({ model, messages, abortSignal: signal });
```
Spreading `chat.toStreamTextOptions()` still works and is equivalent.
The difference is what happens when your options collide with the
managed ones. Passing `tools` after the spread replaces the skill tools,
and passing your own `prepareStep` replaces the managed one, which
silently switches off steering, compaction and injected context. The
managed `streamText` merges tools and composes `prepareStep` instead, so
neither can be turned off by accident.
`system` can be set at the call site, on `chat.agent({ system })`, or
through `chat.prompt.set()`, but only in one of them: setting it in two
places throws, because no single shape merges two system values across
every supported AI SDK version, and dropping one silently is the failure
this seam exists to prevent. Injected instructions append to whichever
one is in play.
`chat.agent()` also takes `registry`, `cacheControl` and
`systemProviderOptions` now, so a managed prompt's model and its cache
breakpoint no longer have to be passed at the call site.
`chat.toStreamTextOptions()` applies them as well, so spreading it into
the `streamText` imported from `ai` stays equivalent to the one `run()`
receives.
`chat.headStart` and `chat.startHeadStart` hand their `run` the same
thing, carrying the options the handover protocol depends on. There it
matters more: re-setting `messages`, `prompt`, `stopWhen` or
`abortSignal` after a spread breaks the handover rather than degrading a
feature, and nothing caught it. On the managed one those four keys are a
type error; `tools` is yours to pass.
- `chat.agent` persists a conversation through a `TranscriptStorage`: an
adapter with `load` and `save` that the runtime drives after every turn,
failed turn and history-changing action. The platform snapshot stays the
default; bring your own to write the conversation to your database as it
happens. Each save carries both the changes since the last one (so a row
store writes only what changed, and an undo is one `truncateAfter`) and
the whole transcript as it now stands (so a document store writes it
as-is with no state of its own).
([#4896](https://github.com/triggerdotdev/trigger.dev/pull/4896))
```ts
chat.agent({
id: "my-chat",
storage: myTranscriptStorage,
run: async ({ messages, signal, streamText }) =>
streamText({ model, messages, abortSignal: signal }),
});
```
`chat.createLoadTranscriptAction(storage)` and `useLoadTranscript` read
the conversation back the same way for every storage, and
`runTranscriptStorageTests` from `@trigger.dev/sdk/ai/test` checks an
implementation against the contract.
Compaction summaries and `chat.inject` context now survive a
continuation run, and crash recovery runs for every agent, including one
that owns its own context. `hydrateMessages` is deprecated in favour of
`loadContext` on a storage. The snapshot format is now version 2, which
older SDK versions cannot read.
- Actions are sent through `useChat` so a turn that follows one renders
like any turn. `TriggerChatTransport` recognises `body.action` on a
`useChat` request and sends it as an action, so `sendMessage(undefined,
{ body: { action } })` or `regenerate({ body: { action } })` sends the
action and `useChat` owns the response: it streams into the message
list, `status` and `error` behave as for a message, and `stop` works.
`useChatActions({ sendMessage })` in `@trigger.dev/sdk/chat/react` is a
two-line convenience over that.
([#4816](https://github.com/triggerdotdev/trigger.dev/pull/4816))
```tsx
const { sendMessage } = useChat({ id: chatId, transport });
const { sendAction } = useChatActions({ sendMessage });
sendAction({ type: "regenerate" });
```
Previously the frontend docs said `useChat` consumed the stream
`transport.sendAction` returns; it never did, so an action's answer was
never rendered by an app following them. `transport.sendAction` still
returns a stream that callers outside `useChat` must read, and now
accepts `{ abortSignal, metadata }`, with per-action metadata merged
over the transport's `clientData`.
- Trigger.dev now uses Zod 4 by default. Projects using Zod 3.25.56 or
later 3.x releases remain supported.
([#4039](https://github.com/triggerdotdev/trigger.dev/pull/4039))
Zod remains a runtime dependency of packages that execute schemas, so
existing and new installations continue to receive it automatically. The
matching peer dependency range allows package managers to reuse either a
compatible Zod 3 or Zod 4 installation from your project.
### Patch Changes
- Chat sessions can now be pinned to a deployment, so a conversation
keeps talking to the agent version its release shipped with, and follows
the pin on its own when your app redeploys. Opt out with `triggerConfig:
{ externalDeploymentId: null }` or `versionSkew: "hold"`. Also fixes
`AgentChat` ignoring `maxDuration`, `region` and `lockToVersion`, and a
restored `AgentChat` session never picking up a new deployment id.
([`1133ad45e`](https://github.com/triggerdotdev/trigger.dev/commit/1133ad45e7c1edbc2ff053678e37146c155521ef))
- `chat.agent`: a `chat.history` edit made in `onTurnComplete` after a
failed turn is now kept. Previously the edit was applied only when the
turn succeeded, so a failure record or a card the hook closed on the
error path never reached the transcript.
([`3f67c71c0`](https://github.com/triggerdotdev/trigger.dev/commit/3f67c71c0c68ea53ba1859b4e31054cc5d52293a))
- `chat.agent`: after a Head Start turn whose handed-over tool call was
followed by more tool steps, the next turn no longer fails with
`tool_use ids must be unique`. The runtime kept the warm step's pending
tool call in the model context alongside the completed response that
already contained it.
([`8b72e6c06`](https://github.com/triggerdotdev/trigger.dev/commit/8b72e6c0616b1570d57f35b5e2a736791ad3c6f0))
- `chat.agent`: a continuation boot no longer re-dispatches the message
that resumed it, and a turn with no new user message no longer calls the
model. Previously a resumed run could answer the same message twice, and
the second attempt failed against providers that reject a trailing
assistant message, overwriting an answer that had already completed.
([`35e57e785`](https://github.com/triggerdotdev/trigger.dev/commit/35e57e785c6c80ee22329c597287ff88a3486e4e))
- `chat.agent`: a run that recovers a session with more than one
in-flight user message no longer drops the unanswered ones if it
restarts mid-recovery. Recovered messages now hold the resume cursor
until each has been answered, so a restart re-answers the rest instead
of resuming past them. Previously the cursor could advance past messages
that were only held in memory, so a crash before they were dispatched
lost them.
([#4907](https://github.com/triggerdotdev/trigger.dev/pull/4907))
- Reading a page of a chat agent's conversation no longer downloads the
whole conversation. The saved transcript now carries an index, so asking
for the most recent messages fetches only those messages, and history
loads in roughly constant time however long the chat gets.
([`b7e86f2af`](https://github.com/triggerdotdev/trigger.dev/commit/b7e86f2afe1b1b4e38f1f2f00222eb172e2d0ee3))
A paged read also returns only the conversation itself. The model-side
context an agent keeps, its compacted history and any injected context,
is no longer included, so it cannot reach a browser through a
load-transcript server action.
The built-in storage is deliberately basic about long conversations:
once an agent has compacted, it keeps roughly the last hundred messages
and drops the rest, so what it rewrites each turn stops growing. A
conversation that never compacts is kept whole. If your app renders
history further back than that, give the agent your own transcript
storage.
The saved format has changed and an older SDK cannot read it, so a
deployment rolled back to an earlier version will not find a readable
transcript for conversations the newer version already saved, and those
conversations continue from the live stream tail instead. Roll forward
rather than back, or keep your own transcript storage.
- `useTriggerChatTransport` now picks up changes to `accessToken`,
`startSession` and `fetch` on re-render, so a chat that stays mounted
while the surrounding page changes no longer keeps sending to the
endpoint captured on first render.
([`9ae9c1ae4`](https://github.com/triggerdotdev/trigger.dev/commit/9ae9c1ae43a91e21afcc6e2c97c4b63b9b0bff71))
- Steering messages are now kept in the conversation when you drive
turns yourself with `chat.createSession()` or `chat.MessageAccumulator`.
Previously a message that arrived mid-answer shaped that answer and then
existed nowhere: it was missing from `turn.uiMessages`, so an app
persisting from there never stored it, missing from `turn.messages`, so
every later turn answered as though it had never been sent, and it was
not queued as its own turn either. It now lands in both, the same way it
does on `chat.agent`.
([#4816](https://github.com/triggerdotdev/trigger.dev/pull/4816))
- Injected system context is merged into a single instruction block, so
it works on every supported AI SDK version. Note that a cached system
prompt gives up its cache entry for as long as an injection is live,
since the cached prefix has changed.
([#4816](https://github.com/triggerdotdev/trigger.dev/pull/4816))
- `chat.inject()` with `role: "system"` now works. It previously put the
system message into the conversation, which AI SDK 7 rejects for every
provider: the next turn died with a generic "An error occurred." and
persisted an empty assistant message, so the agent looked like it had
stopped answering. System-role context is now appended to the model's
instructions, which is also the only way to inject context the agent
treats as trusted.
([#4816](https://github.com/triggerdotdev/trigger.dev/pull/4816))
Two things to know. Instructions are delivered by
`chat.toStreamTextOptions()`, so a `run()` that calls `streamText`
without spreading it does not receive a system-role injection. The
conversational lane has no such requirement. And an injection applies to
the next turn only, rather than repeating on every turn that follows it.
Every inference call in that turn sees it, so a `run()` that builds
options more than once gets the same instructions each time. An
instruction injected after an action has run, and before the next
message, reaches that next turn rather than the one after it.
- A failed write to a realtime or chat session stream no longer crashes
the process running it, and a dropped chat session output write is now
logged instead of swallowed.
([`fb25c0149`](https://github.com/triggerdotdev/trigger.dev/commit/fb25c0149c6c734f942f6f41210b197ed4b1f736))
- Undo, edit and regenerate now survive a run ending. History rolled
back from `onAction` was only kept in the running worker's memory, so
the rollback held while that worker stayed warm and then reverted on the
next continuation. The undone messages came back, minutes later, with no
error. This also holds when the turn before the action failed: the
rollback used to be written against the cursor from before that turn, so
a continuation could replay output the failed turn had already
superseded.
([#4816](https://github.com/triggerdotdev/trigger.dev/pull/4816))
- Reduce sensitive values in CLI and SDK diagnostics, secure files
created by `trigger env pull`, and remove credentials from collected Git
remote metadata.
([`ff05824c1`](https://github.com/triggerdotdev/trigger.dev/commit/ff05824c1bdf1c2276d202ed84328c948cc290a3))
- Server-side `AgentChat` streams now reconnect when the connection
drops mid-turn instead of ending with a truncated reply, and a turn that
still cannot be resumed ends with an error rather than a silent
truncation.
([`8bf27a629`](https://github.com/triggerdotdev/trigger.dev/commit/8bf27a62937b5858f4963d65a9d7802982f1724e))
- Session public tokens can now be narrowed to one stream: `read: {
sessions: "chat_123:out" }` grants read access to that session's `.out`
channel only, without access to the session record or its other
channels.
([`33cf5701b`](https://github.com/triggerdotdev/trigger.dev/commit/33cf5701b4536012d45e365761c4a36067ea5f1d))
- Fixes storage of large trigger payloads for task ids containing a
slash, which could fail the trigger with an "Invalid packet storage
path" error. It affected ids that started or ended with a slash,
contained two slashes in a row, or contained a `.` or `..` path
component. The storage path is now built from a generated id rather than
from the task id, so no task id can produce an unusable one, and
payloads that are already stored are still read from where they were
written.
([`ed37e19c9`](https://github.com/triggerdotdev/trigger.dev/commit/ed37e19c9f70495ba2a067245f8a4a83aaace2c3))
- Steering messages injected mid-answer are now part of the
conversation, both for your hooks and for the model on later turns.
Previously they reached the model for the answer they steered and
reached the browser, but nothing else: `onTurnComplete` never saw them,
so an app storing its own transcript lost the instruction the answer was
shaped by, and it vanished from the conversation on reload. The model
also forgot the instruction from the next turn onwards, answering as
though the message had never been sent, while the chat UI still showed
it. This holds when the steered turn fails part-way, and when
`pendingMessages.prepare` reshapes the message: later turns now see the
same form the steered turn did, not the original message.
([#4816](https://github.com/triggerdotdev/trigger.dev/pull/4816))
Approving a tool call no longer undoes compaction. A tool-approval
continuation used to rebuild the model's context from the full
conversation, so a chat that had been summarised to fit the context
window was sent the whole transcript again on the next call, and could
go over the limit it had just been compacted to avoid.
If you worked around this by saving steering messages as they arrive, in
`pendingMessages.onReceived` for example, that write now duplicates the
one you get from `newUIMessages`. Drop it, or skip messages you have
already stored.
- Reloading a chat while the agent is still answering now shows the
message being answered. Previously the incoming message was only
persisted once the turn finished, so a refresh mid-answer showed the
reply arriving with no question above it.
([`986811008`](https://github.com/triggerdotdev/trigger.dev/commit/9868110089cb08817801bc4e1026dd6c781be1be))
Adds `chat.deferBeforeOutput()` for app-owned writes that the next page
load has to see. Like `chat.defer()` the work is not awaited by the hook
that registers it, so it runs alongside the model and costs no time to
first token, but the answer is held until it lands. Use it for the
conversation or message write you previously had to `await` in
`onTurnStart`, as long as nothing else in the turn reads that write
back: it orders the write against what the frontend can see, not against
the model, so a tool that reads the same row still needs an awaited
write.
- chat.agent transcript fixes: a turn that errors before the model
produces any content no longer stores an empty assistant message, an
error thrown without a message now shows a generic error instead of a
blank one, and a custom transcript storage no longer needs to preserve
exact message JSON for a compaction to survive a continuation.
([#4910](https://github.com/triggerdotdev/trigger.dev/pull/4910))
- Updated dependencies:
- `@trigger.dev/core@4.6.0`
## @trigger.dev/python@4.6.0
### Patch Changes
- Updated dependencies:
- `@trigger.dev/sdk@4.6.0`
- `@trigger.dev/core@4.6.0`
- `@trigger.dev/build@4.6.0`
</details>
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Summary
Every
run()had to spreadchat.toStreamTextOptions(), and leaving it out dropped six things with no error: the managed prompt and its cache control, the registry-resolved model, the prompt's sampling config, telemetry, the skill tools, and theprepareStepthat delivers steering, compaction and injected context.Before:
After:
streamTextcomes fromrun's argument and shadows the one imported fromai, so the correct call is now the shorter one and the managed options cannot be lost by omission.chat.toStreamTextOptions()is unchanged and still supported, and is still the only option in a custom agent.What changes when your options collide with the managed ones
Spread order decides the outcome today, and losing is silent:
The managed
streamTextmerges instead.toolsare passed into the helper so skill tools survive, and aprepareStepyou pass runs after the managed one rather than replacing it. Everything else you name is left alone and wins, telemetry included.systemis the exception: it can be set onchat.agent({ system }), throughchat.prompt.set(), or at the call site, but only in one of them. Two at once throws and names the one that already owns it. No shape merges two system values across every supported AI SDK version, since v5 rejects an array of blocks and a structured block carries the provider options that make prompt caching work.chat.headStart and chat.startHeadStart
buildStreamTextOptionssuppliesmessages,stopWhen: stepCountIs(1)andabortSignal. Step 1 belongs to the route handler and step 2 onward to the agent, so re-settingstopWhenafter a spread hands over a stream that has already run past step 1.Before:
After:
Passing
messages,prompt,stopWhenorabortSignalto thatstreamTextis a type error, with a runtime throw behind it for JavaScript callers.toolsis yours to pass. The old shape only warned in prose.Also in here
chat.agent()takessystem,registry,cacheControlandsystemProviderOptions, so a managed prompt's model and its cache breakpoint no longer have to be passed at the call site.ChatStreamTextis exported for typing a loop factored out ofrun.The signature is taken from the AI SDK's own declaration:
The peer range spans
aiv5, v6 and v7, whose options differ.typeofresolves to whichever version is installed, so generics and tool inference are the caller's own and a v8 option needs no change here.Actions.
onActionno longer receivesstreamTextortools: an action is a state edit, and one that returnschat.turn()(added in #4816) is followed byrun(), which already has both. The action docs on this branch describe that model.chat.toStreamTextOptions()now also applieschat.agent'ssystem,registry,cacheControlandsystemProviderOptions, so the spread form is equivalent to thestreamTexthanded torun(), as the docs say; previously an agent's system prompt was silently dropped on that path. Those options are published on every boot, including for ahydrateMessagesagent, which skips the snapshot boot block where they were first set.Verification
Typecheck and the full suite pass on both
ai@6.0.116andai@7.0.66. The option merge is a pure function so the merged object can be asserted directly, which is howexperimental_telemetrybeing dropped was caught: moststreamTextoptions never reach the provider, so a test that observes the model cannot see them.Run end to end against a deployed agent with every
runrewritten to the new form and no spread anywhere: steering, undo across a cold boot, and regenerate all still pass, a caller's ownprepareStepruns while managed steering still fires inside the turn, and consecutive injections arrive one per turn. The handover-owned options are pinned by@ts-expect-errorassertions in a typechecked test rather than only by the runtime throw.