Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions .changeset/action-stream-into-conversation.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
"@trigger.dev/sdk": patch
---

A response streamed back from `onAction` is now part of the conversation. Returning a `StreamTextResult` from an action sent it to the browser and nowhere else, so a regenerate showed the user a new answer that the model had no memory of — the next turn carried on from the answer that had just been replaced.

A stream that fails part-way through is also no longer committed as though it finished. Whatever streamed is still kept, but the failure is reported instead of the truncated text being stored, and built on, as a complete answer.
5 changes: 5 additions & 0 deletions .changeset/inject-instructions-shape.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@trigger.dev/sdk": patch
---

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.
7 changes: 7 additions & 0 deletions .changeset/inject-system-to-instructions.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
"@trigger.dev/sdk": patch
---

`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 simply stopped answering. System-role context is now appended to the model's instructions, which is also the only way to inject context the agent will treat as trusted.

Two things to know. Instructions are delivered by `chat.toStreamTextOptions()`, so a `run()` that calls `streamText` without spreading it will not receive a system-role injection — the conversational lane has no such requirement. And an injection applies to the next inference call only, rather than repeating on every turn that follows it.
5 changes: 5 additions & 0 deletions .changeset/persist-action-history-mutations.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@trigger.dev/sdk": patch
---

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.
5 changes: 5 additions & 0 deletions .changeset/steering-messages-accumulator.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@trigger.dev/sdk": patch
---

Steering messages injected mid-answer are now part of the conversation your hooks see. Previously they reached the model and the browser but not `onTurnComplete`, so an app storing its own transcript lost the instruction the answer was shaped by — it vanished from the conversation on reload, and later turns had no record of it.
29 changes: 28 additions & 1 deletion docs/ai-chat/actions.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -70,7 +70,34 @@ onAction: async ({ action, messages }) => {
}
```

This is useful for actions that both mutate state and want a fresh model response (regenerate-from-here, retry-with-different-style). Persistence is your responsibility inside `onAction` itself; you have access to the streamed response object.
This is useful for actions that both mutate state and want a fresh model response (regenerate-from-here, retry-with-different-style).

### Actions and persistence

An action is not a turn, so `onTurnComplete` never fires — and that is where an app that owns its own transcript normally writes. What that means depends on which persistence model you use.

**Platform-managed** (no `hydrateMessages`): nothing to do. After an action that changed the conversation — a `chat.history` mutation, a response returned from `onAction`, or both — the runtime writes the snapshot, so the change survives the run ending.

**Your own store** (`hydrateMessages` registered): the runtime deliberately does not write, because your store is the source of truth. A history mutation and a returned response both live only in the running worker until you persist them, and a continuation rehydrates from your store, not from what the worker had in memory. `chat.pipeAndCapture` hands you the same assistant message the runtime would have captured:

```ts
onAction: async ({ action, messages }) => {
if (action.type === "undo") {
chat.history.slice(0, -2);
await db.deleteLastExchange(chatId); // the rollback is yours to persist
}

if (action.type === "regenerate") {
chat.history.slice(0, -1);
const { message } = await chat.pipeAndCapture(
streamText({ model: anthropic("claude-sonnet-4-5"), messages })
);
if (message) await db.saveMessage(message); // and so is the replacement
Comment on lines +90 to +95

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

🔎 Supported by static analysis

🏁 Script executed:

printf '%s\n' '--- repository scopes ---'
find /tmp/coderabbit-repo-knowledge/triggerdotdev-trigger-dev-0bdd0019 -maxdepth 2 -type f -name '*.md' -print
printf '%s\n' '--- target document ---'
cat -n docs/ai-chat/actions.mdx | sed -n '60,110p'
printf '%s\n' '--- directly bound symbols and persistence references ---'
rg -n --glob '!node_modules' --glob '!dist' 'hydrateMessages|pipeAndCapture|saveMessage|chat\.history' docs src packages 2>/dev/null | head -160

Repository: triggerdotdev/trigger.dev

Length of output: 41377


🏁 Script executed:

printf '%s\n' '--- applicable conventions ---'
cat /tmp/coderabbit-repo-knowledge/triggerdotdev-trigger-dev-0bdd0019/conventions/docs.md
printf '%s\n' '--- persistence and history contracts ---'
cat -n docs/ai-chat/backend.mdx | sed -n '465,545p'
cat -n docs/ai-chat/lifecycle-hooks.mdx | sed -n '261,325p'
cat -n docs/ai-chat/patterns/persistence-and-replay.mdx | sed -n '117,172p'
printf '%s\n' '--- regenerate and persistence examples ---'
cat -n docs/ai-chat/changelog.mdx | sed -n '525,555p'
cat -n docs/ai-chat/patterns/branching-conversations.mdx | sed -n '1,180p'
printf '%s\n' '--- implementation files containing the relevant API names ---'
git ls-files | rg '(^|/)(chat|agent|history|accumulator|.*ai.*)\\.(ts|tsx|js|jsx)$' | head -120

Repository: triggerdotdev/trigger.dev

Length of output: 24166


🏁 Script executed:

printf '%s\n' '--- exact implementation and test references ---'
rg -n --glob '!docs/**' --glob '!node_modules/**' --glob '!dist/**' \
  'pipeAndCapture|class .*History|history\.slice|onAction:|regenerate' . | head -240
printf '%s\n' '--- tracked files containing pipeAndCapture ---'
git grep -n 'pipeAndCapture' -- ':!docs/**' ':!node_modules/**' | head -120

Repository: triggerdotdev/trigger.dev

Length of output: 29090


🏁 Script executed:

printf '%s\n' '--- action persistence path in runtime ---'
cat -n packages/trigger-sdk/src/v3/ai.ts | sed -n '8000,8070p'
cat -n packages/trigger-sdk/src/v3/ai.ts | sed -n '10320,10490p'
printf '%s\n' '--- action accumulator tests ---'
cat -n packages/trigger-sdk/test/action-stream-accumulator.test.ts | sed -n '1,175p'
printf '%s\n' '--- relevant runtime tests ---'
cat -n packages/trigger-sdk/test/mockChatAgent.test.ts | sed -n '940,1010p'

Repository: triggerdotdev/trigger.dev

Length of output: 23113


🏁 Script executed:

printf '%s\n' '--- action response merge and persistence boundary ---'
cat -n packages/trigger-sdk/src/v3/ai.ts | sed -n '8060,8125p'
rg -n -A45 -B8 'async addResponse|function addResponse|chatPipeAndCapture|hydrateMessages.*source of truth|customer store' packages/trigger-sdk/src/v3/ai.ts
printf '%s\n' '--- accumulator mutation implementation ---'
rg -n -A35 -B10 'slice\\(start|slice\\(0, -1|class ChatHistory|history:' packages/trigger-sdk/src/v3/ai.ts | head -180

Repository: triggerdotdev/trigger.dev

Length of output: 8671


Persist the regenerated message as a replacement in linear stores.

With hydrateMessages, the runtime skips snapshot writes and onTurnComplete. chat.history.slice(0, -1) changes only the accumulator, while chat.pipeAndCapture returns the new assistant message. If db.saveMessage(message) appends or upserts under a new ID, the old answer remains in the canonical store and the next hydration can return both answers. Use an atomic replacement or full-history update for linear transcripts, or document that this example uses an append-only branching store.

}
},
```

Returning the stream instead of piping it yourself still works and still reaches the browser — you just have no message to store, so the next run will not know about it.

## Gating actions on HITL state

Expand Down
55 changes: 54 additions & 1 deletion docs/ai-chat/background-injection.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -189,9 +189,62 @@ export const myChat = chat.agent({
| **Source** | Backend task code | Frontend user input |
| **Triggered by** | Your code (e.g. `onTurnComplete` + `chat.defer()`) | User sending a message during streaming |
| **Injection point** | Start of next turn, or next `prepareStep` boundary | Next `prepareStep` boundary only |
| **Message role** | Any (`system`, `user`, `assistant`) | Typically `user` |
| **Message role** | Any `system` becomes an instruction, others join the conversation (see below) | Typically `user` |
| **Frontend visibility** | Not visible unless you write custom `data-*` chunks | Visible via `usePendingMessages` hook |

## Two lanes: trusted and untrusted

The role you inject with decides more than position — it decides whether the model
treats the content as trustworthy.

**`role: "system"` goes to the instructions lane.** The block is appended to the
system instructions for subsequent inference calls, so it carries the same standing
as your system prompt. This is the lane for context the agent should simply believe:
entitlements, plan changes, operational notices.

It has to work this way. On AI SDK 7 a system message inside `messages` is rejected
for every provider — `standardizePrompt` throws before any provider is called, and
its own advice is to use the instructions option. `Instructions` accepts
`Array<SystemModelMessage>`, so the injected block is appended there rather than
smuggled into the transcript.

<Warning>
The instructions lane is delivered by `chat.toStreamTextOptions()`, because that
is the only place the SDK can set `streamText`'s instructions for you. If your
`run()` calls `streamText({ model, messages, abortSignal })` without spreading
`chat.toStreamTextOptions()`, a `role: "system"` injection never reaches the
model. The conversational lane has no such requirement — it arrives through
`messages` either way.
</Warning>

Two things worth knowing:

- An injection applies to the next inference call only. The lane is drained once
applied, so a block injected in `onTurnComplete` shapes the following turn and is
not repeated on every turn after it.
- A new instruction block changes the cached prefix, so the first call carrying it
misses the prompt cache. Only the turns where something was actually injected pay
that.
- The injected text is merged into a single instruction rather than added as a
second block, because AI SDK 5 rejects an array of system blocks while accepting
one structured block. That means a cached system prompt loses its cache entry for
as long as an injection is live — the prefix changed, so there is nothing to hit.
If you rely on prompt caching, inject sparingly and prefer facts that go stale, so
the injection clears.

**Any other role joins the conversation, and is untrusted by construction.** A
message injected as `user` is indistinguishable from something the user typed, and a
well-aligned model treats it accordingly — it may say so and re-derive the answer
from tools instead of taking it at face value:

> "that text arrived embedded in your message, not from a tool I called, so I
> verified it myself rather than trusting it"

That is correct behaviour, not a bug. So inject **checkable facts** in the
conversational lane and put **directives** in the instructions lane. A conclusion
injected as a user message is the worst of both: the model neither trusts it nor
ignores it, and may contradict it in front of the user.

## API reference

### chat.inject()
Expand Down
Loading