Skip to content

Commit 52772b5

Browse files
committed
fix(chat): report a failed action stream instead of committing it as 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.
1 parent 47f2f8f commit 52772b5

3 files changed

Lines changed: 95 additions & 0 deletions

File tree

docs/ai-chat/background-injection.mdx

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -208,8 +208,20 @@ its own advice is to use the instructions option. `Instructions` accepts
208208
`Array<SystemModelMessage>`, so the injected block is appended there rather than
209209
smuggled into the transcript.
210210

211+
<Warning>
212+
The instructions lane is delivered by `chat.toStreamTextOptions()`, because that
213+
is the only place the SDK can set `streamText`'s instructions for you. If your
214+
`run()` calls `streamText({ model, messages, abortSignal })` without spreading
215+
`chat.toStreamTextOptions()`, a `role: "system"` injection never reaches the
216+
model. The conversational lane has no such requirement — it arrives through
217+
`messages` either way.
218+
</Warning>
219+
211220
Two things worth knowing:
212221

222+
- An injection applies to the next inference call only. The lane is drained once
223+
applied, so a block injected in `onTurnComplete` shapes the following turn and is
224+
not repeated on every turn after it.
213225
- A new instruction block changes the cached prefix, so the first call carrying it
214226
misses the prompt cache. Only the turns where something was actually injected pay
215227
that.

packages/trigger-sdk/src/v3/ai.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8076,6 +8076,15 @@ function chatAgent<
80768076
locals.set(chatCurrentUIMessagesKey, accumulatedUIMessages);
80778077
actionChangedHistory = true;
80788078
}
8079+
8080+
/**
8081+
* Reported after the partial is committed, not instead of it.
8082+
* `pipeChatAndCapture` returns a stream failure rather than
8083+
* throwing, so without this a mid-stream failure writes a
8084+
* normal turn-complete and the truncated answer is persisted
8085+
* as if it were finished — the next turn then builds on it.
8086+
*/
8087+
if (captured.status === "error") throw captured.error;
80798088
} catch (error) {
80808089
if (
80818090
error instanceof Error &&

packages/trigger-sdk/test/action-stream-accumulator.test.ts

Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -95,4 +95,78 @@ describe("a StreamTextResult returned from onAction", () => {
9595
await harness.close();
9696
}
9797
});
98+
99+
it("reports a mid-stream failure instead of committing a truncated answer as finished", async () => {
100+
/**
101+
* `pipeChatAndCapture` returns a stream failure as `status: "error"` rather
102+
* than throwing it. Unchecked, the action commits whatever streamed, writes a
103+
* normal turn-complete, and the browser just sees the stream stop — so the
104+
* user reads a half-finished answer presented as complete and the next turn
105+
* builds on it. The partial is still kept, as on the turn path; what changes
106+
* is that the failure is surfaced alongside it.
107+
*/
108+
let stage = 0;
109+
const failsMidStream = new MockLanguageModelV3({
110+
doStream: async () => ({
111+
stream: new ReadableStream<LanguageModelV3StreamPart>({
112+
async pull(controller) {
113+
await new Promise((r) => setTimeout(r, 25));
114+
if (stage === 0) {
115+
controller.enqueue({ type: "text-start", id: "t1" });
116+
stage++;
117+
return;
118+
}
119+
if (stage === 1) {
120+
controller.enqueue({ type: "text-delta", id: "t1", delta: "half an answer" });
121+
stage++;
122+
return;
123+
}
124+
controller.error(new Error("provider exploded mid-stream"));
125+
},
126+
}),
127+
}),
128+
});
129+
130+
const agent = chat.agent({
131+
id: "action-stream-error",
132+
actionSchema: z.discriminatedUnion("type", [z.object({ type: z.literal("regenerate") })]),
133+
onAction: async ({ action, messages }) => {
134+
if (action.type !== "regenerate") return;
135+
chat.history.slice(0, -1);
136+
return streamText({ model: failsMidStream, messages });
137+
},
138+
run: async ({ messages, signal }) =>
139+
streamText({
140+
model: new MockLanguageModelV3({
141+
doStream: async () => ({ stream: textStream("first answer") }),
142+
}),
143+
messages,
144+
abortSignal: signal,
145+
}),
146+
});
147+
148+
const harness = mockChatAgent(agent, { chatId: "action-stream-error" });
149+
150+
try {
151+
await harness.sendMessage({
152+
id: "u1",
153+
role: "user",
154+
parts: [{ type: "text", text: "ask" }],
155+
});
156+
await new Promise((r) => setTimeout(r, 40));
157+
158+
await harness.sendAction({ type: "regenerate" }).catch(() => {});
159+
await new Promise((r) => setTimeout(r, 300));
160+
161+
const errors = (harness.allRawChunks as { type?: string }[]).filter(
162+
(c) => c.type === "error"
163+
);
164+
expect(errors.length).toBeGreaterThan(0);
165+
166+
// The partial is still kept rather than discarded.
167+
expect(harness.getSnapshot()?.messages.map(textOf).at(-1)).toContain("half an answer");
168+
} finally {
169+
await harness.close();
170+
}
171+
});
98172
});

0 commit comments

Comments
 (0)