Skip to content

Commit 8cc3b51

Browse files
transport/vercel: compute fork metadata for edits via useChat
Our SDK has two forking operations: edit (replace a user message) and regenerate (re-run an assistant message). The AI SDK's useChat exposes the same two: sendMessage with a messageId (edit) [1] and regenerate(). We already made our regenerate compatible with useChat's — ChatTransport computes forkOf/parent from the conversation tree when trigger is 'regenerate-message'. It turns out we missed the edit case. Without fork metadata, edits via useChat were treated as new messages rather than forks in the conversation tree. We fix it by always including fork metadata when the messageId is provided, regardless of the trigger, thus continuing to cover the regeneration case and now also the edit case. [1] https://ai-sdk.dev/docs/reference/ai-sdk-ui/use-chat#send-message [AIT-681] Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 48d7a1c commit 8cc3b51

File tree

2 files changed

+111
-4
lines changed

2 files changed

+111
-4
lines changed

src/vercel/transport/chat-transport.ts

Lines changed: 20 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -7,12 +7,18 @@
77
* to the core transport's send/cancel methods.
88
*
99
* useChat manages message state before calling sendMessages:
10-
* - submit-message: appends the new user message, passes the full array
10+
* - submit-message (new): appends the new user message, passes the full array
11+
* - submit-message (edit): truncates after the edited message, replaces it,
12+
* passes the truncated array with messageId set
1113
* - regenerate-message: truncates after the target, passes the truncated array
1214
*
1315
* The adapter uses `trigger` to determine the history/messages split:
1416
* - submit-message: last message is new (publish to channel), rest is history
1517
* - regenerate-message: no new messages, entire array is history
18+
*
19+
* When messageId is set (edit or regeneration), the adapter computes fork
20+
* metadata (forkOf/parent) from the conversation tree so the server can
21+
* place the response on the correct branch.
1622
*/
1723

1824
import * as Ably from 'ably';
@@ -35,8 +41,10 @@ export interface SendMessagesRequestContext {
3541
/** What triggered the request: user sent a message, or requested regeneration. */
3642
trigger: 'submit-message' | 'regenerate-message';
3743
/**
38-
* The message ID for regeneration requests. Identifies which assistant
39-
* message to regenerate. Undefined for submit-message.
44+
* The message ID for edit or regeneration requests. For regeneration,
45+
* identifies the assistant message to regenerate. For edits (submit-message
46+
* with messageId), identifies the user message being replaced. Undefined
47+
* when submitting a new message.
4048
*/
4149
messageId?: string;
4250
/** Previous messages in the conversation (context for the LLM). */
@@ -239,11 +247,19 @@ export const createChatTransport = (
239247
}
240248

241249
// Compute fork metadata from the conversation tree.
250+
// For regeneration: messageId is the assistant message being regenerated.
251+
// For edit: messageId is the user message being replaced.
252+
// In both cases: forkOf = the x-ably-msg-id of that message,
253+
// parent = the parent of that message in the tree.
242254
let forkOf: string | undefined;
243255
let parent: string | undefined;
244256

245-
if (trigger === 'regenerate-message' && messageId) {
257+
if (messageId) {
246258
forkOf = messageId;
259+
// Look up the message in the tree to resolve x-ably-msg-id.
260+
// messageId comes from useChat (UIMessage.id) — scan the flattened
261+
// nodes to find the one whose domain message matches this ID.
262+
// Uses the transport's default view — ChatTransport is single-view (one useChat per channel).
247263
const node = transport.view.flattenNodes().find((n) => n.message.id === messageId);
248264
if (node) {
249265
forkOf = node.msgId;

test/vercel/transport/chat-transport.test.ts

Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -278,6 +278,97 @@ describe('createChatTransport', () => {
278278
});
279279
});
280280

281+
describe('sendMessages — submit-message with messageId (edit)', () => {
282+
it('resolves fork metadata from the conversation tree', async () => {
283+
const { transport, send, view, mockTurn } = createMockTransport();
284+
285+
const edited = makeMessage('ui-msg-id');
286+
(view.flattenNodes as ReturnType<typeof vi.fn>).mockReturnValue([
287+
{
288+
message: edited,
289+
msgId: 'wire-msg-id',
290+
parentId: 'wire-parent-id',
291+
forkOf: undefined,
292+
headers: {},
293+
serial: undefined,
294+
},
295+
]);
296+
297+
const chat = createChatTransport(transport);
298+
299+
const streamPromise = chat.sendMessages({
300+
trigger: 'submit-message',
301+
chatId: 'chat-1',
302+
messageId: 'ui-msg-id',
303+
messages: [edited],
304+
abortSignal: undefined,
305+
});
306+
307+
mockTurn.close();
308+
await streamPromise;
309+
310+
const [, opts] = send.mock.calls[0] as [AI.UIMessage[], SendOptions];
311+
expect(opts.forkOf).toBe('wire-msg-id');
312+
expect(opts.parent).toBe('wire-parent-id');
313+
});
314+
315+
it('falls back to raw messageId when node not found in tree', async () => {
316+
const { transport, send, view, mockTurn } = createMockTransport();
317+
(view.flattenNodes as ReturnType<typeof vi.fn>).mockReturnValue([]);
318+
319+
const chat = createChatTransport(transport);
320+
321+
const streamPromise = chat.sendMessages({
322+
trigger: 'submit-message',
323+
chatId: 'chat-1',
324+
messageId: 'unknown-id',
325+
messages: [makeMessage('1')],
326+
abortSignal: undefined,
327+
});
328+
329+
mockTurn.close();
330+
await streamPromise;
331+
332+
const [, opts] = send.mock.calls[0] as [AI.UIMessage[], SendOptions];
333+
expect(opts.forkOf).toBe('unknown-id');
334+
expect(opts.parent).toBeUndefined();
335+
});
336+
337+
it('sends the edited message as new and prior messages as history', async () => {
338+
const { transport, send, view, mockTurn } = createMockTransport();
339+
340+
const m1 = makeMessage('1');
341+
const edited = makeMessage('2');
342+
343+
(view.flattenNodes as ReturnType<typeof vi.fn>).mockReturnValue([
344+
{ message: m1, msgId: 'n1', parentId: undefined, forkOf: undefined, headers: {}, serial: undefined },
345+
{ message: edited, msgId: 'n2', parentId: 'n1', forkOf: undefined, headers: {}, serial: undefined },
346+
]);
347+
348+
const chat = createChatTransport(transport);
349+
350+
const streamPromise = chat.sendMessages({
351+
trigger: 'submit-message',
352+
chatId: 'chat-1',
353+
messageId: '2',
354+
messages: [m1, edited],
355+
abortSignal: undefined,
356+
});
357+
358+
mockTurn.close();
359+
await streamPromise;
360+
361+
const [msgs, opts] = send.mock.calls[0] as [AI.UIMessage[], SendOptions];
362+
expect(msgs).toEqual([edited]);
363+
// CAST: body is always set by the adapter; narrowing to non-undefined.
364+
// eslint-disable-next-line @typescript-eslint/non-nullable-type-assertion-style -- prefer `as` over `!` per TYPES.md
365+
const body = opts.body as Record<string, unknown>;
366+
const bodyHistory = body.history as { message: AI.UIMessage }[];
367+
expect(bodyHistory).toHaveLength(1);
368+
expect(bodyHistory.at(0)?.message).toEqual(m1);
369+
});
370+
});
371+
281372
describe('real stream return', () => {
282373
it('returns the turn stream with chunks flowing through', async () => {
283374
const { transport, mockTurn } = createMockTransport();

0 commit comments

Comments
 (0)