@tanstack/ai 0.18.0 → 0.19.1

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.
@@ -5,7 +5,9 @@
5
5
  * These are used by StreamProcessor to manage the message array.
6
6
  */
7
7
 
8
+ import { parsePartialJSON } from './json-parser'
8
9
  import type {
10
+ StructuredOutputPart,
9
11
  ThinkingPart,
10
12
  ToolCallPart,
11
13
  ToolResultPart,
@@ -251,6 +253,175 @@ export function updateToolCallApprovalResponse(
251
253
  })
252
254
  }
253
255
 
256
+ /**
257
+ * Append a delta to the structured-output part on `messageId`, or create one
258
+ * if absent. Progressive parse of the accumulated buffer fills `partial`.
259
+ *
260
+ * Callers must only invoke this while the part is still in flight — the
261
+ * helper unconditionally writes `status: 'streaming'`, so feeding it a delta
262
+ * after a `complete`/`error` terminal would regress the part. In practice the
263
+ * processor gates calls via `structuredMessageIds`, which is dropped on
264
+ * terminal events.
265
+ *
266
+ * If the progressive parse returns null/undefined (the buffer is not yet a
267
+ * parseable JSON prefix), the previously-good `partial` is preserved so the
268
+ * UI doesn't flicker back to empty for a single render.
269
+ */
270
+ export function appendStructuredOutputDelta(
271
+ messages: Array<UIMessage>,
272
+ messageId: string,
273
+ delta: string,
274
+ ): Array<UIMessage> {
275
+ return messages.map((msg) => {
276
+ if (msg.id !== messageId) {
277
+ return msg
278
+ }
279
+
280
+ const parts = [...msg.parts]
281
+ const existingIndex = parts.findIndex(
282
+ (p): p is StructuredOutputPart => p.type === 'structured-output',
283
+ )
284
+ const existing =
285
+ existingIndex >= 0 ? (parts[existingIndex] as StructuredOutputPart) : null
286
+
287
+ const nextRaw = (existing?.raw ?? '') + delta
288
+ const progressive = parsePartialJSON(nextRaw)
289
+ const nextPartial =
290
+ progressive !== undefined && progressive !== null
291
+ ? progressive
292
+ : existing?.partial
293
+
294
+ const nextPart: StructuredOutputPart = {
295
+ type: 'structured-output',
296
+ status: 'streaming',
297
+ raw: nextRaw,
298
+ ...(nextPartial !== undefined ? { partial: nextPartial } : {}),
299
+ ...(existing?.reasoning !== undefined
300
+ ? { reasoning: existing.reasoning }
301
+ : {}),
302
+ }
303
+
304
+ if (existingIndex >= 0) {
305
+ parts[existingIndex] = nextPart
306
+ } else {
307
+ parts.push(nextPart)
308
+ }
309
+
310
+ return { ...msg, parts }
311
+ })
312
+ }
313
+
314
+ /**
315
+ * Snap the structured-output part on `messageId` to `complete` with the
316
+ * validated `data`. Picks the freshest available `raw` so the wire
317
+ * round-trip stays internally consistent:
318
+ *
319
+ * 1. Caller-supplied `raw` (the original streamed bytes from the model).
320
+ * 2. The existing part's `raw` (deltas accumulated before this terminal).
321
+ * 3. `JSON.stringify(data)` as a defensive fallback for terminal-only
322
+ * completes that never shipped raw — keeps the part self-consistent
323
+ * so downstream consumers never see a complete part with empty raw.
324
+ */
325
+ export function completeStructuredOutputPart(
326
+ messages: Array<UIMessage>,
327
+ messageId: string,
328
+ data: unknown,
329
+ raw: string,
330
+ reasoning?: string,
331
+ ): Array<UIMessage> {
332
+ return messages.map((msg) => {
333
+ if (msg.id !== messageId) {
334
+ return msg
335
+ }
336
+
337
+ const parts = [...msg.parts]
338
+ const existingIndex = parts.findIndex(
339
+ (p): p is StructuredOutputPart => p.type === 'structured-output',
340
+ )
341
+
342
+ const existingRaw =
343
+ existingIndex >= 0
344
+ ? (parts[existingIndex] as StructuredOutputPart).raw
345
+ : ''
346
+ let resolvedRaw = raw || existingRaw
347
+ if (resolvedRaw === '' && data !== undefined) {
348
+ try {
349
+ resolvedRaw = JSON.stringify(data)
350
+ } catch {
351
+ // Unserializable (circular, BigInt, throwing toJSON). Leave raw
352
+ // empty. Both downstream paths handle this: `ag-ui-wire.ts`
353
+ // `collectText` skips complete parts with empty raw entirely, and
354
+ // `uiMessageToModelMessages` falls back to a defensive
355
+ // `safeJsonStringify(data)` which itself returns `''` for the same
356
+ // unserializable inputs — so the turn is silently dropped from the
357
+ // next request rather than shipping garbage or crashing the stream.
358
+ }
359
+ }
360
+
361
+ const nextPart: StructuredOutputPart = {
362
+ type: 'structured-output',
363
+ status: 'complete',
364
+ data,
365
+ partial: data,
366
+ raw: resolvedRaw,
367
+ ...(reasoning !== undefined ? { reasoning } : {}),
368
+ }
369
+
370
+ if (existingIndex >= 0) {
371
+ parts[existingIndex] = nextPart
372
+ } else {
373
+ parts.push(nextPart)
374
+ }
375
+
376
+ return { ...msg, parts }
377
+ })
378
+ }
379
+
380
+ /**
381
+ * Mark the structured-output part on `messageId` as errored. If no part
382
+ * exists yet — RUN_ERROR fired after `structured-output.start` but before
383
+ * any delta — create an empty errored placeholder so consumers have
384
+ * something renderable. Existing complete parts are left alone (an error
385
+ * after a successful complete should not retroactively un-complete it).
386
+ */
387
+ export function errorStructuredOutputPart(
388
+ messages: Array<UIMessage>,
389
+ messageId: string,
390
+ errorMessage: string,
391
+ ): Array<UIMessage> {
392
+ return messages.map((msg) => {
393
+ if (msg.id !== messageId) {
394
+ return msg
395
+ }
396
+
397
+ const parts = [...msg.parts]
398
+ const existingIndex = parts.findIndex(
399
+ (p): p is StructuredOutputPart => p.type === 'structured-output',
400
+ )
401
+
402
+ if (existingIndex < 0) {
403
+ parts.push({
404
+ type: 'structured-output',
405
+ status: 'error',
406
+ raw: '',
407
+ errorMessage,
408
+ })
409
+ return { ...msg, parts }
410
+ }
411
+
412
+ const existing = parts[existingIndex] as StructuredOutputPart
413
+ if (existing.status === 'complete') {
414
+ return msg
415
+ }
416
+ parts[existingIndex] = {
417
+ ...existing,
418
+ status: 'error',
419
+ errorMessage,
420
+ }
421
+ return { ...msg, parts }
422
+ })
423
+ }
424
+
254
425
  /**
255
426
  * Update or add a thinking part to a message, keyed by stepId.
256
427
  * Each distinct stepId produces its own ThinkingPart.
@@ -20,6 +20,9 @@
20
20
  import { generateMessageId, uiMessageToModelMessages } from '../messages.js'
21
21
  import { defaultJSONParser } from './json-parser'
22
22
  import {
23
+ appendStructuredOutputDelta,
24
+ completeStructuredOutputPart,
25
+ errorStructuredOutputPart,
23
26
  updateTextPart,
24
27
  updateThinkingPart,
25
28
  updateToolCallApproval,
@@ -145,6 +148,8 @@ export class StreamProcessor {
145
148
  private pendingManualMessageId: string | null = null
146
149
  private pendingThinkingStepId: string | null = null
147
150
 
151
+ private structuredMessageIds: Set<string> = new Set()
152
+
148
153
  // Run tracking (for concurrent run safety)
149
154
  private activeRuns = new Set<string>()
150
155
 
@@ -383,6 +388,27 @@ export class StreamProcessor {
383
388
  * Remove messages after a certain index (for reload/retry)
384
389
  */
385
390
  removeMessagesAfter(index: number): void {
391
+ const keptIds = new Set(this.messages.slice(0, index + 1).map((m) => m.id))
392
+ // Drop routing state for messages that no longer exist; otherwise a
393
+ // resumed stream (`reload()` or a server that reuses messageIds across
394
+ // runs) could land deltas / tool args on stale map entries and corrupt
395
+ // the new assistant message's parts. Mirror the four routing maps that
396
+ // key on messageId: structuredMessageIds (custom-event routing),
397
+ // messageStates (per-message stream state), toolCallToMessage (tool
398
+ // args → message), and activeMessageIds (finalize / completeAllToolCalls
399
+ // iteration targets — must not include phantoms).
400
+ for (const id of this.structuredMessageIds) {
401
+ if (!keptIds.has(id)) this.structuredMessageIds.delete(id)
402
+ }
403
+ for (const id of this.messageStates.keys()) {
404
+ if (!keptIds.has(id)) this.messageStates.delete(id)
405
+ }
406
+ for (const [toolCallId, msgId] of this.toolCallToMessage) {
407
+ if (!keptIds.has(msgId)) this.toolCallToMessage.delete(toolCallId)
408
+ }
409
+ for (const id of this.activeMessageIds) {
410
+ if (!keptIds.has(id)) this.activeMessageIds.delete(id)
411
+ }
386
412
  this.messages = this.messages.slice(0, index + 1)
387
413
  this.emitMessagesChange()
388
414
  }
@@ -395,6 +421,7 @@ export class StreamProcessor {
395
421
  this.messageStates.clear()
396
422
  this.activeMessageIds.clear()
397
423
  this.toolCallToMessage.clear()
424
+ this.structuredMessageIds.clear()
398
425
  this.pendingManualMessageId = null
399
426
  this.emitMessagesChange()
400
427
  }
@@ -841,6 +868,41 @@ export class StreamProcessor {
841
868
  // Content arriving means all current tool calls for this message are complete
842
869
  this.completeAllToolCallsForMessage(messageId)
843
870
 
871
+ if (this.structuredMessageIds.has(messageId)) {
872
+ // `chunk.delta` is incremental; `chunk.content` is sometimes cumulative
873
+ // (mirrors what the plain-text branch handles below). Reconcile against
874
+ // the existing raw buffer so adapters that emit cumulative content
875
+ // don't duplicate the JSON.
876
+ let delta = chunk.delta || ''
877
+ if (delta === '' && chunk.content !== undefined && chunk.content !== '') {
878
+ const existingRaw = (
879
+ this.messages
880
+ .find((m) => m.id === messageId)
881
+ ?.parts.find(
882
+ (p): p is Extract<MessagePart, { type: 'structured-output' }> =>
883
+ p.type === 'structured-output',
884
+ ) ?? { raw: '' }
885
+ ).raw
886
+ if (chunk.content.startsWith(existingRaw)) {
887
+ delta = chunk.content.slice(existingRaw.length)
888
+ } else if (existingRaw.startsWith(chunk.content)) {
889
+ delta = ''
890
+ } else {
891
+ delta = chunk.content
892
+ }
893
+ }
894
+ if (delta !== '') {
895
+ this.messages = appendStructuredOutputDelta(
896
+ this.messages,
897
+ messageId,
898
+ delta,
899
+ )
900
+ state.totalTextContent += delta
901
+ this.emitMessagesChange()
902
+ }
903
+ return
904
+ }
905
+
844
906
  const previousSegment = state.currentSegmentText
845
907
 
846
908
  // Detect if this is a NEW text segment (after tool calls) vs continuation
@@ -1192,10 +1254,29 @@ export class StreamProcessor {
1192
1254
  } else {
1193
1255
  this.activeRuns.clear()
1194
1256
  }
1195
- this.ensureAssistantMessage()
1196
- // Prefer spec field `message`; fall back to deprecated `error.message`
1257
+ const { messageId } = this.ensureAssistantMessage()
1258
+ // Prefer spec field `message`; fall back to deprecated `error.message`.
1259
+ // If neither is set, the chunk still carries debug context (provider
1260
+ // error codes, request ids, etc.) — log it so the failure isn't silent.
1197
1261
  const errorMessage =
1198
1262
  chunk.message || chunk.error?.message || 'An error occurred'
1263
+ if (!chunk.message && !chunk.error?.message) {
1264
+ console.error(
1265
+ '[StreamProcessor] RUN_ERROR with no message; original chunk:',
1266
+ chunk,
1267
+ )
1268
+ }
1269
+
1270
+ if (this.structuredMessageIds.has(messageId)) {
1271
+ this.messages = errorStructuredOutputPart(
1272
+ this.messages,
1273
+ messageId,
1274
+ errorMessage,
1275
+ )
1276
+ this.structuredMessageIds.delete(messageId)
1277
+ this.emitMessagesChange()
1278
+ }
1279
+
1199
1280
  this.events.onError?.(new Error(errorMessage))
1200
1281
  }
1201
1282
 
@@ -1375,6 +1456,38 @@ export class StreamProcessor {
1375
1456
  ): void {
1376
1457
  const messageId = this.getActiveAssistantMessageId()
1377
1458
 
1459
+ if (chunk.name === 'structured-output.start' && chunk.value) {
1460
+ const v = chunk.value as { messageId?: string }
1461
+ const targetId = v.messageId ?? messageId
1462
+ if (targetId) {
1463
+ this.ensureAssistantMessage(targetId)
1464
+ this.structuredMessageIds.add(targetId)
1465
+ }
1466
+ return
1467
+ }
1468
+
1469
+ if (chunk.name === 'structured-output.complete' && chunk.value) {
1470
+ const v = chunk.value as {
1471
+ object: unknown
1472
+ raw?: string
1473
+ reasoning?: string
1474
+ messageId?: string
1475
+ }
1476
+ const targetId = v.messageId ?? messageId
1477
+ if (targetId) {
1478
+ this.messages = completeStructuredOutputPart(
1479
+ this.messages,
1480
+ targetId,
1481
+ v.object,
1482
+ v.raw ?? '',
1483
+ v.reasoning,
1484
+ )
1485
+ this.structuredMessageIds.delete(targetId)
1486
+ this.emitMessagesChange()
1487
+ }
1488
+ // Fall through so user `onCustomEvent` callbacks still observe the event.
1489
+ }
1490
+
1378
1491
  // Handle client tool input availability - trigger client-side execution
1379
1492
  if (chunk.name === 'tool-input-available' && chunk.value) {
1380
1493
  const { toolCallId, toolName, input } = chunk.value as {
@@ -1591,6 +1704,27 @@ export class StreamProcessor {
1591
1704
  }
1592
1705
  }
1593
1706
 
1707
+ // The stream closed but one or more structured-output runs never sent
1708
+ // their terminal `structured-output.complete`. Snap each lingering
1709
+ // streaming part to error so the UI doesn't appear to stream forever,
1710
+ // and drop the routing entries so a subsequent run on the same
1711
+ // processor instance (long-lived `subscribe()` mode) doesn't reuse
1712
+ // the stale ids.
1713
+ //
1714
+ // The iteration is unconditional w.r.t. `this.hasError` — RUN_ERROR
1715
+ // already removed its target messageId from `structuredMessageIds`
1716
+ // before reaching finalize, so anything still in the set is by
1717
+ // definition a non-errored, never-completed run (the multi-run case:
1718
+ // run-A errors, run-B is still streaming when finalize fires).
1719
+ for (const messageId of this.structuredMessageIds) {
1720
+ this.messages = errorStructuredOutputPart(
1721
+ this.messages,
1722
+ messageId,
1723
+ 'Stream ended without structured-output.complete',
1724
+ )
1725
+ }
1726
+ this.structuredMessageIds.clear()
1727
+
1594
1728
  this.activeMessageIds.clear()
1595
1729
 
1596
1730
  // Remove whitespace-only assistant messages (handles models like Gemini
@@ -1719,6 +1853,7 @@ export class StreamProcessor {
1719
1853
  this.activeMessageIds.clear()
1720
1854
  this.activeRuns.clear()
1721
1855
  this.toolCallToMessage.clear()
1856
+ this.structuredMessageIds.clear()
1722
1857
  this.pendingManualMessageId = null
1723
1858
  this.pendingThinkingStepId = null
1724
1859
  this.finishReason = null
@@ -4,5 +4,6 @@
4
4
 
5
5
  export type { ResolvedCategories } from './logger/internal-logger'
6
6
  export { InternalLogger } from './logger/internal-logger'
7
+ export type { Logger } from './logger/types'
7
8
  export { resolveDebugOption } from './logger/resolve'
8
9
  export { toRunErrorPayload } from './activities/error-payload'
package/src/types.ts CHANGED
@@ -365,7 +365,43 @@ export interface ThinkingPart {
365
365
  signature?: string
366
366
  }
367
367
 
368
- export type MessagePart =
368
+ /**
369
+ * Recursive `Partial` — every nested field becomes optional. Used as the
370
+ * `partial` type on a streaming structured-output part since the progressive
371
+ * JSON parse hands back objects whose fields are only filled in as bytes
372
+ * arrive. Defaulted in `DeepPartial<unknown>` → `unknown` so untyped parts
373
+ * keep their existing shape.
374
+ */
375
+ export type DeepPartial<T> =
376
+ T extends ReadonlyArray<infer U>
377
+ ? Array<DeepPartial<U>>
378
+ : T extends object
379
+ ? { [K in keyof T]?: DeepPartial<T[K]> }
380
+ : T
381
+
382
+ /**
383
+ * StructuredOutputPart — a typed structured response attached to the assistant
384
+ * message that produced it. Generic over the schema-inferred data type so
385
+ * consumers can thread `useChat({ outputSchema })`'s schema all the way down
386
+ * to `messages[i].parts[j].data`. Defaults to `unknown` so untyped consumers
387
+ * (e.g. internal codepaths that don't know about TSchema) keep working.
388
+ */
389
+ export interface StructuredOutputPart<TData = unknown> {
390
+ type: 'structured-output'
391
+ status: 'streaming' | 'complete' | 'error'
392
+ /** Progressive parse of `raw` via parsePartialJSON — populated while streaming and after complete. */
393
+ partial?: DeepPartial<TData>
394
+ /** Validated final object — only set when `status === 'complete'`. */
395
+ data?: TData
396
+ /** Accumulating JSON buffer. Source of truth for wire round-trip. */
397
+ raw: string
398
+ /** Optional chain-of-thought surfaced by reasoning models alongside the structured output. */
399
+ reasoning?: string
400
+ /** Populated when `status === 'error'`. */
401
+ errorMessage?: string
402
+ }
403
+
404
+ export type MessagePart<TData = unknown> =
369
405
  | TextPart
370
406
  | ImagePart
371
407
  | AudioPart
@@ -374,15 +410,19 @@ export type MessagePart =
374
410
  | ToolCallPart
375
411
  | ToolResultPart
376
412
  | ThinkingPart
413
+ | StructuredOutputPart<TData>
377
414
 
378
415
  /**
379
416
  * UIMessage - Domain-specific message format optimized for building chat UIs
380
- * Contains parts that can be text, tool calls, or tool results
417
+ * Contains parts that can be text, tool calls, or tool results. Generic over
418
+ * the structured-output data type so `useChat({ outputSchema })`'s schema
419
+ * narrows `parts.find(p => p.type === 'structured-output').data` on the
420
+ * consumer side without manual casts.
381
421
  */
382
- export interface UIMessage {
422
+ export interface UIMessage<TData = unknown> {
383
423
  id: string
384
424
  role: 'system' | 'user' | 'assistant'
385
- parts: Array<MessagePart>
425
+ parts: Array<MessagePart<TData>>
386
426
  createdAt?: Date
387
427
  }
388
428
 
@@ -1106,24 +1146,34 @@ export interface CustomEvent extends AGUICustomEvent {
1106
1146
  * }
1107
1147
  * ```
1108
1148
  */
1109
- export interface StructuredOutputCompleteEvent<T = unknown> extends Omit<
1110
- CustomEvent,
1111
- 'name' | 'value'
1112
- > {
1149
+ export interface StructuredOutputCompleteEvent<
1150
+ T = unknown,
1151
+ > extends CustomEvent {
1113
1152
  name: 'structured-output.complete'
1114
1153
  value: { object: T; raw: string; reasoning?: string }
1115
1154
  }
1116
1155
 
1156
+ /**
1157
+ * Emitted at the start of a streaming structured-output run, before the JSON
1158
+ * deltas. Tells consumers that the upcoming `TEXT_MESSAGE_CONTENT` deltas
1159
+ * belong to a structured response so they can route those bytes into a
1160
+ * `StructuredOutputPart` instead of building a `TextPart`. Carries the
1161
+ * `messageId` the deltas will be tagged with so the routing decision can be
1162
+ * made per-message rather than globally.
1163
+ */
1164
+ export interface StructuredOutputStartEvent extends CustomEvent {
1165
+ name: 'structured-output.start'
1166
+ value: { messageId: string }
1167
+ }
1168
+
1117
1169
  /**
1118
1170
  * Emitted when a server tool requires approval before execution. The agent
1119
1171
  * loop yields this and pauses — `structured-output.complete` will not fire
1120
1172
  * for that run. The shape is fixed by the orchestrator's tool-approval flow
1121
- * (see `buildApprovalChunks` in `activities/chat/index.ts`).
1173
+ * (the agent-loop branch of `runStreamingStructuredOutputImpl` in
1174
+ * `activities/chat/index.ts` forwards CUSTOM events from `TextEngine.run()`).
1122
1175
  */
1123
- export interface ApprovalRequestedEvent extends Omit<
1124
- CustomEvent,
1125
- 'name' | 'value'
1126
- > {
1176
+ export interface ApprovalRequestedEvent extends CustomEvent {
1127
1177
  name: 'approval-requested'
1128
1178
  value: {
1129
1179
  toolCallId: string
@@ -1136,13 +1186,10 @@ export interface ApprovalRequestedEvent extends Omit<
1136
1186
  /**
1137
1187
  * Emitted when a client tool is invoked. The agent loop yields this and
1138
1188
  * pauses to let the caller run the tool client-side — `structured-output.complete`
1139
- * will not fire for that run. Shape fixed by `buildClientToolChunks` in
1140
- * `activities/chat/index.ts`.
1189
+ * will not fire for that run. Shape fixed by the agent-loop forwarding in
1190
+ * `runStreamingStructuredOutputImpl` in `activities/chat/index.ts`.
1141
1191
  */
1142
- export interface ToolInputAvailableEvent extends Omit<
1143
- CustomEvent,
1144
- 'name' | 'value'
1145
- > {
1192
+ export interface ToolInputAvailableEvent extends CustomEvent {
1146
1193
  name: 'tool-input-available'
1147
1194
  value: {
1148
1195
  toolCallId: string
@@ -1183,6 +1230,7 @@ export interface ToolInputAvailableEvent extends Omit<
1183
1230
  */
1184
1231
  export type StructuredOutputStream<T = unknown> = AsyncIterable<
1185
1232
  | Exclude<StreamChunk, CustomEvent>
1233
+ | StructuredOutputStartEvent
1186
1234
  | StructuredOutputCompleteEvent<T>
1187
1235
  | ApprovalRequestedEvent
1188
1236
  | ToolInputAvailableEvent
@@ -1,4 +1,4 @@
1
- import type { ContentPart, MessagePart, TextPart, UIMessage } from '../types'
1
+ import type { ContentPart, MessagePart, UIMessage } from '../types'
2
2
 
3
3
  type AGUITextInputContent = { type: 'text'; text: string }
4
4
  type AGUIInputContent =
@@ -113,10 +113,29 @@ export function uiMessagesToWire(
113
113
  }
114
114
 
115
115
  function collectText(parts: ReadonlyArray<MessagePart>): string {
116
- return parts
117
- .filter((p): p is TextPart => p.type === 'text')
118
- .map((p) => p.content)
119
- .join('')
116
+ // The streamed JSON of a completed structured-output part is the source of
117
+ // truth for multi-turn coherence — emitting it back as assistant content
118
+ // lets the LLM see its own prior structured response. Streaming/errored
119
+ // parts are skipped: they'd ship malformed JSON fragments and confuse the
120
+ // model. `completeStructuredOutputPart` tries hard to populate `raw`
121
+ // (caller → existing buffer → `JSON.stringify(data)`), but the stringify
122
+ // fallback can leave it empty when `data` is unserializable (BigInt,
123
+ // circular). The `p.raw !== ''` guard below is what enforces "no malformed
124
+ // round-trip" in that case — without it we'd ship `''` and the model would
125
+ // see an empty assistant turn.
126
+ const out: Array<string> = []
127
+ for (const p of parts) {
128
+ if (p.type === 'text') {
129
+ out.push(p.content)
130
+ } else if (
131
+ p.type === 'structured-output' &&
132
+ p.status === 'complete' &&
133
+ p.raw !== ''
134
+ ) {
135
+ out.push(p.raw)
136
+ }
137
+ }
138
+ return out.join('')
120
139
  }
121
140
 
122
141
  function collectUserContent(