Skip to content

feat(chat): hand run() a streamText with the managed options already applied - #4884

Merged
ericallam merged 46 commits into
mainfrom
feat/chat-bound-streamtext
Sep 7, 2026
Merged

feat(chat): hand run() a streamText with the managed options already applied#4884
ericallam merged 46 commits into
mainfrom
feat/chat-bound-streamtext

Conversation

@ericallam

@ericallam ericallam commented Sep 3, 2026

Copy link
Copy Markdown
Member

Summary

Every run() had to spread chat.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 the prepareStep that delivers steering, compaction and injected context.

Before:

import { chat } from "@trigger.dev/sdk/ai";
import { streamText, stepCountIs } from "ai";
import { anthropic } from "@ai-sdk/anthropic";

export const myChat = chat.agent({
  id: "my-chat",
  tools: { myTool },
  run: async ({ messages, tools, signal }) =>
    streamText({
      ...chat.toStreamTextOptions({ registry, tools }),
      model: anthropic("claude-sonnet-4-5"),
      system: "You are a helpful assistant.",
      messages,
      abortSignal: signal,
      stopWhen: stepCountIs(15),
    }),
});

After:

import { chat } from "@trigger.dev/sdk/ai";
import { stepCountIs } from "ai";
import { anthropic } from "@ai-sdk/anthropic";

export const myChat = chat.agent({
  id: "my-chat",
  system: "You are a helpful assistant.",
  registry,
  tools: { myTool },
  run: async ({ messages, tools, signal, streamText }) =>
    streamText({
      model: anthropic("claude-sonnet-4-5"),
      messages,
      tools,
      abortSignal: signal,
      stopWhen: stepCountIs(15),
    }),
});

streamText comes from run's argument and shadows the one imported from ai, 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:

streamText({ ...chat.toStreamTextOptions(), tools: myTools })       // skill tools dropped
streamText({ ...chat.toStreamTextOptions(), prepareStep: mine })    // steering, compaction and injection off

The managed streamText merges instead. tools are passed into the helper so skill tools survive, and a prepareStep you pass runs after the managed one rather than replacing it. Everything else you name is left alone and wins, telemetry included.

system is the exception: it can be set on chat.agent({ system }), through chat.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

buildStreamTextOptions supplies messages, stopWhen: stepCountIs(1) and abortSignal. Step 1 belongs to the route handler and step 2 onward to the agent, so re-setting stopWhen after a spread hands over a stream that has already run past step 1.

Before:

import { streamText, stepCountIs } from "ai";

export const POST = chat.headStart({
  agentId: "my-chat",
  run: async ({ chat: helper }) =>
    streamText({
      ...helper.toStreamTextOptions({ tools: headStartTools }),
      model: anthropic("claude-sonnet-4-6"),
      system: "You are a helpful assistant.",
    }),
});

After:

export const POST = chat.headStart({
  agentId: "my-chat",
  run: async ({ streamText }) =>
    streamText({
      model: anthropic("claude-sonnet-4-6"),
      system: "You are a helpful assistant.",
      tools: headStartTools,
    }),
});

Passing messages, prompt, stopWhen or abortSignal to that streamText is a type error, with a runtime throw behind it for JavaScript callers. tools is yours to pass. The old shape only warned in prose.

Also in here

  • chat.agent() takes system, registry, cacheControl and systemProviderOptions, so a managed prompt's model and its cache breakpoint no longer have to be passed at the call site.
  • ChatStreamText is exported for typing a loop factored out of run.

The signature is taken from the AI SDK's own declaration:

import type { streamText as aiStreamTextSignature } from "ai";
type AiStreamTextFn = typeof aiStreamTextSignature;

The peer range spans ai v5, v6 and v7, whose options differ. typeof resolves to whichever version is installed, so generics and tool inference are the caller's own and a v8 option needs no change here.

Actions. onAction no longer receives streamText or tools: an action is a state edit, and one that returns chat.turn() (added in #4816) is followed by run(), which already has both. The action docs on this branch describe that model. chat.toStreamTextOptions() now also applies chat.agent's system, registry, cacheControl and systemProviderOptions, so the spread form is equivalent to the streamText handed to run(), 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 a hydrateMessages agent, which skips the snapshot boot block where they were first set.

Verification

Typecheck and the full suite pass on both ai@6.0.116 and ai@7.0.66. The option merge is a pure function so the merged object can be asserted directly, which is how experimental_telemetry being dropped was caught: most streamText options never reach the provider, so a test that observes the model cannot see them.

Run end to end against a deployed agent with every run rewritten to the new form and no spread anywhere: steering, undo across a cold boot, and regenerate all still pass, a caller's own prepareStep runs while managed steering still fires inside the turn, and consecutive injections arrive one per turn. The handover-owned options are pinned by @ts-expect-error assertions in a typechecked test rather than only by the runtime throw.

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

changeset-bot Bot commented Sep 3, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: bf66457

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 27 packages
Name Type
@trigger.dev/sdk Minor
@trigger.dev/python Minor
@internal/dashboard-agent Patch
@trigger.dev/build Minor
trigger.dev Minor
@trigger.dev/core Minor
@trigger.dev/react-hooks Minor
@trigger.dev/redis-worker Minor
@trigger.dev/rsc Minor
@trigger.dev/schema-to-json Minor
@trigger.dev/database Minor
@trigger.dev/otlp-importer Minor
@trigger.dev/rbac Minor
@trigger.dev/sso Minor
@internal/clickhouse Patch
@internal/llm-model-catalog Patch
@internal/metrics-pipeline Patch
@internal/redis Patch
@internal/replication Patch
@internal/run-engine Patch
@internal/run-store Patch
@internal/schedule-engine Patch
@internal/tracing Patch
@internal/webhook-engine Patch
@internal/webhook-sources Patch
@internal/testcontainers Patch
@internal/cache Patch

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

@coderabbitai

coderabbitai Bot commented Sep 3, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

Note

Reviews paused

It 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 reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Team

Run ID: 21cafab4-8308-4db2-99e4-a01b82b24a31

📥 Commits

Reviewing files that changed from the base of the PR and between c9336c5 and bf66457.

📒 Files selected for processing (1)
  • .changeset/managed-streamtext-in-run.md

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)
  • GitHub Check: webapp / 🧪 Unit Tests: Webapp (20, 24)
  • GitHub Check: webapp / 🧪 Unit Tests: Webapp (8, 24)
  • GitHub Check: webapp / 🧪 Unit Tests: Webapp (24, 24)
  • GitHub Check: webapp / 🧪 Unit Tests: Webapp (7, 24)
  • GitHub Check: webapp / 🧪 Unit Tests: Webapp (19, 24)
  • GitHub Check: webapp / 🧪 Unit Tests: Webapp (12, 24)
  • GitHub Check: webapp / 🧪 Unit Tests: Webapp (22, 24)
  • GitHub Check: webapp / 🧪 Unit Tests: Webapp (10, 24)
  • GitHub Check: webapp / 🧪 Unit Tests: Webapp (18, 24)
  • GitHub Check: webapp / 🧪 Unit Tests: Webapp (17, 24)
  • GitHub Check: webapp / 🧪 Unit Tests: Webapp (11, 24)
  • GitHub Check: webapp / 🧪 Unit Tests: Webapp (16, 24)
  • GitHub Check: webapp / 🧪 Unit Tests: Webapp (9, 24)
  • GitHub Check: webapp / 🧪 Unit Tests: Webapp (13, 24)
  • GitHub Check: webapp / 🧪 Unit Tests: Webapp (2, 24)
  • GitHub Check: webapp / 🧪 Unit Tests: Webapp (14, 24)
  • GitHub Check: webapp / 🧪 Unit Tests: Webapp (6, 24)
  • GitHub Check: webapp / 🧪 Unit Tests: Webapp (21, 24)
  • GitHub Check: webapp / 🧪 Unit Tests: Webapp (5, 24)
  • GitHub Check: webapp / 🧪 Unit Tests: Webapp (4, 24)
  • GitHub Check: webapp / 🧪 Unit Tests: Webapp (15, 24)
  • GitHub Check: webapp / 🧪 Unit Tests: Webapp (23, 24)
  • GitHub Check: webapp / 🧪 Unit Tests: Webapp (1, 24)
  • GitHub Check: webapp / 🧪 Unit Tests: Webapp (3, 24)
  • GitHub Check: sdk-compat / Node.js 26.4 (warp-ubuntu-latest-x64-4x)
  • GitHub Check: sdk-compat / Bun Runtime
  • GitHub Check: sdk-compat / Node.js 24.18 (warp-ubuntu-latest-x64-4x)
  • GitHub Check: sdk-compat / Node.js 22.23 (warp-ubuntu-latest-x64-4x)
  • GitHub Check: sdk-compat / Node.js 20.20 (warp-ubuntu-latest-x64-4x)
  • GitHub Check: e2e / 🧪 CLI v3 tests (warp-windows-latest-x64-8x - npm)
  • GitHub Check: e2e / 🧪 CLI v3 tests (warp-ubuntu-latest-x64-4x - npm)
  • GitHub Check: e2e / 🧪 CLI v3 tests (warp-windows-latest-x64-8x - pnpm)
  • GitHub Check: sdk-compat / Deno Runtime
  • GitHub Check: sdk-compat / Cloudflare Workers
  • GitHub Check: e2e / 🧪 CLI v3 tests (warp-ubuntu-latest-x64-4x - pnpm)
  • GitHub Check: internal / 🧪 Unit Tests: Internal
  • GitHub Check: fk-cascade-guard / fk-cascade-guard
  • GitHub Check: runops-guard / runops-guard
  • GitHub Check: typecheck / typecheck
  • GitHub Check: packages / 🧪 Unit Tests: Packages (2, 3)
  • GitHub Check: e2e-webapp / 🧪 E2E Tests: Webapp (1, 2)
  • GitHub Check: e2e-webapp / 🧪 E2E Tests: Webapp (2, 2)
  • GitHub Check: packages / 🧪 Unit Tests: Packages (3, 3)
  • GitHub Check: packages / 🧪 Unit Tests: Packages (1, 3)
  • GitHub Check: code-quality / code-quality
  • GitHub Check: audit
  • GitHub Check: Analyze (javascript-typescript)
  • GitHub Check: Build and publish previews
🧰 Additional context used
🪛 LanguageTool
.changeset/managed-streamtext-in-run.md

[style] ~5-~5: To strengthen your wording, consider replacing the phrasal verb “leave out”.
Context: ...eady applied, so they cannot be lost by leaving out the spread: ```ts run: async ({ messag...

(OMIT_EXCLUDE)


[style] ~12-~12: ‘by accident’ might be wordy. Consider a shorter alternative.
Context: ...instead, so neither can be turned off by accident. system` can be set at the call site,...

(EN_WORDINESS_PREMIUM_BY_ACCIDENT)


[style] ~14-~14: To form a complete sentence, be sure to include a subject.
Context: ...an be turned off by accident. system can be set at the call site, on `chat.agent...

(MISSING_IT_THERE)


[grammar] ~18-~18: Ensure spelling is correct
Context: ...at.headStartandchat.startHeadStarthand theirrun` the same thing, carrying the options th...

(QB_NEW_EN_ORTHOGRAPHY_ERROR_IDS_1)

🔇 Additional comments (1)
.changeset/managed-streamtext-in-run.md (1)

1-18: LGTM!


Walkthrough

The SDK now supplies managed streamText functions to agent runs and action handlers. These functions preserve prompts, tools, telemetry, registry settings, caching options, and prepareStep behavior. Head Start handlers receive bound functions that own handover options. Runtime exports, public types, tests, release notes, and chat-agent documentation were updated.

Merge Risk: 🟡 Moderate · up to bf664

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)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning 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… Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Title check ✅ Passed The title clearly and concisely describes the primary change: providing run() with a managed streamText function.
Description check ✅ Passed The description is detailed and on topic. It explains the implementation, behavior changes, migration examples, action lifecycle, testing, and verification. It does not include the template checklist,…
Full details: Docstring Coverage

Explanation

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

  • Fix all pre-merge checks with AI
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/chat-bound-streamtext

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.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@ericallam
ericallam force-pushed the feat/chat-bound-streamtext branch from 4984f70 to f36bb10 Compare September 3, 2026 16:58
@pkg-pr-new

pkg-pr-new Bot commented Sep 3, 2026

Copy link
Copy Markdown

Open in StackBlitz

@trigger.dev/build

npm i https://pkg.pr.new/@trigger.dev/build@bf66457

trigger.dev

npm i https://pkg.pr.new/trigger.dev@bf66457

@trigger.dev/core

npm i https://pkg.pr.new/@trigger.dev/core@bf66457

@trigger.dev/python

npm i https://pkg.pr.new/@trigger.dev/python@bf66457

@trigger.dev/react-hooks

npm i https://pkg.pr.new/@trigger.dev/react-hooks@bf66457

@trigger.dev/redis-worker

npm i https://pkg.pr.new/@trigger.dev/redis-worker@bf66457

@trigger.dev/rsc

npm i https://pkg.pr.new/@trigger.dev/rsc@bf66457

@trigger.dev/schema-to-json

npm i https://pkg.pr.new/@trigger.dev/schema-to-json@bf66457

@trigger.dev/sdk

npm i https://pkg.pr.new/@trigger.dev/sdk@bf66457

commit: bf66457

@ericallam

Copy link
Copy Markdown
Member Author

@coderabbitai review

@coderabbitai

coderabbitai Bot commented Sep 3, 2026

Copy link
Copy Markdown
Contributor
✅ Action performed

Review finished.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

coderabbitai[bot]

This comment was marked as resolved.

@ericallam
ericallam marked this pull request as ready for review September 3, 2026 21:47
devin-ai-integration[bot]

This comment was marked as resolved.

@ericallam
ericallam force-pushed the feat/chat-bound-streamtext branch from f36bb10 to 57bb078 Compare September 4, 2026 08:46
devin-ai-integration[bot]

This comment was marked as resolved.

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.
@ericallam
ericallam force-pushed the feat/chat-bound-streamtext branch from 1a1da14 to 45bafb0 Compare September 4, 2026 09:08
devin-ai-integration[bot]

This comment was marked as resolved.

The edit an action makes is snapshotted before the turn chat.turn()
requests begins, so a turn that is cancelled or runs out of memory
continues from the edited history rather than from the snapshot the edit
replaced. The turn's run() payload carries trigger "action-turn", not
"action", so a handler that returns early on the action trigger still
answers. An action sent through useChat keeps the request's metadata.

Also moves the action-turn test onto this branch's own surface; it had
used the bound streamText and chat.agent({ system }) from #4884.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AxuSksX18bj1yhnLpkcQ6a
@ericallam
ericallam force-pushed the feat/chat-bound-streamtext branch from 3203120 to b1fdca8 Compare September 5, 2026 05:06
devin-ai-integration[bot]

This comment was marked as resolved.

coderabbitai[bot]

This comment was marked as resolved.

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.
@ericallam
ericallam force-pushed the feat/chat-bound-streamtext branch from b1fdca8 to 871f86c Compare September 5, 2026 05:17
coderabbitai[bot]

This comment was marked as resolved.

ericallam and others added 10 commits September 5, 2026 07:23
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
…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.
@ericallam
ericallam force-pushed the feat/chat-bound-streamtext branch from c9336c5 to bf66457 Compare September 5, 2026 06:23
@ericallam
ericallam added this pull request to the merge queue Sep 7, 2026
Base automatically changed from fix/chat-agent-accumulator to main September 7, 2026 07:17
Merged via the queue into main with commit 7fb8310 Sep 7, 2026
67 of 68 checks passed
pull Bot pushed a commit to Stars1233/trigger.dev that referenced this pull request Sep 7, 2026
…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>

@devin-ai-integration devin-ai-integration Bot left a comment

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.

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.

Devin Review

Comment on lines 190 to +200
`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.

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.

🔍 Advanced guidance retains the removed action API

The bundled skill still directs actions to return responses. The runtime now accepts only chat.turn(), so generated examples can fail at runtime.

Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants