Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
c6c35d5
test: cover the head-start accumulator seed without hydrateMessages
ericallam Aug 20, 2026
5fef13f
fix(chat): put injected steering messages into the accumulator
ericallam Aug 20, 2026
4b0f52a
fix(chat): persist history an action rolled back
ericallam Aug 20, 2026
e399b60
fix(chat): make a response streamed from onAction part of the convers…
ericallam Aug 20, 2026
52bd98d
fix(chat): route a system-role injection to the instructions lane
ericallam Aug 20, 2026
47f2f8f
fix(chat): address review on the accumulator, instructions and action…
ericallam Aug 28, 2026
52772b5
fix(chat): report a failed action stream instead of committing it as …
ericallam Aug 28, 2026
c6b0dbd
docs(chat): note the instructions delivery path and one-shot injectio…
ericallam Aug 29, 2026
e3318cf
docs(ai-chat): say what an action persists under each persistence model
ericallam Aug 29, 2026
02e0a70
docs(ai-chat): delete the replaced answer in the regenerate example
ericallam Sep 3, 2026
e6618cf
docs(ai-chat): style-guide pass on the injection and action sections
ericallam Sep 3, 2026
1f7fb5e
docs(ai-chat): drop em dashes from the docs and changesets
ericallam Sep 3, 2026
3246d12
docs(ai-chat): drop the remaining em dashes from the actions and inje…
ericallam Sep 3, 2026
86d67fa
docs(chat): warn about the two upgrade hazards in the changesets
ericallam Sep 3, 2026
e2737f6
fix(chat): consume injected instructions per turn, not per options build
ericallam Sep 3, 2026
692c060
fix(chat): keep an injection made during the turn that consumed the lane
ericallam Sep 3, 2026
900418d
fix(chat): rebuild the model messages after a steering injection
ericallam Sep 4, 2026
83fe5ef
test(chat): cover the model-lane rebuild on a turn that captures no r…
ericallam Sep 4, 2026
6683e4b
fix(chat): keep a steer in the lanes on the createSession surface
ericallam Sep 4, 2026
bf82221
fix(chat): append a steer to the model lane instead of rebuilding it
ericallam Sep 4, 2026
dd06745
fix(chat): report a drained steer from a turn that fails
ericallam Sep 4, 2026
a0a07bb
test(chat): pin a one-shot instruction across an action
ericallam Sep 4, 2026
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, and the next turn carried on from the answer it had 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. An action that used to end quietly on a mid-stream failure now surfaces an error to the frontend, so handle it the way you handle a failed turn.
5 changes: 5 additions & 0 deletions .changeset/createsession-steering-lanes.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@trigger.dev/sdk": patch
---

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`.
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 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.

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

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.

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.
48 changes: 39 additions & 9 deletions docs/ai-chat/actions.mdx
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
---
title: "Actions"
sidebarTitle: "Actions"
description: "Custom commands sent from the frontend that mutate chat state without consuming a turn undo, rollback, edit, regenerate."
description: "Custom commands sent from the frontend that mutate chat state without consuming a turn: undo, rollback, edit, regenerate."
---

## Overview
Expand Down Expand Up @@ -70,7 +70,37 @@ 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);
await db.deleteLastAssistant(chatId); // drop the answer being replaced
const { message } = await chat.pipeAndCapture(
streamText({ model: anthropic("claude-sonnet-4-5"), messages })
);
if (message) await db.saveMessage(message); // then store the new one
}
},
```

Mirror each mutation in your store, not only the additions. A `chat.history` mutation is invisible to your database, so a regenerate is a delete *and* an insert. Saving the new answer without removing the old one leaves both in the canonical transcript, and the next hydration returns the two of them. (An append-only or branching store is the exception: there you write a new version and resolve the head on read.)

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

## Gating actions on HITL state

Expand All @@ -89,10 +119,10 @@ onAction: async ({ action, messages, signal }) => {
## Sending actions from the frontend

```ts
// Browser TriggerChatTransport
// Browser: TriggerChatTransport
const stream = await transport.sendAction(chatId, { type: "undo" });

// Server AgentChat
// Server: AgentChat
const stream = await agentChat.sendAction({ type: "rollback", targetMessageId: "msg-3" });
```

Expand All @@ -104,8 +134,8 @@ The action payload is validated against `actionSchema` on the backend; invalid a

## See also

- [`chat.history`](/ai-chat/backend#chat-history) the imperative API actions use to mutate state
- [Sending actions from the frontend](/ai-chat/frontend#sending-actions) `transport.sendAction` ergonomics
- [`hydrateMessages`](/ai-chat/lifecycle-hooks#hydratemessages) fires before `onAction` when set
- [Branching conversations](/ai-chat/patterns/branching-conversations) pairs action handlers with backend-controlled history
- [Human-in-the-loop](/ai-chat/patterns/human-in-the-loop) gating fresh actions while a tool is waiting
- [`chat.history`](/ai-chat/backend#chat-history): the imperative API actions use to mutate state
- [Sending actions from the frontend](/ai-chat/frontend#sending-actions): `transport.sendAction` ergonomics
- [`hydrateMessages`](/ai-chat/lifecycle-hooks#hydratemessages): fires before `onAction` when set
- [Branching conversations](/ai-chat/patterns/branching-conversations): pairs action handlers with backend-controlled history
- [Human-in-the-loop](/ai-chat/patterns/human-in-the-loop): gating fresh actions while a tool is waiting
74 changes: 61 additions & 13 deletions docs/ai-chat/background-injection.mdx
Original file line number Diff line number Diff line change
@@ -1,14 +1,14 @@
---
title: "Background injection"
sidebarTitle: "Background injection"
description: "Inject context from background work into the agent's conversation self-review, RAG augmentation, or any async analysis."
description: "Inject context from background work into the agent's conversation: self-review, RAG augmentation, or any async analysis."
---

## Overview

`chat.inject()` queues model messages for injection into the conversation. Messages are picked up at the start of the next turn or at the next `prepareStep` boundary (between tool-call steps).

This is the backend counterpart to [pending messages](/ai-chat/pending-messages) — pending messages come from the user via the frontend, while `chat.inject()` comes from your task code.
This is the backend counterpart to [pending messages](/ai-chat/pending-messages). Pending messages come from the user via the frontend, while `chat.inject()` comes from your task code.

## Basic usage

Expand All @@ -34,7 +34,7 @@ The most powerful pattern combines `chat.defer()` (background work) with `chat.i
export const myChat = chat.agent({
id: "my-chat",
onTurnComplete: async ({ messages }) => {
// Kick off background analysis doesn't block the turn
// Kick off background analysis, doesn't block the turn
chat.defer(
(async () => {
const analysis = await analyzeConversation(messages);
Expand Down Expand Up @@ -150,7 +150,7 @@ export const myChat = chat.agent({
});
```

The self-review runs on `claude-haiku-4-5` (fast, cheap) in the background. If the user sends another message before it completes, the coaching is still injected `chat.inject()` persists across the idle wait.
The self-review runs on `claude-haiku-4-5` (fast, cheap) in the background. If the user sends another message before it completes, the coaching is still injected, because `chat.inject()` persists across the idle wait.

## Other use cases

Expand All @@ -161,13 +161,13 @@ The self-review runs on `claude-haiku-4-5` (fast, cheap) in the background. If t

## `chat.defer` standalone

`chat.defer()` is also useful on its own, without `chat.inject()`. Any work whose timing has no resume implication analytics, audit logs, search-index writes, cache warming can run in parallel with streaming instead of in the critical path. All deferred promises are awaited (with a 5s timeout) before `onTurnComplete` fires.
`chat.defer()` is also useful on its own, without `chat.inject()`. Any work whose timing has no resume implication (analytics, audit logs, search-index writes, cache warming) can run in parallel with streaming instead of in the critical path. All deferred promises are awaited (with a 5s timeout) before `onTurnComplete` fires.

```ts
export const myChat = chat.agent({
id: "my-chat",
onTurnStart: async ({ chatId, runId }) => {
// Analytics fire-and-forget, irrelevant to resume.
// Analytics: fire-and-forget, irrelevant to resume.
chat.defer(analytics.track("turn_started", { chatId, runId }));
},
run: async ({ messages, signal }) => {
Expand All @@ -176,10 +176,10 @@ export const myChat = chat.agent({
});
```

`chat.defer()` can be called from anywhere during a turn hooks, `run()`, or nested helpers. All deferred promises are collected and awaited together before `onTurnComplete`.
`chat.defer()` can be called from anywhere during a turn: hooks, `run()`, or nested helpers. All deferred promises are collected and awaited together before `onTurnComplete`.

<Warning>
**Don't use `chat.defer()` for the message-history write in `onTurnStart`.** That write must land *before* the model starts streaming, otherwise a mid-stream page refresh will read `[]` from your DB and lose the user's message from the rendered conversation. See [Database persistence `onTurnStart`](/ai-chat/patterns/database-persistence#onturnstart). Reserve `chat.defer` for writes whose timing has no resume implication.
**Don't use `chat.defer()` for the message-history write in `onTurnStart`.** That write must land *before* the model starts streaming, otherwise a mid-stream page refresh will read `[]` from your DB and lose the user's message from the rendered conversation. See [Database persistence: `onTurnStart`](/ai-chat/patterns/database-persistence#onturnstart). Reserve `chat.defer` for writes whose timing has no resume implication.
</Warning>

## How it differs from pending messages
Expand All @@ -189,9 +189,57 @@ 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 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, so the injected block goes there
rather than 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>

- An injection applies to the next turn only. A block injected in `onTurnComplete`
shapes the following turn and is cleared after it, so it is not repeated on every
turn from then on. 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.
- 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. Merging changes the cached prefix, so a cached system prompt
gets no cache hit for as long as an injection is live. 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, and 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 All @@ -200,7 +248,7 @@ export const myChat = chat.agent({
chat.inject(messages: ModelMessage[]): void
```

Queue model messages for injection at the next opportunity. Messages persist across the idle wait between turns — they are not reset when a new turn starts.
Queue model messages for injection at the next opportunity. Messages persist across the idle wait between turns, and are not reset when a new turn starts.

**Parameters:**

Expand All @@ -209,9 +257,9 @@ Queue model messages for injection at the next opportunity. Messages persist acr
| `messages` | `ModelMessage[]` | Model messages to inject (from the `ai` package) |

Messages are drained (consumed) when:
1. A new turn starts before `run()` executes
2. A `prepareStep` boundary is reached between tool-call steps during streaming
1. A new turn starts, before `run()` executes
2. A `prepareStep` boundary is reached, between tool-call steps during streaming

<Note>
`chat.inject()` writes to an in-memory queue in the current process. It works from any code running in the same task lifecycle hooks, deferred work, tool execute functions, etc. It does not work from subtasks or other runs.
`chat.inject()` writes to an in-memory queue in the current process. It works from any code running in the same task: lifecycle hooks, deferred work, tool execute functions, etc. It does not work from subtasks or other runs.
</Note>
1 change: 1 addition & 0 deletions docs/ai-chat/testing.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -634,6 +634,7 @@ The harness's initial wire payload depends on `mode`:
| `sendHandover({ partialAssistantMessage, isFinal?, messageId? })` | Dispatch a `handover` signal — only meaningful when started with `mode: "handover-prepare"`. The agent picks up partial assistant messages and continues the turn. |
| `sendHandoverSkip()` | Dispatch a `handover-skip` signal — only meaningful when started with `mode: "handover-prepare"`. The agent exits cleanly without firing turn hooks. |
| `sendAction(action)` | Route a custom action through `actionSchema` + `onAction`. |
| `sendPendingMessage(message)` | Append a user message mid-turn without waiting for a turn to complete, so it reaches the running turn as a steering message. Resolves once the record has landed on `session.in`. |
| `sendStop(message?)` | Fire a stop signal. Does not wait for the turn — the run's `signal.aborted` becomes `true`. |
| `seedSnapshot(snapshot)` | Pre-seed the snapshot read for the next boot. Effective on the next run boot only. |
| `seedSessionOutTail(chunks?)` | Pre-seed `session.out` chunks for the next boot's replay. Reduces to settled assistant turns. |
Expand Down
Loading