Skip to content

Commit 02aa30d

Browse files
committed
fix(chat): correct the wiring caveat, deferral does not need the spread
The previous commit said a `pendingMessages` config without `chat.toStreamTextOptions()` still loses mid-turn messages. That was true before this branch and is not true on it: the arrival path only observes now, so the record stays queued on the channel whatever happens to the steering queue, and the next turn takes it. Injection is the part that needs the spread. Without it nothing injects, so every mid-turn message is answered as the next turn, which is the documented default rather than a loss. Covers the shape a developer reaches by following the docs for `onReceived` alone, with no `shouldInject` and no spread, so nothing can drain the queue.
1 parent 66c83c8 commit 02aa30d

4 files changed

Lines changed: 44 additions & 3 deletions

File tree

.changeset/spry-steers-defer.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,8 @@ chat.agent({
1818
model,
1919
messages,
2020
abortSignal: signal,
21-
// Required for pendingMessages to be wired up at all.
21+
// Required for injection. Without it nothing injects, and every
22+
// mid-turn message is answered as the next turn instead.
2223
...chat.toStreamTextOptions(),
2324
}),
2425
});

docs/ai-chat/pending-messages.mdx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ By default (without `pendingMessages`), a message sent while the agent is respon
1212

1313
The `pendingMessages` option enables steering instead, injecting user messages between tool-call steps via the AI SDK's `prepareStep`. Messages that arrive during streaming are queued and injected at the next step boundary. A message that is not injected becomes the next turn instead, whether that is because `shouldInject` returned `false` or because there were no more step boundaries (single-step response or final text generation). The backend handles that, so no client-side re-send is involved.
1414

15-
This requires the `pendingMessages` options to actually reach `streamText`, by spreading `chat.toStreamTextOptions()` (or passing `prepareStep`). Configuring `pendingMessages` without that wiring leaves nothing to drain the queue, and mid-turn messages are lost.
15+
Injection is what needs wiring: the `pendingMessages` options only reach `streamText` if you spread `chat.toStreamTextOptions()` (or pass `prepareStep`). Without that, nothing injects, so every mid-turn message is answered as the next turn. Deferral does not depend on it.
1616

1717
## How it works
1818

docs/ai-chat/reference.mdx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -396,7 +396,7 @@ Options for the `pendingMessages` field. See [Pending Messages](/ai-chat/pending
396396

397397
| Option | Type | Required | Description |
398398
| -------------- | --------------------------------------------------------------------------------- | -------- | ----------------------------------------------------------------------------------------- |
399-
| `shouldInject` | `(event: PendingMessagesBatchEvent) => boolean \| Promise<boolean>` | No | Decide whether to inject the batch between tool-call steps. If absent, nothing is injected and the messages are answered as the next turn. Requires `chat.toStreamTextOptions()` (or `prepareStep`) to be passed to `streamText`. |
399+
| `shouldInject` | `(event: PendingMessagesBatchEvent) => boolean \| Promise<boolean>` | No | Decide whether to inject the batch between tool-call steps. If absent, nothing is injected and the messages are answered as the next turn. Only consulted when `chat.toStreamTextOptions()` (or `prepareStep`) reaches `streamText`; without that nothing is injected and every mid-turn message becomes the next turn. |
400400
| `prepare` | `(event: PendingMessagesBatchEvent) => ModelMessage[] \| Promise<ModelMessage[]>` | No | Transform the batch before injection. Default: convert each via `convertToModelMessages`. |
401401
| `onReceived` | `(event: PendingMessageReceivedEvent) => void \| Promise<void>` | No | Called when a message arrives during streaming (per-message). |
402402
| `onInjected` | `(event: PendingMessagesInjectedEvent) => void \| Promise<void>` | No | Called after a batch is injected via prepareStep. |

packages/trigger-sdk/test/pending-message-drain.test.ts

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -224,6 +224,46 @@ describe("chat.agent declined steering message", () => {
224224
});
225225
});
226226

227+
/**
228+
* The shape a developer reaches by following the docs for `onReceived` alone:
229+
* a `pendingMessages` config, no `shouldInject`, and no
230+
* `chat.toStreamTextOptions()` spread, so nothing can ever drain the steering
231+
* queue. The message must still be answered. It used to be taken off the
232+
* channel by the arrival handler and then stranded in a queue with no consumer.
233+
*/
234+
describe("chat.agent pendingMessages with nothing wired to drain it", () => {
235+
it("still answers a mid-turn message as its own turn", async () => {
236+
const received: string[] = [];
237+
238+
const agent = chat.agent({
239+
id: "pending-drain.unwired",
240+
pendingMessages: {
241+
onReceived: ({ message }) => {
242+
received.push(message.id);
243+
},
244+
},
245+
run: async ({ messages, signal }) => {
246+
return streamText({ model: echoModel(), messages, abortSignal: signal });
247+
},
248+
});
249+
250+
const harness = mockChatAgent(agent, { chatId: "pending-drain-unwired" });
251+
try {
252+
const first = harness.sendMessage(userMessage("m1", "u-1"));
253+
await waitFor(() => streamedText(harness).includes("ANSWER(m1)"));
254+
void harness.sendMessage(userMessage("m2", "u-2"));
255+
await first;
256+
257+
await waitFor(() => turnCompleteCount(harness) >= 2);
258+
259+
expect(received).toContain("u-2");
260+
expect(streamedText(harness)).toContain("ANSWER(m2)");
261+
} finally {
262+
await harness.close();
263+
}
264+
});
265+
});
266+
227267
describe("chat.agent errored turn", () => {
228268
it(
229269
"does not duplicate messages buffered after a turn that threw",

0 commit comments

Comments
 (0)