@cloudflare/ai-chat 0.0.3 → 0.0.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/index.ts CHANGED
@@ -235,6 +235,14 @@ export class AIChatAgent<
235
235
  */
236
236
  private _lastCleanupTime = 0;
237
237
 
238
+ /**
239
+ * Set of connection IDs that are pending stream resume.
240
+ * These connections have received CF_AGENT_STREAM_RESUMING but haven't sent ACK yet.
241
+ * They should be excluded from live stream broadcasts until they ACK.
242
+ * @internal
243
+ */
244
+ private _pendingResumeConnections: Set<string> = new Set();
245
+
238
246
  /** Array of chat messages for the current conversation */
239
247
  messages: ChatMessage[];
240
248
 
@@ -286,6 +294,20 @@ export class AIChatAgent<
286
294
  return _onConnect(connection, ctx);
287
295
  };
288
296
 
297
+ // Wrap onClose to clean up pending resume connections
298
+ const _onClose = this.onClose.bind(this);
299
+ this.onClose = async (
300
+ connection: Connection,
301
+ code: number,
302
+ reason: string,
303
+ wasClean: boolean
304
+ ) => {
305
+ // Clean up pending resume state for this connection
306
+ this._pendingResumeConnections.delete(connection.id);
307
+ // Call consumer's onClose
308
+ return _onClose(connection, code, reason, wasClean);
309
+ };
310
+
289
311
  // Wrap onMessage
290
312
  const _onMessage = this.onMessage.bind(this);
291
313
  this.onMessage = async (connection: Connection, message: WSMessage) => {
@@ -395,6 +417,7 @@ export class AIChatAgent<
395
417
  this._activeStreamId = null;
396
418
  this._activeRequestId = null;
397
419
  this._streamChunkIndex = 0;
420
+ this._pendingResumeConnections.clear();
398
421
  this.messages = [];
399
422
  this._broadcastChatMessage(
400
423
  { type: MessageType.CF_AGENT_CHAT_CLEAR },
@@ -418,6 +441,8 @@ export class AIChatAgent<
418
441
 
419
442
  // Handle stream resume acknowledgment
420
443
  if (data.type === MessageType.CF_AGENT_STREAM_RESUME_ACK) {
444
+ this._pendingResumeConnections.delete(connection.id);
445
+
421
446
  if (
422
447
  this._activeStreamId &&
423
448
  this._activeRequestId &&
@@ -574,6 +599,10 @@ export class AIChatAgent<
574
599
  return;
575
600
  }
576
601
 
602
+ // Add connection to pending set - they'll be excluded from live broadcasts
603
+ // until they send ACK to receive the full stream replay
604
+ this._pendingResumeConnections.add(connection.id);
605
+
577
606
  // Notify client - they will send ACK when ready
578
607
  connection.send(
579
608
  JSON.stringify({
@@ -726,6 +755,9 @@ export class AIChatAgent<
726
755
  this._activeRequestId = null;
727
756
  this._streamChunkIndex = 0;
728
757
 
758
+ // Clear pending resume connections - no active stream to resume
759
+ this._pendingResumeConnections.clear();
760
+
729
761
  // Periodically clean up old streams (not on every completion)
730
762
  this._maybeCleanupOldStreams();
731
763
  }
@@ -756,7 +788,41 @@ export class AIChatAgent<
756
788
  }
757
789
 
758
790
  private _broadcastChatMessage(message: OutgoingMessage, exclude?: string[]) {
759
- this.broadcast(JSON.stringify(message), exclude);
791
+ // Combine explicit exclusions with connections pending stream resume.
792
+ // Pending connections should not receive live stream chunks until they ACK,
793
+ // at which point they'll receive the full replay via _sendStreamChunks.
794
+ const allExclusions = [
795
+ ...(exclude || []),
796
+ ...this._pendingResumeConnections
797
+ ];
798
+ this.broadcast(JSON.stringify(message), allExclusions);
799
+ }
800
+
801
+ /**
802
+ * Broadcasts a text event for non-SSE responses.
803
+ * This ensures plain text responses follow the AI SDK v5 stream protocol.
804
+ *
805
+ * @param streamId - The stream identifier for chunk storage
806
+ * @param event - The text event payload (text-start, text-delta with delta, or text-end)
807
+ * @param continuation - Whether this is a continuation of a previous stream
808
+ */
809
+ private _broadcastTextEvent(
810
+ streamId: string,
811
+ event:
812
+ | { type: "text-start"; id: string }
813
+ | { type: "text-delta"; id: string; delta: string }
814
+ | { type: "text-end"; id: string },
815
+ continuation: boolean
816
+ ) {
817
+ const body = JSON.stringify(event);
818
+ this._storeStreamChunk(streamId, body);
819
+ this._broadcastChatMessage({
820
+ body,
821
+ done: false,
822
+ id: event.id,
823
+ type: MessageType.CF_AGENT_USE_CHAT_RESPONSE,
824
+ ...(continuation && { continuation: true })
825
+ });
760
826
  }
761
827
 
762
828
  private _loadMessagesFromDb(): ChatMessage[] {
@@ -1241,17 +1307,37 @@ export class AIChatAgent<
1241
1307
  return true;
1242
1308
  }
1243
1309
 
1244
- private async _reply(
1310
+ private async _streamSSEReply(
1245
1311
  id: string,
1246
- response: Response,
1247
- excludeBroadcastIds: string[] = [],
1248
- options: { continuation?: boolean } = {}
1312
+ streamId: string,
1313
+ reader: ReadableStreamDefaultReader<Uint8Array>,
1314
+ message: ChatMessage,
1315
+ streamCompleted: { value: boolean },
1316
+ continuation = false
1249
1317
  ) {
1250
- const { continuation = false } = options;
1251
-
1252
- return this._tryCatchChat(async () => {
1253
- if (!response.body) {
1254
- // Send empty response if no body
1318
+ let activeTextParts: Record<string, TextUIPart> = {};
1319
+ let activeReasoningParts: Record<string, ReasoningUIPart> = {};
1320
+ const partialToolCalls: Record<
1321
+ string,
1322
+ { text: string; index: number; toolName: string; dynamic?: boolean }
1323
+ > = {};
1324
+
1325
+ /* Lazy loading ai sdk, because putting it in module scope is
1326
+ * causing issues with startup time.
1327
+ * The only place it's used is in _reply, which only matters after
1328
+ * a chat message is received.
1329
+ * So it's safe to delay loading it until a chat message is received.
1330
+ */
1331
+ const { getToolName, isToolUIPart, parsePartialJson } = await import("ai");
1332
+
1333
+ streamCompleted.value = false;
1334
+ while (true) {
1335
+ const { done, value } = await reader.read();
1336
+ if (done) {
1337
+ // Mark the stream as completed
1338
+ this._completeStream(streamId);
1339
+ streamCompleted.value = true;
1340
+ // Send final completion signal
1255
1341
  this._broadcastChatMessage({
1256
1342
  body: "",
1257
1343
  done: true,
@@ -1259,659 +1345,700 @@ export class AIChatAgent<
1259
1345
  type: MessageType.CF_AGENT_USE_CHAT_RESPONSE,
1260
1346
  ...(continuation && { continuation: true })
1261
1347
  });
1262
- return;
1348
+ break;
1263
1349
  }
1264
1350
 
1265
- // Start tracking this stream for resumability
1266
- const streamId = this._startStream(id);
1351
+ const chunk = decoder.decode(value);
1352
+
1353
+ // After streaming is complete, persist the complete assistant's response
1354
+
1355
+ // Parse AI SDK v5 SSE format and extract text deltas
1356
+ const lines = chunk.split("\n");
1357
+ for (const line of lines) {
1358
+ if (line.startsWith("data: ") && line !== "data: [DONE]") {
1359
+ try {
1360
+ const data: UIMessageChunk = JSON.parse(line.slice(6)); // Remove 'data: ' prefix
1361
+ switch (data.type) {
1362
+ case "text-start": {
1363
+ const textPart: TextUIPart = {
1364
+ type: "text",
1365
+ text: "",
1366
+ providerMetadata: data.providerMetadata,
1367
+ state: "streaming"
1368
+ };
1369
+ activeTextParts[data.id] = textPart;
1370
+ message.parts.push(textPart);
1371
+ break;
1372
+ }
1267
1373
 
1268
- /* Lazy loading ai sdk, because putting it in module scope is
1269
- * causing issues with startup time.
1270
- * The only place it's used is in _reply, which only matters after
1271
- * a chat message is received.
1272
- * So it's safe to delay loading it until a chat message is received.
1273
- */
1274
- const { getToolName, isToolUIPart, parsePartialJson } =
1275
- await import("ai");
1374
+ case "text-delta": {
1375
+ const textPart = activeTextParts[data.id];
1376
+ textPart.text += data.delta;
1377
+ textPart.providerMetadata =
1378
+ data.providerMetadata ?? textPart.providerMetadata;
1379
+ break;
1380
+ }
1276
1381
 
1277
- const reader = response.body.getReader();
1382
+ case "text-end": {
1383
+ const textPart = activeTextParts[data.id];
1384
+ textPart.state = "done";
1385
+ textPart.providerMetadata =
1386
+ data.providerMetadata ?? textPart.providerMetadata;
1387
+ delete activeTextParts[data.id];
1388
+ break;
1389
+ }
1278
1390
 
1279
- // Parsing state adapted from:
1280
- // https://github.com/vercel/ai/blob/main/packages/ai/src/ui-message-stream/ui-message-chunks.ts#L295
1281
- const message: ChatMessage = {
1282
- id: `assistant_${Date.now()}_${Math.random().toString(36).slice(2, 11)}`, // default
1283
- role: "assistant",
1284
- parts: []
1285
- };
1286
- // Track the streaming message so tool results can be applied before persistence
1287
- this._streamingMessage = message;
1288
- // Set up completion promise for tool continuation to wait on
1289
- this._streamCompletionPromise = new Promise((resolve) => {
1290
- this._streamCompletionResolve = resolve;
1291
- });
1292
- let activeTextParts: Record<string, TextUIPart> = {};
1293
- let activeReasoningParts: Record<string, ReasoningUIPart> = {};
1294
- const partialToolCalls: Record<
1295
- string,
1296
- { text: string; index: number; toolName: string; dynamic?: boolean }
1297
- > = {};
1298
-
1299
- function updateDynamicToolPart(
1300
- options: {
1301
- toolName: string;
1302
- toolCallId: string;
1303
- providerExecuted?: boolean;
1304
- } & (
1305
- | {
1306
- state: "input-streaming";
1307
- input: unknown;
1308
- }
1309
- | {
1310
- state: "input-available";
1311
- input: unknown;
1312
- providerMetadata?: ProviderMetadata;
1313
- }
1314
- | {
1315
- state: "output-available";
1316
- input: unknown;
1317
- output: unknown;
1318
- preliminary: boolean | undefined;
1319
- }
1320
- | {
1321
- state: "output-error";
1322
- input: unknown;
1323
- errorText: string;
1324
- providerMetadata?: ProviderMetadata;
1325
- }
1326
- )
1327
- ) {
1328
- const part = message.parts.find(
1329
- (part) =>
1330
- part.type === "dynamic-tool" &&
1331
- part.toolCallId === options.toolCallId
1332
- ) as DynamicToolUIPart | undefined;
1333
-
1334
- const anyOptions = options as Record<string, unknown>;
1335
- const anyPart = part as Record<string, unknown>;
1336
-
1337
- if (part != null) {
1338
- part.state = options.state;
1339
- anyPart.toolName = options.toolName;
1340
- anyPart.input = anyOptions.input;
1341
- anyPart.output = anyOptions.output;
1342
- anyPart.errorText = anyOptions.errorText;
1343
- anyPart.rawInput = anyOptions.rawInput ?? anyPart.rawInput;
1344
- anyPart.preliminary = anyOptions.preliminary;
1391
+ case "reasoning-start": {
1392
+ const reasoningPart: ReasoningUIPart = {
1393
+ type: "reasoning",
1394
+ text: "",
1395
+ providerMetadata: data.providerMetadata,
1396
+ state: "streaming"
1397
+ };
1398
+ activeReasoningParts[data.id] = reasoningPart;
1399
+ message.parts.push(reasoningPart);
1400
+ break;
1401
+ }
1345
1402
 
1346
- if (
1347
- anyOptions.providerMetadata != null &&
1348
- part.state === "input-available"
1349
- ) {
1350
- part.callProviderMetadata =
1351
- anyOptions.providerMetadata as ProviderMetadata;
1352
- }
1353
- } else {
1354
- message.parts.push({
1355
- type: "dynamic-tool",
1356
- toolName: options.toolName,
1357
- toolCallId: options.toolCallId,
1358
- state: options.state,
1359
- input: anyOptions.input,
1360
- output: anyOptions.output,
1361
- errorText: anyOptions.errorText,
1362
- preliminary: anyOptions.preliminary,
1363
- ...(anyOptions.providerMetadata != null
1364
- ? { callProviderMetadata: anyOptions.providerMetadata }
1365
- : {})
1366
- } as DynamicToolUIPart);
1367
- }
1368
- }
1403
+ case "reasoning-delta": {
1404
+ const reasoningPart = activeReasoningParts[data.id];
1405
+ reasoningPart.text += data.delta;
1406
+ reasoningPart.providerMetadata =
1407
+ data.providerMetadata ?? reasoningPart.providerMetadata;
1408
+ break;
1409
+ }
1369
1410
 
1370
- function updateToolPart(
1371
- options: {
1372
- toolName: string;
1373
- toolCallId: string;
1374
- providerExecuted?: boolean;
1375
- } & (
1376
- | {
1377
- state: "input-streaming";
1378
- input: unknown;
1379
- providerExecuted?: boolean;
1380
- }
1381
- | {
1382
- state: "input-available";
1383
- input: unknown;
1384
- providerExecuted?: boolean;
1385
- providerMetadata?: ProviderMetadata;
1386
- }
1387
- | {
1388
- state: "output-available";
1389
- input: unknown;
1390
- output: unknown;
1391
- providerExecuted?: boolean;
1392
- preliminary?: boolean;
1393
- }
1394
- | {
1395
- state: "output-error";
1396
- input: unknown;
1397
- rawInput?: unknown;
1398
- errorText: string;
1399
- providerExecuted?: boolean;
1400
- providerMetadata?: ProviderMetadata;
1401
- }
1402
- )
1403
- ) {
1404
- const part = message.parts.find(
1405
- (part) =>
1406
- isToolUIPart(part) &&
1407
- (part as ToolUIPart).toolCallId === options.toolCallId
1408
- ) as ToolUIPart | undefined;
1409
-
1410
- const anyOptions = options as Record<string, unknown>;
1411
- const anyPart = part as Record<string, unknown>;
1412
-
1413
- if (part != null) {
1414
- part.state = options.state;
1415
- anyPart.input = anyOptions.input;
1416
- anyPart.output = anyOptions.output;
1417
- anyPart.errorText = anyOptions.errorText;
1418
- anyPart.rawInput = anyOptions.rawInput;
1419
- anyPart.preliminary = anyOptions.preliminary;
1420
-
1421
- // once providerExecuted is set, it stays for streaming
1422
- anyPart.providerExecuted =
1423
- anyOptions.providerExecuted ?? part.providerExecuted;
1411
+ case "reasoning-end": {
1412
+ const reasoningPart = activeReasoningParts[data.id];
1413
+ reasoningPart.providerMetadata =
1414
+ data.providerMetadata ?? reasoningPart.providerMetadata;
1415
+ reasoningPart.state = "done";
1416
+ delete activeReasoningParts[data.id];
1424
1417
 
1425
- if (
1426
- anyOptions.providerMetadata != null &&
1427
- part.state === "input-available"
1428
- ) {
1429
- part.callProviderMetadata =
1430
- anyOptions.providerMetadata as ProviderMetadata;
1431
- }
1432
- } else {
1433
- message.parts.push({
1434
- type: `tool-${options.toolName}`,
1435
- toolCallId: options.toolCallId,
1436
- state: options.state,
1437
- input: anyOptions.input,
1438
- output: anyOptions.output,
1439
- rawInput: anyOptions.rawInput,
1440
- errorText: anyOptions.errorText,
1441
- providerExecuted: anyOptions.providerExecuted,
1442
- preliminary: anyOptions.preliminary,
1443
- ...(anyOptions.providerMetadata != null
1444
- ? { callProviderMetadata: anyOptions.providerMetadata }
1445
- : {})
1446
- } as ToolUIPart);
1447
- }
1448
- }
1418
+ break;
1419
+ }
1449
1420
 
1450
- async function updateMessageMetadata(metadata: unknown) {
1451
- if (metadata != null) {
1452
- const mergedMetadata =
1453
- message.metadata != null
1454
- ? { ...message.metadata, ...metadata } // TODO: do proper merging
1455
- : metadata;
1421
+ case "file": {
1422
+ message.parts.push({
1423
+ type: "file",
1424
+ mediaType: data.mediaType,
1425
+ url: data.url
1426
+ });
1456
1427
 
1457
- message.metadata = mergedMetadata;
1458
- }
1459
- }
1428
+ break;
1429
+ }
1460
1430
 
1461
- let streamCompleted = false;
1462
- try {
1463
- while (true) {
1464
- const { done, value } = await reader.read();
1465
- if (done) {
1466
- // Mark the stream as completed
1467
- this._completeStream(streamId);
1468
- streamCompleted = true;
1469
- // Send final completion signal
1470
- this._broadcastChatMessage({
1471
- body: "",
1472
- done: true,
1473
- id,
1474
- type: MessageType.CF_AGENT_USE_CHAT_RESPONSE,
1475
- ...(continuation && { continuation: true })
1476
- });
1477
- break;
1478
- }
1431
+ case "source-url": {
1432
+ message.parts.push({
1433
+ type: "source-url",
1434
+ sourceId: data.sourceId,
1435
+ url: data.url,
1436
+ title: data.title,
1437
+ providerMetadata: data.providerMetadata
1438
+ });
1479
1439
 
1480
- const chunk = decoder.decode(value);
1481
-
1482
- // Determine response format based on content-type
1483
- const contentType = response.headers.get("content-type") || "";
1484
- const isSSE = contentType.includes("text/event-stream");
1485
-
1486
- // After streaming is complete, persist the complete assistant's response
1487
- if (isSSE) {
1488
- // Parse AI SDK v5 SSE format and extract text deltas
1489
- const lines = chunk.split("\n");
1490
- for (const line of lines) {
1491
- if (line.startsWith("data: ") && line !== "data: [DONE]") {
1492
- try {
1493
- const data: UIMessageChunk = JSON.parse(line.slice(6)); // Remove 'data: ' prefix
1494
- switch (data.type) {
1495
- case "text-start": {
1496
- const textPart: TextUIPart = {
1497
- type: "text",
1498
- text: "",
1499
- providerMetadata: data.providerMetadata,
1500
- state: "streaming"
1501
- };
1502
- activeTextParts[data.id] = textPart;
1503
- message.parts.push(textPart);
1504
- break;
1505
- }
1506
-
1507
- case "text-delta": {
1508
- const textPart = activeTextParts[data.id];
1509
- textPart.text += data.delta;
1510
- textPart.providerMetadata =
1511
- data.providerMetadata ?? textPart.providerMetadata;
1512
- break;
1513
- }
1514
-
1515
- case "text-end": {
1516
- const textPart = activeTextParts[data.id];
1517
- textPart.state = "done";
1518
- textPart.providerMetadata =
1519
- data.providerMetadata ?? textPart.providerMetadata;
1520
- delete activeTextParts[data.id];
1521
- break;
1522
- }
1523
-
1524
- case "reasoning-start": {
1525
- const reasoningPart: ReasoningUIPart = {
1526
- type: "reasoning",
1527
- text: "",
1528
- providerMetadata: data.providerMetadata,
1529
- state: "streaming"
1530
- };
1531
- activeReasoningParts[data.id] = reasoningPart;
1532
- message.parts.push(reasoningPart);
1533
- break;
1534
- }
1535
-
1536
- case "reasoning-delta": {
1537
- const reasoningPart = activeReasoningParts[data.id];
1538
- reasoningPart.text += data.delta;
1539
- reasoningPart.providerMetadata =
1540
- data.providerMetadata ?? reasoningPart.providerMetadata;
1541
- break;
1542
- }
1543
-
1544
- case "reasoning-end": {
1545
- const reasoningPart = activeReasoningParts[data.id];
1546
- reasoningPart.providerMetadata =
1547
- data.providerMetadata ?? reasoningPart.providerMetadata;
1548
- reasoningPart.state = "done";
1549
- delete activeReasoningParts[data.id];
1550
-
1551
- break;
1552
- }
1553
-
1554
- case "file": {
1555
- message.parts.push({
1556
- type: "file",
1557
- mediaType: data.mediaType,
1558
- url: data.url
1559
- });
1560
-
1561
- break;
1562
- }
1563
-
1564
- case "source-url": {
1565
- message.parts.push({
1566
- type: "source-url",
1567
- sourceId: data.sourceId,
1568
- url: data.url,
1569
- title: data.title,
1570
- providerMetadata: data.providerMetadata
1571
- });
1572
-
1573
- break;
1574
- }
1575
-
1576
- case "source-document": {
1577
- message.parts.push({
1578
- type: "source-document",
1579
- sourceId: data.sourceId,
1580
- mediaType: data.mediaType,
1581
- title: data.title,
1582
- filename: data.filename,
1583
- providerMetadata: data.providerMetadata
1584
- });
1585
-
1586
- break;
1587
- }
1588
-
1589
- case "tool-input-start": {
1590
- const toolInvocations =
1591
- message.parts.filter(isToolUIPart);
1592
-
1593
- // add the partial tool call to the map
1594
- partialToolCalls[data.toolCallId] = {
1595
- text: "",
1596
- toolName: data.toolName,
1597
- index: toolInvocations.length,
1598
- dynamic: data.dynamic
1599
- };
1600
-
1601
- if (data.dynamic) {
1602
- updateDynamicToolPart({
1603
- toolCallId: data.toolCallId,
1604
- toolName: data.toolName,
1605
- state: "input-streaming",
1606
- input: undefined
1607
- });
1608
- } else {
1609
- updateToolPart({
1610
- toolCallId: data.toolCallId,
1611
- toolName: data.toolName,
1612
- state: "input-streaming",
1613
- input: undefined
1614
- });
1615
- }
1440
+ break;
1441
+ }
1616
1442
 
1617
- break;
1618
- }
1619
-
1620
- case "tool-input-delta": {
1621
- const partialToolCall = partialToolCalls[data.toolCallId];
1622
-
1623
- partialToolCall.text += data.inputTextDelta;
1624
-
1625
- const partialArgsResult = await parsePartialJson(
1626
- partialToolCall.text
1627
- );
1628
- const partialArgs = (
1629
- partialArgsResult as { value: Record<string, unknown> }
1630
- ).value;
1631
-
1632
- if (partialToolCall.dynamic) {
1633
- updateDynamicToolPart({
1634
- toolCallId: data.toolCallId,
1635
- toolName: partialToolCall.toolName,
1636
- state: "input-streaming",
1637
- input: partialArgs
1638
- });
1639
- } else {
1640
- updateToolPart({
1641
- toolCallId: data.toolCallId,
1642
- toolName: partialToolCall.toolName,
1643
- state: "input-streaming",
1644
- input: partialArgs
1645
- });
1646
- }
1443
+ case "source-document": {
1444
+ message.parts.push({
1445
+ type: "source-document",
1446
+ sourceId: data.sourceId,
1447
+ mediaType: data.mediaType,
1448
+ title: data.title,
1449
+ filename: data.filename,
1450
+ providerMetadata: data.providerMetadata
1451
+ });
1647
1452
 
1648
- break;
1649
- }
1650
-
1651
- case "tool-input-available": {
1652
- if (data.dynamic) {
1653
- updateDynamicToolPart({
1654
- toolCallId: data.toolCallId,
1655
- toolName: data.toolName,
1656
- state: "input-available",
1657
- input: data.input,
1658
- providerMetadata: data.providerMetadata
1659
- });
1660
- } else {
1661
- updateToolPart({
1662
- toolCallId: data.toolCallId,
1663
- toolName: data.toolName,
1664
- state: "input-available",
1665
- input: data.input,
1666
- providerExecuted: data.providerExecuted,
1667
- providerMetadata: data.providerMetadata
1668
- });
1669
- }
1453
+ break;
1454
+ }
1670
1455
 
1671
- // TODO: Do we want to expose onToolCall?
1672
-
1673
- // invoke the onToolCall callback if it exists. This is blocking.
1674
- // In the future we should make this non-blocking, which
1675
- // requires additional state management for error handling etc.
1676
- // Skip calling onToolCall for provider-executed tools since they are already executed
1677
- // if (onToolCall && !data.providerExecuted) {
1678
- // await onToolCall({
1679
- // toolCall: data
1680
- // });
1681
- // }
1682
- break;
1683
- }
1684
-
1685
- case "tool-input-error": {
1686
- if (data.dynamic) {
1687
- updateDynamicToolPart({
1688
- toolCallId: data.toolCallId,
1689
- toolName: data.toolName,
1690
- state: "output-error",
1691
- input: data.input,
1692
- errorText: data.errorText,
1693
- providerMetadata: data.providerMetadata
1694
- });
1695
- } else {
1696
- updateToolPart({
1697
- toolCallId: data.toolCallId,
1698
- toolName: data.toolName,
1699
- state: "output-error",
1700
- input: undefined,
1701
- rawInput: data.input,
1702
- errorText: data.errorText,
1703
- providerExecuted: data.providerExecuted,
1704
- providerMetadata: data.providerMetadata
1705
- });
1706
- }
1456
+ case "tool-input-start": {
1457
+ const toolInvocations = message.parts.filter(isToolUIPart);
1458
+
1459
+ // add the partial tool call to the map
1460
+ partialToolCalls[data.toolCallId] = {
1461
+ text: "",
1462
+ toolName: data.toolName,
1463
+ index: toolInvocations.length,
1464
+ dynamic: data.dynamic
1465
+ };
1707
1466
 
1708
- break;
1709
- }
1467
+ if (data.dynamic) {
1468
+ this.updateDynamicToolPart(message, {
1469
+ toolCallId: data.toolCallId,
1470
+ toolName: data.toolName,
1471
+ state: "input-streaming",
1472
+ input: undefined
1473
+ });
1474
+ } else {
1475
+ await this.updateToolPart(message, {
1476
+ toolCallId: data.toolCallId,
1477
+ toolName: data.toolName,
1478
+ state: "input-streaming",
1479
+ input: undefined
1480
+ });
1481
+ }
1710
1482
 
1711
- case "tool-output-available": {
1712
- if (data.dynamic) {
1713
- const toolInvocations = message.parts.filter(
1714
- (part) => part.type === "dynamic-tool"
1715
- ) as DynamicToolUIPart[];
1483
+ break;
1484
+ }
1716
1485
 
1717
- const toolInvocation = toolInvocations.find(
1718
- (invocation) =>
1719
- invocation.toolCallId === data.toolCallId
1720
- );
1486
+ case "tool-input-delta": {
1487
+ const partialToolCall = partialToolCalls[data.toolCallId];
1721
1488
 
1722
- if (!toolInvocation)
1723
- throw new Error("Tool invocation not found");
1724
-
1725
- updateDynamicToolPart({
1726
- toolCallId: data.toolCallId,
1727
- toolName: toolInvocation.toolName,
1728
- state: "output-available",
1729
- input: toolInvocation.input,
1730
- output: data.output,
1731
- preliminary: data.preliminary
1732
- });
1733
- } else {
1734
- const toolInvocations = message.parts.filter(
1735
- isToolUIPart
1736
- ) as ToolUIPart[];
1737
-
1738
- const toolInvocation = toolInvocations.find(
1739
- (invocation) =>
1740
- invocation.toolCallId === data.toolCallId
1741
- );
1489
+ partialToolCall.text += data.inputTextDelta;
1742
1490
 
1743
- if (!toolInvocation)
1744
- throw new Error("Tool invocation not found");
1745
-
1746
- updateToolPart({
1747
- toolCallId: data.toolCallId,
1748
- toolName: getToolName(toolInvocation),
1749
- state: "output-available",
1750
- input: toolInvocation.input,
1751
- output: data.output,
1752
- providerExecuted: data.providerExecuted,
1753
- preliminary: data.preliminary
1754
- });
1755
- }
1491
+ const partialArgsResult = await parsePartialJson(
1492
+ partialToolCall.text
1493
+ );
1494
+ const partialArgs = (
1495
+ partialArgsResult as { value: Record<string, unknown> }
1496
+ ).value;
1497
+
1498
+ if (partialToolCall.dynamic) {
1499
+ this.updateDynamicToolPart(message, {
1500
+ toolCallId: data.toolCallId,
1501
+ toolName: partialToolCall.toolName,
1502
+ state: "input-streaming",
1503
+ input: partialArgs
1504
+ });
1505
+ } else {
1506
+ await this.updateToolPart(message, {
1507
+ toolCallId: data.toolCallId,
1508
+ toolName: partialToolCall.toolName,
1509
+ state: "input-streaming",
1510
+ input: partialArgs
1511
+ });
1512
+ }
1756
1513
 
1757
- break;
1758
- }
1514
+ break;
1515
+ }
1759
1516
 
1760
- case "tool-output-error": {
1761
- if (data.dynamic) {
1762
- const toolInvocations = message.parts.filter(
1763
- (part) => part.type === "dynamic-tool"
1764
- ) as DynamicToolUIPart[];
1517
+ case "tool-input-available": {
1518
+ if (data.dynamic) {
1519
+ this.updateDynamicToolPart(message, {
1520
+ toolCallId: data.toolCallId,
1521
+ toolName: data.toolName,
1522
+ state: "input-available",
1523
+ input: data.input,
1524
+ providerMetadata: data.providerMetadata
1525
+ });
1526
+ } else {
1527
+ await this.updateToolPart(message, {
1528
+ toolCallId: data.toolCallId,
1529
+ toolName: data.toolName,
1530
+ state: "input-available",
1531
+ input: data.input,
1532
+ providerExecuted: data.providerExecuted,
1533
+ providerMetadata: data.providerMetadata
1534
+ });
1535
+ }
1765
1536
 
1766
- const toolInvocation = toolInvocations.find(
1767
- (invocation) =>
1768
- invocation.toolCallId === data.toolCallId
1769
- );
1537
+ // TODO: Do we want to expose onToolCall?
1538
+
1539
+ // invoke the onToolCall callback if it exists. This is blocking.
1540
+ // In the future we should make this non-blocking, which
1541
+ // requires additional state management for error handling etc.
1542
+ // Skip calling onToolCall for provider-executed tools since they are already executed
1543
+ // if (onToolCall && !data.providerExecuted) {
1544
+ // await onToolCall({
1545
+ // toolCall: data
1546
+ // });
1547
+ // }
1548
+ break;
1549
+ }
1770
1550
 
1771
- if (!toolInvocation)
1772
- throw new Error("Tool invocation not found");
1773
-
1774
- updateDynamicToolPart({
1775
- toolCallId: data.toolCallId,
1776
- toolName: toolInvocation.toolName,
1777
- state: "output-error",
1778
- input: toolInvocation.input,
1779
- errorText: data.errorText
1780
- });
1781
- } else {
1782
- const toolInvocations = message.parts.filter(
1783
- isToolUIPart
1784
- ) as ToolUIPart[];
1785
-
1786
- const toolInvocation = toolInvocations.find(
1787
- (invocation) =>
1788
- invocation.toolCallId === data.toolCallId
1789
- );
1551
+ case "tool-input-error": {
1552
+ if (data.dynamic) {
1553
+ this.updateDynamicToolPart(message, {
1554
+ toolCallId: data.toolCallId,
1555
+ toolName: data.toolName,
1556
+ state: "output-error",
1557
+ input: data.input,
1558
+ errorText: data.errorText,
1559
+ providerMetadata: data.providerMetadata
1560
+ });
1561
+ } else {
1562
+ await this.updateToolPart(message, {
1563
+ toolCallId: data.toolCallId,
1564
+ toolName: data.toolName,
1565
+ state: "output-error",
1566
+ input: undefined,
1567
+ rawInput: data.input,
1568
+ errorText: data.errorText,
1569
+ providerExecuted: data.providerExecuted,
1570
+ providerMetadata: data.providerMetadata
1571
+ });
1572
+ }
1790
1573
 
1791
- if (!toolInvocation)
1792
- throw new Error("Tool invocation not found");
1793
- updateToolPart({
1794
- toolCallId: data.toolCallId,
1795
- toolName: getToolName(toolInvocation),
1796
- state: "output-error",
1797
- input: toolInvocation.input,
1798
- rawInput:
1799
- "rawInput" in toolInvocation
1800
- ? toolInvocation.rawInput
1801
- : undefined,
1802
- errorText: data.errorText
1803
- });
1804
- }
1574
+ break;
1575
+ }
1805
1576
 
1806
- break;
1807
- }
1808
-
1809
- case "start-step": {
1810
- // add a step boundary part to the message
1811
- message.parts.push({ type: "step-start" });
1812
- break;
1813
- }
1814
-
1815
- case "finish-step": {
1816
- // reset the current text and reasoning parts
1817
- activeTextParts = {};
1818
- activeReasoningParts = {};
1819
- break;
1820
- }
1821
-
1822
- case "start": {
1823
- if (data.messageId != null) {
1824
- message.id = data.messageId;
1825
- }
1577
+ case "tool-output-available": {
1578
+ if (data.dynamic) {
1579
+ const toolInvocations = message.parts.filter(
1580
+ (part) => part.type === "dynamic-tool"
1581
+ ) as DynamicToolUIPart[];
1826
1582
 
1827
- await updateMessageMetadata(data.messageMetadata);
1828
-
1829
- break;
1830
- }
1831
-
1832
- case "finish": {
1833
- await updateMessageMetadata(data.messageMetadata);
1834
- break;
1835
- }
1836
-
1837
- case "message-metadata": {
1838
- await updateMessageMetadata(data.messageMetadata);
1839
- break;
1840
- }
1841
-
1842
- case "error": {
1843
- this._broadcastChatMessage({
1844
- error: true,
1845
- body: data.errorText ?? JSON.stringify(data),
1846
- done: false,
1847
- id,
1848
- type: MessageType.CF_AGENT_USE_CHAT_RESPONSE
1849
- });
1850
-
1851
- break;
1852
- }
1853
- // Do we want to handle data parts?
1854
- }
1583
+ const toolInvocation = toolInvocations.find(
1584
+ (invocation) => invocation.toolCallId === data.toolCallId
1585
+ );
1855
1586
 
1856
- // Convert internal AI SDK stream events to valid UIMessageStreamPart format.
1857
- // The "finish" event with "finishReason" is an internal LanguageModelV3StreamPart,
1858
- // not a UIMessageStreamPart (which expects "messageMetadata" instead).
1859
- // See: https://github.com/cloudflare/agents/issues/677
1860
- let eventToSend: unknown = data;
1861
- if (data.type === "finish" && "finishReason" in data) {
1862
- const { finishReason, ...rest } = data as {
1863
- finishReason: string;
1864
- [key: string]: unknown;
1865
- };
1866
- eventToSend = {
1867
- ...rest,
1868
- type: "finish",
1869
- messageMetadata: { finishReason }
1870
- };
1871
- }
1587
+ if (!toolInvocation)
1588
+ throw new Error("Tool invocation not found");
1872
1589
 
1873
- // Store chunk for replay on reconnection
1874
- const chunkBody = JSON.stringify(eventToSend);
1875
- this._storeStreamChunk(streamId, chunkBody);
1876
-
1877
- // Forward the converted event to the client
1878
- this._broadcastChatMessage({
1879
- body: chunkBody,
1880
- done: false,
1881
- id,
1882
- type: MessageType.CF_AGENT_USE_CHAT_RESPONSE,
1883
- ...(continuation && { continuation: true })
1590
+ this.updateDynamicToolPart(message, {
1591
+ toolCallId: data.toolCallId,
1592
+ toolName: toolInvocation.toolName,
1593
+ state: "output-available",
1594
+ input: toolInvocation.input,
1595
+ output: data.output,
1596
+ preliminary: data.preliminary
1597
+ });
1598
+ } else {
1599
+ const toolInvocations = message.parts.filter(
1600
+ isToolUIPart
1601
+ ) as ToolUIPart[];
1602
+
1603
+ const toolInvocation = toolInvocations.find(
1604
+ (invocation) => invocation.toolCallId === data.toolCallId
1605
+ );
1606
+
1607
+ if (!toolInvocation)
1608
+ throw new Error("Tool invocation not found");
1609
+
1610
+ await this.updateToolPart(message, {
1611
+ toolCallId: data.toolCallId,
1612
+ toolName: getToolName(toolInvocation),
1613
+ state: "output-available",
1614
+ input: toolInvocation.input,
1615
+ output: data.output,
1616
+ providerExecuted: data.providerExecuted,
1617
+ preliminary: data.preliminary
1884
1618
  });
1885
- } catch (_error) {
1886
- // Skip malformed JSON lines silently
1887
1619
  }
1620
+
1621
+ break;
1888
1622
  }
1623
+
1624
+ case "tool-output-error": {
1625
+ if (data.dynamic) {
1626
+ const toolInvocations = message.parts.filter(
1627
+ (part) => part.type === "dynamic-tool"
1628
+ ) as DynamicToolUIPart[];
1629
+
1630
+ const toolInvocation = toolInvocations.find(
1631
+ (invocation) => invocation.toolCallId === data.toolCallId
1632
+ );
1633
+
1634
+ if (!toolInvocation)
1635
+ throw new Error("Tool invocation not found");
1636
+
1637
+ this.updateDynamicToolPart(message, {
1638
+ toolCallId: data.toolCallId,
1639
+ toolName: toolInvocation.toolName,
1640
+ state: "output-error",
1641
+ input: toolInvocation.input,
1642
+ errorText: data.errorText
1643
+ });
1644
+ } else {
1645
+ const toolInvocations = message.parts.filter(
1646
+ isToolUIPart
1647
+ ) as ToolUIPart[];
1648
+
1649
+ const toolInvocation = toolInvocations.find(
1650
+ (invocation) => invocation.toolCallId === data.toolCallId
1651
+ );
1652
+
1653
+ if (!toolInvocation)
1654
+ throw new Error("Tool invocation not found");
1655
+ await this.updateToolPart(message, {
1656
+ toolCallId: data.toolCallId,
1657
+ toolName: getToolName(toolInvocation),
1658
+ state: "output-error",
1659
+ input: toolInvocation.input,
1660
+ rawInput:
1661
+ "rawInput" in toolInvocation
1662
+ ? toolInvocation.rawInput
1663
+ : undefined,
1664
+ errorText: data.errorText
1665
+ });
1666
+ }
1667
+
1668
+ break;
1669
+ }
1670
+
1671
+ case "start-step": {
1672
+ // add a step boundary part to the message
1673
+ message.parts.push({ type: "step-start" });
1674
+ break;
1675
+ }
1676
+
1677
+ case "finish-step": {
1678
+ // reset the current text and reasoning parts
1679
+ activeTextParts = {};
1680
+ activeReasoningParts = {};
1681
+ break;
1682
+ }
1683
+
1684
+ case "start": {
1685
+ if (data.messageId != null) {
1686
+ message.id = data.messageId;
1687
+ }
1688
+
1689
+ await this.updateMessageMetadata(message, data.messageMetadata);
1690
+
1691
+ break;
1692
+ }
1693
+
1694
+ case "finish": {
1695
+ await this.updateMessageMetadata(message, data.messageMetadata);
1696
+ break;
1697
+ }
1698
+
1699
+ case "message-metadata": {
1700
+ await this.updateMessageMetadata(message, data.messageMetadata);
1701
+ break;
1702
+ }
1703
+
1704
+ case "error": {
1705
+ this._broadcastChatMessage({
1706
+ error: true,
1707
+ body: data.errorText ?? JSON.stringify(data),
1708
+ done: false,
1709
+ id,
1710
+ type: MessageType.CF_AGENT_USE_CHAT_RESPONSE
1711
+ });
1712
+
1713
+ break;
1714
+ }
1715
+ // Do we want to handle data parts?
1889
1716
  }
1890
- } else {
1891
- // Handle plain text responses (e.g., from generateText)
1892
- // Treat the entire chunk as a text delta to preserve exact formatting
1893
- if (chunk.length > 0) {
1894
- message.parts.push({ type: "text", text: chunk });
1895
- // Synthesize a text-delta event so clients can stream-render
1896
- const chunkBody = JSON.stringify({
1897
- type: "text-delta",
1898
- delta: chunk
1899
- });
1900
- // Store chunk for replay on reconnection
1901
- this._storeStreamChunk(streamId, chunkBody);
1902
- this._broadcastChatMessage({
1903
- body: chunkBody,
1904
- done: false,
1905
- id,
1906
- type: MessageType.CF_AGENT_USE_CHAT_RESPONSE,
1907
- ...(continuation && { continuation: true })
1908
- });
1717
+
1718
+ // Convert internal AI SDK stream events to valid UIMessageStreamPart format.
1719
+ // The "finish" event with "finishReason" is an internal LanguageModelV3StreamPart,
1720
+ // not a UIMessageStreamPart (which expects "messageMetadata" instead).
1721
+ // See: https://github.com/cloudflare/agents/issues/677
1722
+ let eventToSend: unknown = data;
1723
+ if (data.type === "finish" && "finishReason" in data) {
1724
+ const { finishReason, ...rest } = data as {
1725
+ finishReason: string;
1726
+ [key: string]: unknown;
1727
+ };
1728
+ eventToSend = {
1729
+ ...rest,
1730
+ type: "finish",
1731
+ messageMetadata: { finishReason }
1732
+ };
1909
1733
  }
1734
+
1735
+ // Store chunk for replay on reconnection
1736
+ const chunkBody = JSON.stringify(eventToSend);
1737
+ this._storeStreamChunk(streamId, chunkBody);
1738
+
1739
+ // Forward the converted event to the client
1740
+ this._broadcastChatMessage({
1741
+ body: chunkBody,
1742
+ done: false,
1743
+ id,
1744
+ type: MessageType.CF_AGENT_USE_CHAT_RESPONSE,
1745
+ ...(continuation && { continuation: true })
1746
+ });
1747
+ } catch (_error) {
1748
+ // Skip malformed JSON lines silently
1910
1749
  }
1911
1750
  }
1751
+ }
1752
+ }
1753
+ }
1754
+
1755
+ // Handle plain text responses (e.g., from generateText)
1756
+ private async _sendPlaintextReply(
1757
+ id: string,
1758
+ streamId: string,
1759
+ reader: ReadableStreamDefaultReader<Uint8Array>,
1760
+ message: ChatMessage,
1761
+ streamCompleted: { value: boolean },
1762
+ continuation = false
1763
+ ) {
1764
+ // if not AI SDK SSE format, we need to inject text-start and text-end events ourselves
1765
+ this._broadcastTextEvent(
1766
+ streamId,
1767
+ { type: "text-start", id },
1768
+ continuation
1769
+ );
1770
+
1771
+ while (true) {
1772
+ const { done, value } = await reader.read();
1773
+ if (done) {
1774
+ this._broadcastTextEvent(
1775
+ streamId,
1776
+ { type: "text-end", id },
1777
+ continuation
1778
+ );
1779
+
1780
+ // Mark the stream as completed
1781
+ this._completeStream(streamId);
1782
+ streamCompleted.value = true;
1783
+ // Send final completion signal
1784
+ this._broadcastChatMessage({
1785
+ body: "",
1786
+ done: true,
1787
+ id,
1788
+ type: MessageType.CF_AGENT_USE_CHAT_RESPONSE,
1789
+ ...(continuation && { continuation: true })
1790
+ });
1791
+ break;
1792
+ }
1793
+
1794
+ const chunk = decoder.decode(value);
1795
+
1796
+ // Treat the entire chunk as a text delta to preserve exact formatting
1797
+ if (chunk.length > 0) {
1798
+ message.parts.push({ type: "text", text: chunk });
1799
+ this._broadcastTextEvent(
1800
+ streamId,
1801
+ { type: "text-delta", id, delta: chunk },
1802
+ continuation
1803
+ );
1804
+ }
1805
+ }
1806
+ }
1807
+
1808
+ private updateDynamicToolPart(
1809
+ message: ChatMessage,
1810
+ options: {
1811
+ toolName: string;
1812
+ toolCallId: string;
1813
+ providerExecuted?: boolean;
1814
+ } & (
1815
+ | {
1816
+ state: "input-streaming";
1817
+ input: unknown;
1818
+ }
1819
+ | {
1820
+ state: "input-available";
1821
+ input: unknown;
1822
+ providerMetadata?: ProviderMetadata;
1823
+ }
1824
+ | {
1825
+ state: "output-available";
1826
+ input: unknown;
1827
+ output: unknown;
1828
+ preliminary: boolean | undefined;
1829
+ }
1830
+ | {
1831
+ state: "output-error";
1832
+ input: unknown;
1833
+ errorText: string;
1834
+ providerMetadata?: ProviderMetadata;
1835
+ }
1836
+ )
1837
+ ) {
1838
+ const part = message.parts.find(
1839
+ (part) =>
1840
+ part.type === "dynamic-tool" && part.toolCallId === options.toolCallId
1841
+ ) as DynamicToolUIPart | undefined;
1842
+
1843
+ const anyOptions = options as Record<string, unknown>;
1844
+ const anyPart = part as Record<string, unknown>;
1845
+
1846
+ if (part != null) {
1847
+ part.state = options.state;
1848
+ anyPart.toolName = options.toolName;
1849
+ anyPart.input = anyOptions.input;
1850
+ anyPart.output = anyOptions.output;
1851
+ anyPart.errorText = anyOptions.errorText;
1852
+ anyPart.rawInput = anyOptions.rawInput ?? anyPart.rawInput;
1853
+ anyPart.preliminary = anyOptions.preliminary;
1854
+
1855
+ if (
1856
+ anyOptions.providerMetadata != null &&
1857
+ part.state === "input-available"
1858
+ ) {
1859
+ part.callProviderMetadata =
1860
+ anyOptions.providerMetadata as ProviderMetadata;
1861
+ }
1862
+ } else {
1863
+ message.parts.push({
1864
+ type: "dynamic-tool",
1865
+ toolName: options.toolName,
1866
+ toolCallId: options.toolCallId,
1867
+ state: options.state,
1868
+ input: anyOptions.input,
1869
+ output: anyOptions.output,
1870
+ errorText: anyOptions.errorText,
1871
+ preliminary: anyOptions.preliminary,
1872
+ ...(anyOptions.providerMetadata != null
1873
+ ? { callProviderMetadata: anyOptions.providerMetadata }
1874
+ : {})
1875
+ } as DynamicToolUIPart);
1876
+ }
1877
+ }
1878
+
1879
+ private async updateToolPart(
1880
+ message: ChatMessage,
1881
+ options: {
1882
+ toolName: string;
1883
+ toolCallId: string;
1884
+ providerExecuted?: boolean;
1885
+ } & (
1886
+ | {
1887
+ state: "input-streaming";
1888
+ input: unknown;
1889
+ providerExecuted?: boolean;
1890
+ }
1891
+ | {
1892
+ state: "input-available";
1893
+ input: unknown;
1894
+ providerExecuted?: boolean;
1895
+ providerMetadata?: ProviderMetadata;
1896
+ }
1897
+ | {
1898
+ state: "output-available";
1899
+ input: unknown;
1900
+ output: unknown;
1901
+ providerExecuted?: boolean;
1902
+ preliminary?: boolean;
1903
+ }
1904
+ | {
1905
+ state: "output-error";
1906
+ input: unknown;
1907
+ rawInput?: unknown;
1908
+ errorText: string;
1909
+ providerExecuted?: boolean;
1910
+ providerMetadata?: ProviderMetadata;
1911
+ }
1912
+ )
1913
+ ) {
1914
+ const { isToolUIPart } = await import("ai");
1915
+
1916
+ const part = message.parts.find(
1917
+ (part) =>
1918
+ isToolUIPart(part) &&
1919
+ (part as ToolUIPart).toolCallId === options.toolCallId
1920
+ ) as ToolUIPart | undefined;
1921
+
1922
+ const anyOptions = options as Record<string, unknown>;
1923
+ const anyPart = part as Record<string, unknown>;
1924
+
1925
+ if (part != null) {
1926
+ part.state = options.state;
1927
+ anyPart.input = anyOptions.input;
1928
+ anyPart.output = anyOptions.output;
1929
+ anyPart.errorText = anyOptions.errorText;
1930
+ anyPart.rawInput = anyOptions.rawInput;
1931
+ anyPart.preliminary = anyOptions.preliminary;
1932
+
1933
+ // once providerExecuted is set, it stays for streaming
1934
+ anyPart.providerExecuted =
1935
+ anyOptions.providerExecuted ?? part.providerExecuted;
1936
+
1937
+ if (
1938
+ anyOptions.providerMetadata != null &&
1939
+ part.state === "input-available"
1940
+ ) {
1941
+ part.callProviderMetadata =
1942
+ anyOptions.providerMetadata as ProviderMetadata;
1943
+ }
1944
+ } else {
1945
+ message.parts.push({
1946
+ type: `tool-${options.toolName}`,
1947
+ toolCallId: options.toolCallId,
1948
+ state: options.state,
1949
+ input: anyOptions.input,
1950
+ output: anyOptions.output,
1951
+ rawInput: anyOptions.rawInput,
1952
+ errorText: anyOptions.errorText,
1953
+ providerExecuted: anyOptions.providerExecuted,
1954
+ preliminary: anyOptions.preliminary,
1955
+ ...(anyOptions.providerMetadata != null
1956
+ ? { callProviderMetadata: anyOptions.providerMetadata }
1957
+ : {})
1958
+ } as ToolUIPart);
1959
+ }
1960
+ }
1961
+
1962
+ private async updateMessageMetadata(message: ChatMessage, metadata: unknown) {
1963
+ if (metadata != null) {
1964
+ const mergedMetadata =
1965
+ message.metadata != null
1966
+ ? { ...message.metadata, ...metadata } // TODO: do proper merging
1967
+ : metadata;
1968
+
1969
+ message.metadata = mergedMetadata;
1970
+ }
1971
+ }
1972
+
1973
+ private async _reply(
1974
+ id: string,
1975
+ response: Response,
1976
+ excludeBroadcastIds: string[] = [],
1977
+ options: { continuation?: boolean } = {}
1978
+ ) {
1979
+ const { continuation = false } = options;
1980
+
1981
+ return this._tryCatchChat(async () => {
1982
+ if (!response.body) {
1983
+ // Send empty response if no body
1984
+ this._broadcastChatMessage({
1985
+ body: "",
1986
+ done: true,
1987
+ id,
1988
+ type: MessageType.CF_AGENT_USE_CHAT_RESPONSE,
1989
+ ...(continuation && { continuation: true })
1990
+ });
1991
+ return;
1992
+ }
1993
+
1994
+ // Start tracking this stream for resumability
1995
+ const streamId = this._startStream(id);
1996
+
1997
+ const reader = response.body.getReader();
1998
+
1999
+ // Parsing state adapted from:
2000
+ // https://github.com/vercel/ai/blob/main/packages/ai/src/ui-message-stream/ui-message-chunks.ts#L295
2001
+ const message: ChatMessage = {
2002
+ id: `assistant_${Date.now()}_${Math.random().toString(36).slice(2, 11)}`, // default
2003
+ role: "assistant",
2004
+ parts: []
2005
+ };
2006
+ // Track the streaming message so tool results can be applied before persistence
2007
+ this._streamingMessage = message;
2008
+ // Set up completion promise for tool continuation to wait on
2009
+ this._streamCompletionPromise = new Promise((resolve) => {
2010
+ this._streamCompletionResolve = resolve;
2011
+ });
2012
+
2013
+ // Determine response format based on content-type
2014
+ const contentType = response.headers.get("content-type") || "";
2015
+ const isSSE = contentType.includes("text/event-stream"); // AI SDK v5 SSE format
2016
+ const streamCompleted = { value: false };
2017
+
2018
+ try {
2019
+ if (isSSE) {
2020
+ // AI SDK v5 SSE format
2021
+ await this._streamSSEReply(
2022
+ id,
2023
+ streamId,
2024
+ reader,
2025
+ message,
2026
+ streamCompleted,
2027
+ continuation
2028
+ );
2029
+ } else {
2030
+ await this._sendPlaintextReply(
2031
+ id,
2032
+ streamId,
2033
+ reader,
2034
+ message,
2035
+ streamCompleted,
2036
+ continuation
2037
+ );
2038
+ }
1912
2039
  } catch (error) {
1913
2040
  // Mark stream as error if not already completed
1914
- if (!streamCompleted) {
2041
+ if (!streamCompleted.value) {
1915
2042
  this._markStreamError(streamId);
1916
2043
  // Notify clients of the error
1917
2044
  this._broadcastChatMessage({