fix(chat): keep steering, action and injected messages in the conversation - #4816
fix(chat): keep steering, action and injected messages in the conversation#4816ericallam wants to merge 29 commits into
Conversation
🦋 Changeset detectedLatest commit: ae72c70 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:
WalkthroughThe SDK routes Merge Risk: 🟡 Moderate · up to The PR improves persistence for steering, action, and injected messages, but regenerated answers can still be duplicated in linear stores unless applications replace the old message atomically, and failed action snapshots can allow stale history to return later. Merge should wait for the persistence guidance and failure handling to be fixed or explicitly accepted. 🚥 Pre-merge checks | ✅ 4 | ❌ 1❌ Failed checks (1 warning)
✅ Passed checks (4 passed)
Full details: Docstring CoverageExplanation Docstring coverage is 33.33% which is insufficient. The required threshold is 80.00%. Docstring coverage is scoped to functions touched by this diff. Analyzed 15 functions across 8 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 |
@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: |
There was a problem hiding this comment.
Caution
Some comments are outside the diff and can’t be posted inline due to platform limitations.
⚠️ Outside diff range comments (2)
packages/trigger-sdk/src/v3/ai.ts (2)
4267-4276: 🎯 Functional Correctness | 🟠 Major | 🏗️ Heavy liftKeep the model accumulator synchronized with injected steering messages.
Lines 4269-4276 update only
chatCurrentUIMessagesKey. They do not updateaccumulatedMessagesorturnNewModelMessages. After this response, the next turn in the same worker usesaccumulatedMessages, so it omits steering that influenced the prior response.onTurnComplete.messagesalso omits that message.Return the claimed UI and model messages from
drainSteeringQueue(). Update both accumulator representations in each caller. Add a regression test that sends a steering message, completes the turn, then verifies the nextrun()call receives that message.
4267-4276: 📐 Maintainability & Code Quality | 🟠 Major | ⚡ Quick winAdd required development crumbs.
Add approved crumb instrumentation for these new behavior paths. If no approved namespace fits, ask before adding one.
packages/trigger-sdk/src/v3/ai.ts#L4267-L4276: add crumbs for steering claim and accumulator updates.packages/trigger-sdk/test/inject-system-instructions.test.ts#L179-L234: add crumbs for the one-turn instruction-drain scenario.As per coding guidelines, “Add crumbs as you write code — not just when debugging.”
Source: Coding guidelines
ℹ️ Review info
⚙️ Run configuration
Configuration used: Repository UI
Review profile: CHILL
Plan: Pro Plus
Run ID: f069755c-6da5-41bc-8e27-c3b1350588ff
📒 Files selected for processing (6)
packages/trigger-sdk/src/v3/ai.tspackages/trigger-sdk/test/action-snapshot.test.tspackages/trigger-sdk/test/action-stream-accumulator.test.tspackages/trigger-sdk/test/chatHandover.test.tspackages/trigger-sdk/test/inject-system-instructions.test.tspackages/trigger-sdk/test/steering-accumulator.test.ts
🚧 Files skipped from review as they are similar to previous changes (4)
- packages/trigger-sdk/test/action-stream-accumulator.test.ts
- packages/trigger-sdk/test/chatHandover.test.ts
- packages/trigger-sdk/test/action-snapshot.test.ts
- packages/trigger-sdk/test/steering-accumulator.test.ts
Included review availability: Your plan provides up to 10 included reviews per hour; 6 remain after this review.
📜 Review details
⏰ Context from checks skipped due to timeout. (43)
- GitHub Check: webapp / 🧪 Unit Tests: Webapp (24, 24)
- GitHub Check: webapp / 🧪 Unit Tests: Webapp (21, 24)
- GitHub Check: webapp / 🧪 Unit Tests: Webapp (15, 24)
- GitHub Check: webapp / 🧪 Unit Tests: Webapp (14, 24)
- GitHub Check: webapp / 🧪 Unit Tests: Webapp (23, 24)
- GitHub Check: webapp / 🧪 Unit Tests: Webapp (22, 24)
- GitHub Check: webapp / 🧪 Unit Tests: Webapp (13, 24)
- GitHub Check: webapp / 🧪 Unit Tests: Webapp (17, 24)
- GitHub Check: webapp / 🧪 Unit Tests: Webapp (2, 24)
- GitHub Check: webapp / 🧪 Unit Tests: Webapp (7, 24)
- GitHub Check: webapp / 🧪 Unit Tests: Webapp (11, 24)
- GitHub Check: webapp / 🧪 Unit Tests: Webapp (18, 24)
- GitHub Check: webapp / 🧪 Unit Tests: Webapp (20, 24)
- GitHub Check: webapp / 🧪 Unit Tests: Webapp (19, 24)
- GitHub Check: webapp / 🧪 Unit Tests: Webapp (16, 24)
- GitHub Check: webapp / 🧪 Unit Tests: Webapp (5, 24)
- GitHub Check: webapp / 🧪 Unit Tests: Webapp (9, 24)
- GitHub Check: webapp / 🧪 Unit Tests: Webapp (8, 24)
- GitHub Check: webapp / 🧪 Unit Tests: Webapp (10, 24)
- GitHub Check: webapp / 🧪 Unit Tests: Webapp (6, 24)
- GitHub Check: webapp / 🧪 Unit Tests: Webapp (4, 24)
- GitHub Check: webapp / 🧪 Unit Tests: Webapp (1, 24)
- GitHub Check: webapp / 🧪 Unit Tests: Webapp (12, 24)
- GitHub Check: webapp / 🧪 Unit Tests: Webapp (3, 24)
- GitHub Check: sdk-compat / Cloudflare Workers
- GitHub Check: sdk-compat / Node.js 20.20 (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: internal / 🧪 Unit Tests: Internal
- GitHub Check: sdk-compat / Node.js 26.4 (warp-ubuntu-latest-x64-4x)
- GitHub Check: packages / 🧪 Unit Tests: Packages (1, 3)
- GitHub Check: sdk-compat / Deno Runtime
- GitHub Check: sdk-compat / Node.js 22.23 (warp-ubuntu-latest-x64-4x)
- GitHub Check: packages / 🧪 Unit Tests: Packages (3, 3)
- GitHub Check: packages / 🧪 Unit Tests: Packages (2, 3)
- GitHub Check: e2e-webapp / 🧪 E2E Tests: Webapp (2, 2)
- GitHub Check: typecheck / typecheck
- GitHub Check: e2e-webapp / 🧪 E2E Tests: Webapp (1, 2)
- GitHub Check: fk-cascade-guard / fk-cascade-guard
- GitHub Check: runops-guard / runops-guard
- GitHub Check: Analyze (javascript-typescript)
- GitHub Check: code-quality / code-quality
- GitHub Check: Build and publish previews
🧰 Additional context used
📓 Path-based instructions (10)
Always import from `@trigger.dev/sdk`. Never use `@trigger.dev/sdk/v3` or deprecated `client.defineJob`.
📄 CodeRabbit inference engine (AGENTS.md)
Files:
packages/trigger-sdk/test/inject-system-instructions.test.tspackages/trigger-sdk/src/v3/ai.ts
We use vitest exclusively. **Never mock anything** - use testcontainers instead.
📄 CodeRabbit inference engine (AGENTS.md)
Files:
packages/trigger-sdk/test/inject-system-instructions.test.ts
**Prefer static imports over dynamic imports.** Only use dynamic `import()` when:
📄 CodeRabbit inference engine (AGENTS.md)
Files:
packages/trigger-sdk/test/inject-system-instructions.test.tspackages/trigger-sdk/src/v3/ai.ts
Add crumbs as you write code — not just when debugging. Mark lines with
📄 CodeRabbit inference engine (AGENTS.md)
Files:
packages/trigger-sdk/test/inject-system-instructions.test.tspackages/trigger-sdk/src/v3/ai.ts
Always import from `@trigger.dev/sdk`. Never use `@trigger.dev/sdk/v3` (deprecated path alias)
📄 CodeRabbit inference engine (packages/trigger-sdk/CLAUDE.md)
Files:
packages/trigger-sdk/test/inject-system-instructions.test.tspackages/trigger-sdk/src/v3/ai.ts
In the Trigger.dev SDK (packages/trigger-sdk), prefer isomorphic code like fetch and ReadableStream instead of Node.js-specific code
📄 CodeRabbit inference engine (.github/copilot-instructions.md)
Files:
packages/trigger-sdk/test/inject-system-instructions.test.tspackages/trigger-sdk/src/v3/ai.ts
Use vitest for all tests in the Trigger.dev repository
📄 CodeRabbit inference engine (.github/copilot-instructions.md)
Files:
packages/trigger-sdk/test/inject-system-instructions.test.ts
Use function declarations instead of default exports
📄 CodeRabbit inference engine (.github/copilot-instructions.md)
Files:
packages/trigger-sdk/test/inject-system-instructions.test.tspackages/trigger-sdk/src/v3/ai.ts
Use types over interfaces for TypeScript
📄 CodeRabbit inference engine (.github/copilot-instructions.md)
Files:
packages/trigger-sdk/test/inject-system-instructions.test.tspackages/trigger-sdk/src/v3/ai.ts
When creating or editing OTEL metrics (counters, histograms, gauges), ensure metric attributes have low cardinality by using only enums, booleans, bounded error codes, or bounded shard IDs
📄 CodeRabbit inference engine (.cursor/rules/otel-metrics.mdc)
Files:
packages/trigger-sdk/test/inject-system-instructions.test.tspackages/trigger-sdk/src/v3/ai.ts
🔇 Additional comments (1)
packages/trigger-sdk/test/inject-system-instructions.test.ts (1)
11-27: Replace mock-based chat coverage with the approved test strategy.This test uses
simulateReadableStreamandMockLanguageModelV3. Use the repository Testcontainers-backed fixture instead.As per coding guidelines, “We use vitest exclusively. Never mock anything - use testcontainers instead.”
Also applies to: 179-188
Source: Coding guidelines
There was a problem hiding this comment.
Caution
Some comments are outside the diff and can’t be posted inline due to platform limitations.
⚠️ Outside diff range comments (2)
packages/trigger-sdk/src/v3/ai.ts (2)
4267-4276: 🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick winPersist claimed steering messages in raw accumulator paths.
drainSteeringQueue()updates onlychatCurrentUIMessagesKeyandchatTurnNewUIMessagesKey.ChatMessageAccumulator.prepareStep()andChatTurn.prepareStep()pass their own queue into this helper, but neither raw accumulator is connected to these locals.After a successful injection, the current inference sees the steering message. The next raw turn and caller-managed persistence do not see it because
uiMessagesandmodelMessageswere not updated.Return the claimed UI and model messages, or add a callback that updates each raw accumulator after a successful claim. Deduplicate by message ID.
8036-8087: 📐 Maintainability & Code Quality | 🟠 Major | ⚡ Quick winAdd crumb markers for this new state transition.
Add
//@Crumbsmarkers, or wrap this action stream capture and error path in a `// `#region` `@crumbsblock. This path changes persistence order and error propagation.As per coding guidelines, “Add crumbs as you write code — not just when debugging.”
Source: Coding guidelines
ℹ️ Review info
⚙️ Run configuration
Configuration used: Repository UI
Review profile: CHILL
Plan: Pro Plus
Run ID: 7d29e0d9-c508-4a05-bf16-072254964879
📒 Files selected for processing (3)
docs/ai-chat/background-injection.mdxpackages/trigger-sdk/src/v3/ai.tspackages/trigger-sdk/test/action-stream-accumulator.test.ts
Included review availability: Your plan provides up to 10 included reviews per hour; 5 remain after this review.
📜 Review details
⏰ Context from checks skipped due to timeout. (42)
- GitHub Check: packages / 🧪 Unit Tests: Packages (2, 3)
- GitHub Check: webapp / 🧪 Unit Tests: Webapp (20, 24)
- GitHub Check: webapp / 🧪 Unit Tests: Webapp (23, 24)
- GitHub Check: webapp / 🧪 Unit Tests: Webapp (12, 24)
- GitHub Check: webapp / 🧪 Unit Tests: Webapp (16, 24)
- GitHub Check: webapp / 🧪 Unit Tests: Webapp (18, 24)
- GitHub Check: webapp / 🧪 Unit Tests: Webapp (24, 24)
- GitHub Check: webapp / 🧪 Unit Tests: Webapp (17, 24)
- GitHub Check: webapp / 🧪 Unit Tests: Webapp (19, 24)
- GitHub Check: webapp / 🧪 Unit Tests: Webapp (15, 24)
- GitHub Check: webapp / 🧪 Unit Tests: Webapp (10, 24)
- GitHub Check: webapp / 🧪 Unit Tests: Webapp (22, 24)
- GitHub Check: webapp / 🧪 Unit Tests: Webapp (21, 24)
- GitHub Check: webapp / 🧪 Unit Tests: Webapp (14, 24)
- GitHub Check: webapp / 🧪 Unit Tests: Webapp (11, 24)
- GitHub Check: webapp / 🧪 Unit Tests: Webapp (4, 24)
- GitHub Check: webapp / 🧪 Unit Tests: Webapp (6, 24)
- GitHub Check: webapp / 🧪 Unit Tests: Webapp (9, 24)
- GitHub Check: webapp / 🧪 Unit Tests: Webapp (7, 24)
- GitHub Check: webapp / 🧪 Unit Tests: Webapp (3, 24)
- GitHub Check: webapp / 🧪 Unit Tests: Webapp (1, 24)
- GitHub Check: webapp / 🧪 Unit Tests: Webapp (13, 24)
- GitHub Check: webapp / 🧪 Unit Tests: Webapp (8, 24)
- GitHub Check: webapp / 🧪 Unit Tests: Webapp (5, 24)
- GitHub Check: webapp / 🧪 Unit Tests: Webapp (2, 24)
- GitHub Check: sdk-compat / Node.js 24.18 (warp-ubuntu-latest-x64-4x)
- GitHub Check: packages / 🧪 Unit Tests: Packages (1, 3)
- GitHub Check: sdk-compat / Node.js 26.4 (warp-ubuntu-latest-x64-4x)
- GitHub Check: sdk-compat / Node.js 20.20 (warp-ubuntu-latest-x64-4x)
- GitHub Check: internal / 🧪 Unit Tests: Internal
- GitHub Check: sdk-compat / Bun Runtime
- GitHub Check: fk-cascade-guard / fk-cascade-guard
- GitHub Check: sdk-compat / Cloudflare Workers
- GitHub Check: e2e-webapp / 🧪 E2E Tests: Webapp (2, 2)
- GitHub Check: sdk-compat / Deno Runtime
- GitHub Check: runops-guard / runops-guard
- GitHub Check: typecheck / typecheck
- GitHub Check: packages / 🧪 Unit Tests: Packages (3, 3)
- GitHub Check: e2e-webapp / 🧪 E2E Tests: Webapp (1, 2)
- GitHub Check: code-quality / code-quality
- GitHub Check: Analyze (javascript-typescript)
- GitHub Check: Build and publish previews
🧰 Additional context used
📓 Path-based instructions (11)
Always import from `@trigger.dev/sdk`. Never use `@trigger.dev/sdk/v3` or deprecated `client.defineJob`.
📄 CodeRabbit inference engine (AGENTS.md)
Files:
packages/trigger-sdk/test/action-stream-accumulator.test.tspackages/trigger-sdk/src/v3/ai.ts
We use vitest exclusively. **Never mock anything** - use testcontainers instead.
📄 CodeRabbit inference engine (AGENTS.md)
Files:
packages/trigger-sdk/test/action-stream-accumulator.test.ts
**Prefer static imports over dynamic imports.** Only use dynamic `import()` when:
📄 CodeRabbit inference engine (AGENTS.md)
Files:
packages/trigger-sdk/test/action-stream-accumulator.test.tspackages/trigger-sdk/src/v3/ai.ts
Add crumbs as you write code — not just when debugging. Mark lines with
📄 CodeRabbit inference engine (AGENTS.md)
Files:
docs/ai-chat/background-injection.mdxpackages/trigger-sdk/test/action-stream-accumulator.test.tspackages/trigger-sdk/src/v3/ai.ts
Always import from `@trigger.dev/sdk`. Never use `@trigger.dev/sdk/v3` (deprecated path alias)
📄 CodeRabbit inference engine (packages/trigger-sdk/CLAUDE.md)
Files:
packages/trigger-sdk/test/action-stream-accumulator.test.tspackages/trigger-sdk/src/v3/ai.ts
In the Trigger.dev SDK (packages/trigger-sdk), prefer isomorphic code like fetch and ReadableStream instead of Node.js-specific code
📄 CodeRabbit inference engine (.github/copilot-instructions.md)
Files:
packages/trigger-sdk/test/action-stream-accumulator.test.tspackages/trigger-sdk/src/v3/ai.ts
Use vitest for all tests in the Trigger.dev repository
📄 CodeRabbit inference engine (.github/copilot-instructions.md)
Files:
packages/trigger-sdk/test/action-stream-accumulator.test.ts
Use function declarations instead of default exports
📄 CodeRabbit inference engine (.github/copilot-instructions.md)
Files:
packages/trigger-sdk/test/action-stream-accumulator.test.tspackages/trigger-sdk/src/v3/ai.ts
MDX documentation pages must include frontmatter with title (required), description (required), and sidebarTitle (optional) in YAML format
📄 CodeRabbit inference engine (docs/CLAUDE.md)
Files:
docs/ai-chat/background-injection.mdx
Use types over interfaces for TypeScript
📄 CodeRabbit inference engine (.github/copilot-instructions.md)
Files:
packages/trigger-sdk/test/action-stream-accumulator.test.tspackages/trigger-sdk/src/v3/ai.ts
When creating or editing OTEL metrics (counters, histograms, gauges), ensure metric attributes have low cardinality by using only enums, booleans, bounded error codes, or bounded shard IDs
📄 CodeRabbit inference engine (.cursor/rules/otel-metrics.mdc)
Files:
packages/trigger-sdk/test/action-stream-accumulator.test.tspackages/trigger-sdk/src/v3/ai.ts
🧠 Learnings (2)
📚 Learning: 2026-06-16T09:19:47.637Z
Learnt from: d-cs
Repo: triggerdotdev/trigger.dev PR: 3960
File: apps/webapp/test/prismaInfrastructureErrorCapture.test.ts:0-0
Timestamp: 2026-06-16T09:19:47.637Z
Learning: In this repo’s Vitest setup, `vitest.config.ts` uses `globals: true`, so identifiers like `vi`, `describe`, `it`, and `expect` are available as globals in Vitest test files. During code review, do not flag missing `vi`/`describe`/`it`/`expect` imports as a runtime error or correctness issue when they’re used in `*.test.ts/tsx` or `*.spec.ts/tsx` files. Explicit imports are still preferred for consistency, but they’re not required for runtime behavior.
Applied to files:
packages/trigger-sdk/test/action-stream-accumulator.test.ts
📚 Learning: 2026-08-16T18:36:58.179Z
Learnt from: ericallam
Repo: triggerdotdev/trigger.dev PR: 4537
File: packages/trigger-sdk/test/normalizeKeyString.test.ts:1-2
Timestamp: 2026-08-16T18:36:58.179Z
Learning: For related SDK `chat.agent` tests in the Trigger.dev repository—including chat channels, handover, snapshot, and transport-event coverage—keep new test files under `packages/trigger-sdk/test/` rather than colocating them with the `packages/trigger-sdk/src/v3/` source files.
Applied to files:
packages/trigger-sdk/test/action-stream-accumulator.test.ts
🔇 Additional comments (2)
docs/ai-chat/background-injection.mdx (1)
211-233: LGTM!packages/trigger-sdk/test/action-stream-accumulator.test.ts (1)
99-171: LGTM!
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.
02c2e6b to
52772b5
Compare
…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.
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.
…esponse A run() that pipes the stream itself skips the auto-pipe, so no onFinish is attached and nothing is captured. The rebuild sits outside both capturedResponseMessage branches for that reason. Gating it on a captured response fails this test and leaves the other one passing.
drainSteeringQueue reported what it claimed by pushing into a locals array that only chat.agent populates, so on chat.createSession and chat.MessageAccumulator the push was a silent no-op behind its truthiness guard. A mid-turn steer shaped that turn's answer and then existed nowhere: not in the session's uiMessages, not in its modelMessages, and not deferred to its own turn either. The drain now returns what it claimed alongside what to inject, and each surface files it. Adds absorbSteering to the accumulator, used by both of its drain sites.
Reconciling the model lane by reconverting the UI lane assumed the UI lane is a superset of it. Compaction breaks that by design: it replaces the model lane with a summary and deliberately leaves the UI lane whole, so any reconversion restored every message the summary had replaced. Reproduced on both surfaces: the next turn was sent the full transcript with no summary, while the steer itself was present, which is what made a steer-presence check pass. The model lane is now only ever appended to. The stale flag becomes the claimed messages themselves, reconciled before the response is appended so the order stays steer-then-answer, and onto whatever the lane holds, summary included. absorbSteering appends to both lanes the same way.
The error path built newUIMessages from the wire message and the partial only, never from the per-turn list the drain appends to, so a turn that failed after a steer handed onTurnComplete everything except the steer. Seeded from the per-turn list at both construction sites, deduped by id. Reproduced with a stream that rejects mid-answer; an AI SDK error part takes the normal completion path and was never affected.
An action and the message after it share a turn number, so the action reads the pending instruction and the intended turn still receives it, and it does not carry to the turn after. Removing the per-turn stash makes the action consume it and the intended turn gets nothing, which is the failure the stash prevents.
The bound streamText on the run and onAction arguments is #4884's, so on this branch alone the test neither typechecked nor ran.
The steered turn was sent what pendingMessages.prepare produced; later turns were sent the claimed UI message reconverted, so the model's memory of the instruction differed from the one it acted on. The pending list now carries both forms and reconciliation appends the model form, still without reconverting the UI lane so compaction survives. absorbSteering takes the injected form as well.
The append ran on the success path only. A turn that failed after a steer reported it to the hook's newUIMessages but left it pending, so the error event's messages lacked it and the next turn received it one slot late. The catch path reconciles it now, before the partial is considered.
Reporting a failed action stream by throwing landed in the shared turn-error path, which fired onTurnComplete, kept the turn number and consumed the one-shot instruction lane. The action branch now reports the failure itself and falls through to its own snapshot, completion and turn--, so the next real turn is still next and still gets an instruction injected before the action.
The error path wrote its snapshot with the failed turn's completion cursor but did not update the shared cursor, so a later history-changing action, whose snapshot is cursor-neutral, wrote the cursor from before the failed turn and a continuation would replay superseded output.
A prepared steer keeps its form on later turns, a failed action is still not a turn, and an action's rollback after a failed turn keeps the right cursor.
… edit The per-turn model delta onTurnComplete reports as newMessages never received a steer's model form, so append-only persistence from it lost the model's view. And a chat.history edit after a drain 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 as well, and skips the lane append for anything the rebuild already placed. On that path the lane keeps the raw form from the rebuild; a history edit is the app rewriting history.
| for (const entry of pending) { | ||
| if (!options?.alreadyInLane?.has(entry.ui.id)) accumulatedMessages.push(...entry.model); | ||
| options?.turnNew?.push(...entry.model); |
There was a problem hiding this comment.
🟡 History edits erase prepared steering form
When pendingMessages.prepare transforms a steer and chat.history changes that turn, reconcilePendingSteer skips the transformed messages. Later turns receive the raw message, or omit it when compaction also replaced model history.
Prompt for agents
reconcilePendingSteer suppresses a pending model form whenever runOverride contained the same UI ID. That override was rebuilt from the raw UI message, so it does not preserve a custom pendingMessages.prepare transformation. The same ID set also remains active after a model-only compaction override replaces the rebuilt lane, causing the steer to disappear entirely. Reconcile prepared model forms without duplicating default conversions, and base deduplication on the final model lane after all overrides. Add tests combining a custom prepare transform with chat.history.set, plus the same case with inner compaction.
Was this helpful? React with 👍 or 👎 to provide feedback.
| addUnique(erroredWireMessage); | ||
| for (const m of (locals.get(chatTurnNewUIMessagesKey) ?? []) as TUIMessage[]) { | ||
| addUnique(m); | ||
| } | ||
| if (includePartial) addUnique(partialResponse!); |
There was a problem hiding this comment.
🟡 Failed turns expose raw steering deltas
When a transformed steer precedes a stream failure, buildErroredNew adds its UI form to the error delta. onTurnComplete.newMessages then reports the raw steer, so append-only model persistence stores different context.
Prompt for agents
On the failed-turn path, buildErroredNew correctly includes consumed steering UI messages, but erroredNewModelMessages is rebuilt from those UI messages. This discards the model form returned by pendingMessages.prepare, even though reconcilePendingSteer appends that transformed form to accumulatedMessages. Build the error-path model delta from the recorded PendingSteer model forms, while retaining raw UI messages in newUIMessages and avoiding duplicates. Add a failing-stream test with a prepare callback that changes role or content and assert newMessages matches the transformed form.
Was this helpful? React with 👍 or 👎 to provide feedback.
Summary
A steering message sent while the agent was answering
Before: the steer reached the model for the answer it steered, and reached the browser, but never
uiMessagesornewUIMessages, 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.agentkeeps a UI accumulator and a model accumulator, and the drain appended to the UI one only; the model saw the message through theprepareStepreturn value, which is per-step. The model lane is advanced by appending each turn's delta, so it never learned the message existed: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()andchat.MessageAccumulator. Those keep their own accumulator, and the drain recorded what it claimed by pushing into a locals array onlychat.agentpopulates, so there the push was a silent no-op. A mid-turn steer shaped that turn's answer and then existed nowhere: not inturn.uiMessages, not inturn.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
newUIMessagesfrom 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
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.
A reply streamed back from an action
Before: it reached the browser and nowhere else, so the user read a new answer the model had no memory of and the next turn carried on from the answer just replaced.
Now the captured message goes into the accumulator (replacing a message with the same id, otherwise appended, which is what makes a regenerate update the answer in place), the model-message lane is rebuilt from it, and the snapshot is written. The next turn's
messageshas it, and so does a continuation. A stream that fails part-way is no longer stored as though it finished either: the partial is kept, the failure reported.Both action fixes are for platform-managed persistence. With
hydrateMessagesthe runtime deliberately does not write, because your store is the source of truth, so a rollback and a streamed replacement are still yours to save, andchat.pipeAndCapturehands you the same message the runtime would have captured. The actions page now covers both models; it previously said only that persistence was your responsibility.Injected system context
Before, on AI SDK 7: every provider rejected it (
AI_InvalidPromptErrorfromstandardizePrompt, 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:
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.A failed action stays an action. An action whose stream failed was reported by throwing into the shared turn-error path, which fired
onTurnComplete, kept the turn number, and consumed the one-shot instruction lane. The action branch now reports the failure itself and falls through to 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'smessageslacked 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 fromnewMessageslost the model's view of it. And achat.historyedit 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.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 #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
createSessionfix, 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 thecreateSessionsurface 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.