@tanstack/ai 0.41.0 → 0.43.0

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.
Files changed (272) hide show
  1. package/README.md +15 -1
  2. package/dist/esm/activities/chat/adapter.js +23 -16
  3. package/dist/esm/activities/chat/adapter.js.map +1 -1
  4. package/dist/esm/activities/chat/agent-loop-strategies.d.ts +10 -4
  5. package/dist/esm/activities/chat/agent-loop-strategies.js +75 -17
  6. package/dist/esm/activities/chat/agent-loop-strategies.js.map +1 -1
  7. package/dist/esm/activities/chat/cancel.d.ts +40 -0
  8. package/dist/esm/activities/chat/cancel.js +54 -0
  9. package/dist/esm/activities/chat/cancel.js.map +1 -0
  10. package/dist/esm/activities/chat/index.d.ts +28 -16
  11. package/dist/esm/activities/chat/index.js +2100 -1744
  12. package/dist/esm/activities/chat/index.js.map +1 -1
  13. package/dist/esm/activities/chat/mcp/manager.d.ts +2 -2
  14. package/dist/esm/activities/chat/mcp/manager.js +90 -77
  15. package/dist/esm/activities/chat/mcp/manager.js.map +1 -1
  16. package/dist/esm/activities/chat/mcp/types.d.ts +2 -2
  17. package/dist/esm/activities/chat/messages.js +397 -346
  18. package/dist/esm/activities/chat/messages.js.map +1 -1
  19. package/dist/esm/activities/chat/middleware/builder.js +17 -15
  20. package/dist/esm/activities/chat/middleware/builder.js.map +1 -1
  21. package/dist/esm/activities/chat/middleware/capabilities.js +78 -43
  22. package/dist/esm/activities/chat/middleware/capabilities.js.map +1 -1
  23. package/dist/esm/activities/chat/middleware/compose.d.ts +94 -1
  24. package/dist/esm/activities/chat/middleware/compose.js +623 -531
  25. package/dist/esm/activities/chat/middleware/compose.js.map +1 -1
  26. package/dist/esm/activities/chat/middleware/define.js +12 -5
  27. package/dist/esm/activities/chat/middleware/define.js.map +1 -1
  28. package/dist/esm/activities/chat/middleware/index.d.ts +5 -1
  29. package/dist/esm/activities/chat/middleware/locks.d.ts +50 -0
  30. package/dist/esm/activities/chat/middleware/locks.js +71 -0
  31. package/dist/esm/activities/chat/middleware/locks.js.map +1 -0
  32. package/dist/esm/activities/chat/middleware/pending-turn.d.ts +15 -0
  33. package/dist/esm/activities/chat/middleware/pending-turn.js +35 -0
  34. package/dist/esm/activities/chat/middleware/pending-turn.js.map +1 -0
  35. package/dist/esm/activities/chat/middleware/run-disconnect.d.ts +23 -0
  36. package/dist/esm/activities/chat/middleware/run-disconnect.js +42 -0
  37. package/dist/esm/activities/chat/middleware/run-disconnect.js.map +1 -0
  38. package/dist/esm/activities/chat/middleware/run-store.d.ts +283 -0
  39. package/dist/esm/activities/chat/middleware/run-store.js +176 -0
  40. package/dist/esm/activities/chat/middleware/run-store.js.map +1 -0
  41. package/dist/esm/activities/chat/middleware/sandbox-runtime.js +14 -8
  42. package/dist/esm/activities/chat/middleware/sandbox-runtime.js.map +1 -1
  43. package/dist/esm/activities/chat/middleware/tool-cache-middleware.js +79 -70
  44. package/dist/esm/activities/chat/middleware/tool-cache-middleware.js.map +1 -1
  45. package/dist/esm/activities/chat/middleware/types.d.ts +59 -2
  46. package/dist/esm/activities/chat/middleware/validate.js +23 -28
  47. package/dist/esm/activities/chat/middleware/validate.js.map +1 -1
  48. package/dist/esm/activities/chat/stream/json-parser.js +39 -25
  49. package/dist/esm/activities/chat/stream/json-parser.js.map +1 -1
  50. package/dist/esm/activities/chat/stream/message-updaters.js +275 -234
  51. package/dist/esm/activities/chat/stream/message-updaters.js.map +1 -1
  52. package/dist/esm/activities/chat/stream/processor.d.ts +24 -4
  53. package/dist/esm/activities/chat/stream/processor.js +1341 -1542
  54. package/dist/esm/activities/chat/stream/processor.js.map +1 -1
  55. package/dist/esm/activities/chat/stream/strategies.js +69 -53
  56. package/dist/esm/activities/chat/stream/strategies.js.map +1 -1
  57. package/dist/esm/activities/chat/tools/approval-schema.d.ts +19 -0
  58. package/dist/esm/activities/chat/tools/approval-schema.js +117 -0
  59. package/dist/esm/activities/chat/tools/approval-schema.js.map +1 -0
  60. package/dist/esm/activities/chat/tools/lazy-tool-manager.js +164 -191
  61. package/dist/esm/activities/chat/tools/lazy-tool-manager.js.map +1 -1
  62. package/dist/esm/activities/chat/tools/lazy-tools.js +24 -12
  63. package/dist/esm/activities/chat/tools/lazy-tools.js.map +1 -1
  64. package/dist/esm/activities/chat/tools/schema-converter.js +293 -146
  65. package/dist/esm/activities/chat/tools/schema-converter.js.map +1 -1
  66. package/dist/esm/activities/chat/tools/tool-calls.d.ts +18 -2
  67. package/dist/esm/activities/chat/tools/tool-calls.js +522 -531
  68. package/dist/esm/activities/chat/tools/tool-calls.js.map +1 -1
  69. package/dist/esm/activities/chat/tools/tool-definition.d.ts +75 -16
  70. package/dist/esm/activities/chat/tools/tool-definition.js +95 -23
  71. package/dist/esm/activities/chat/tools/tool-definition.js.map +1 -1
  72. package/dist/esm/activities/error-payload.js +85 -47
  73. package/dist/esm/activities/error-payload.js.map +1 -1
  74. package/dist/esm/activities/generateAudio/adapter.js +22 -15
  75. package/dist/esm/activities/generateAudio/adapter.js.map +1 -1
  76. package/dist/esm/activities/generateAudio/index.d.ts +4 -0
  77. package/dist/esm/activities/generateAudio/index.js +141 -105
  78. package/dist/esm/activities/generateAudio/index.js.map +1 -1
  79. package/dist/esm/activities/generateImage/adapter.js +22 -15
  80. package/dist/esm/activities/generateImage/adapter.js.map +1 -1
  81. package/dist/esm/activities/generateImage/index.d.ts +4 -0
  82. package/dist/esm/activities/generateImage/index.js +155 -111
  83. package/dist/esm/activities/generateImage/index.js.map +1 -1
  84. package/dist/esm/activities/generateSpeech/adapter.js +22 -15
  85. package/dist/esm/activities/generateSpeech/adapter.js.map +1 -1
  86. package/dist/esm/activities/generateSpeech/index.d.ts +4 -0
  87. package/dist/esm/activities/generateSpeech/index.js +159 -110
  88. package/dist/esm/activities/generateSpeech/index.js.map +1 -1
  89. package/dist/esm/activities/generateTranscription/adapter.js +22 -15
  90. package/dist/esm/activities/generateTranscription/adapter.js.map +1 -1
  91. package/dist/esm/activities/generateTranscription/index.d.ts +4 -0
  92. package/dist/esm/activities/generateTranscription/index.js +159 -100
  93. package/dist/esm/activities/generateTranscription/index.js.map +1 -1
  94. package/dist/esm/activities/generateVideo/adapter.js +36 -29
  95. package/dist/esm/activities/generateVideo/adapter.js.map +1 -1
  96. package/dist/esm/activities/generateVideo/index.d.ts +143 -19
  97. package/dist/esm/activities/generateVideo/index.js +456 -279
  98. package/dist/esm/activities/generateVideo/index.js.map +1 -1
  99. package/dist/esm/activities/generateVideo/snap.js +60 -48
  100. package/dist/esm/activities/generateVideo/snap.js.map +1 -1
  101. package/dist/esm/activities/index.js +8 -34
  102. package/dist/esm/activities/middleware/index.d.ts +1 -1
  103. package/dist/esm/activities/middleware/run.d.ts +10 -0
  104. package/dist/esm/activities/middleware/run.js +53 -29
  105. package/dist/esm/activities/middleware/run.js.map +1 -1
  106. package/dist/esm/activities/middleware/types.d.ts +44 -6
  107. package/dist/esm/activities/stream-generation-result.d.ts +4 -1
  108. package/dist/esm/activities/stream-generation-result.js +79 -44
  109. package/dist/esm/activities/stream-generation-result.js.map +1 -1
  110. package/dist/esm/activities/summarize/adapter.js +22 -15
  111. package/dist/esm/activities/summarize/adapter.js.map +1 -1
  112. package/dist/esm/activities/summarize/chat-stream-summarize.js +252 -202
  113. package/dist/esm/activities/summarize/chat-stream-summarize.js.map +1 -1
  114. package/dist/esm/activities/summarize/index.d.ts +27 -0
  115. package/dist/esm/activities/summarize/index.js +268 -102
  116. package/dist/esm/activities/summarize/index.js.map +1 -1
  117. package/dist/esm/adapter-internals.d.ts +2 -1
  118. package/dist/esm/adapter-internals.js +4 -11
  119. package/dist/esm/client.d.ts +25 -3
  120. package/dist/esm/client.js +131 -64
  121. package/dist/esm/client.js.map +1 -1
  122. package/dist/esm/custom-events.d.ts +76 -0
  123. package/dist/esm/custom-events.js +37 -0
  124. package/dist/esm/custom-events.js.map +1 -0
  125. package/dist/esm/delivery-detach.d.ts +50 -0
  126. package/dist/esm/delivery-detach.js +71 -0
  127. package/dist/esm/delivery-detach.js.map +1 -0
  128. package/dist/esm/delivery-disconnect.d.ts +62 -0
  129. package/dist/esm/delivery-disconnect.js +81 -0
  130. package/dist/esm/delivery-disconnect.js.map +1 -0
  131. package/dist/esm/extend-adapter.js +19 -17
  132. package/dist/esm/extend-adapter.js.map +1 -1
  133. package/dist/esm/index.d.ts +23 -5
  134. package/dist/esm/index.js +30 -97
  135. package/dist/esm/interrupt-resume.d.ts +71 -0
  136. package/dist/esm/interrupt-resume.js +438 -0
  137. package/dist/esm/interrupt-resume.js.map +1 -0
  138. package/dist/esm/interrupt-serialization.d.ts +12 -0
  139. package/dist/esm/interrupt-serialization.js +178 -0
  140. package/dist/esm/interrupt-serialization.js.map +1 -0
  141. package/dist/esm/interrupts.d.ts +84 -0
  142. package/dist/esm/interrupts.js +31 -0
  143. package/dist/esm/interrupts.js.map +1 -0
  144. package/dist/esm/locks.d.ts +10 -0
  145. package/dist/esm/locks.js +2 -0
  146. package/dist/esm/logger/console-logger.js +101 -78
  147. package/dist/esm/logger/console-logger.js.map +1 -1
  148. package/dist/esm/logger/internal-logger.js +104 -89
  149. package/dist/esm/logger/internal-logger.js.map +1 -1
  150. package/dist/esm/logger/resolve.js +54 -49
  151. package/dist/esm/logger/resolve.js.map +1 -1
  152. package/dist/esm/logger/types.d.ts +1 -1
  153. package/dist/esm/middlewares/content-guard.js +142 -148
  154. package/dist/esm/middlewares/content-guard.js.map +1 -1
  155. package/dist/esm/middlewares/index.js +2 -6
  156. package/dist/esm/middlewares/otel.js +598 -732
  157. package/dist/esm/middlewares/otel.js.map +1 -1
  158. package/dist/esm/middlewares/usage-attributes.js +47 -40
  159. package/dist/esm/middlewares/usage-attributes.js.map +1 -1
  160. package/dist/esm/realtime/event-emitter.js +24 -25
  161. package/dist/esm/realtime/event-emitter.js.map +1 -1
  162. package/dist/esm/realtime/index.d.ts +5 -9
  163. package/dist/esm/realtime/index.js +29 -6
  164. package/dist/esm/realtime/index.js.map +1 -1
  165. package/dist/esm/scope.d.ts +47 -0
  166. package/dist/esm/stream-durability.d.ts +171 -0
  167. package/dist/esm/stream-durability.js +295 -0
  168. package/dist/esm/stream-durability.js.map +1 -0
  169. package/dist/esm/stream-to-response.d.ts +178 -13
  170. package/dist/esm/stream-to-response.js +663 -115
  171. package/dist/esm/stream-to-response.js.map +1 -1
  172. package/dist/esm/strip-to-spec-middleware.js +30 -16
  173. package/dist/esm/strip-to-spec-middleware.js.map +1 -1
  174. package/dist/esm/system-prompts.js +27 -21
  175. package/dist/esm/system-prompts.js.map +1 -1
  176. package/dist/esm/tool-registry.js +72 -45
  177. package/dist/esm/tool-registry.js.map +1 -1
  178. package/dist/esm/tools/provider-tool.js +14 -5
  179. package/dist/esm/tools/provider-tool.js.map +1 -1
  180. package/dist/esm/types.d.ts +332 -21
  181. package/dist/esm/types.js +2 -0
  182. package/dist/esm/utilities/ag-ui-wire.js +79 -93
  183. package/dist/esm/utilities/ag-ui-wire.js.map +1 -1
  184. package/dist/esm/utilities/chat-params.d.ts +26 -4
  185. package/dist/esm/utilities/chat-params.js +218 -92
  186. package/dist/esm/utilities/chat-params.js.map +1 -1
  187. package/dist/esm/utilities/errors.js +28 -18
  188. package/dist/esm/utilities/errors.js.map +1 -1
  189. package/dist/esm/utilities/media-prompt.js +46 -41
  190. package/dist/esm/utilities/media-prompt.js.map +1 -1
  191. package/dist/esm/utilities/numbers.js +13 -10
  192. package/dist/esm/utilities/numbers.js.map +1 -1
  193. package/dist/esm/utilities/provider-executed.js +20 -11
  194. package/dist/esm/utilities/provider-executed.js.map +1 -1
  195. package/dist/esm/utilities/sampling-keys.js +31 -19
  196. package/dist/esm/utilities/sampling-keys.js.map +1 -1
  197. package/dist/esm/utilities/tool-result.js +42 -30
  198. package/dist/esm/utilities/tool-result.js.map +1 -1
  199. package/dist/esm/utilities/usage.js +27 -9
  200. package/dist/esm/utilities/usage.js.map +1 -1
  201. package/dist/esm/utils.js +26 -18
  202. package/dist/esm/utils.js.map +1 -1
  203. package/package.json +10 -6
  204. package/skills/ai-core/SKILL.md +69 -18
  205. package/skills/ai-core/adapter-configuration/SKILL.md +44 -21
  206. package/skills/ai-core/adapter-configuration/references/anthropic-adapter.md +1 -3
  207. package/skills/ai-core/adapter-configuration/references/byteplus-adapter.md +148 -0
  208. package/skills/ai-core/adapter-configuration/references/gemini-adapter.md +2 -6
  209. package/skills/ai-core/adapter-configuration/references/groq-adapter.md +2 -6
  210. package/skills/ai-core/adapter-configuration/references/openai-adapter.md +1 -3
  211. package/skills/ai-core/ag-ui-protocol/SKILL.md +1 -1
  212. package/skills/ai-core/chat-experience/SKILL.md +156 -11
  213. package/skills/ai-core/client-persistence/SKILL.md +277 -0
  214. package/skills/ai-core/custom-backend-integration/SKILL.md +1 -1
  215. package/skills/ai-core/debug-logging/SKILL.md +1 -1
  216. package/skills/ai-core/locks/SKILL.md +143 -0
  217. package/skills/ai-core/media-generation/SKILL.md +144 -12
  218. package/skills/ai-core/middleware/SKILL.md +258 -33
  219. package/skills/ai-core/structured-outputs/SKILL.md +1 -1
  220. package/skills/ai-core/tool-calling/SKILL.md +54 -59
  221. package/src/activities/chat/agent-loop-strategies.ts +10 -4
  222. package/src/activities/chat/cancel.ts +81 -0
  223. package/src/activities/chat/index.ts +1152 -153
  224. package/src/activities/chat/mcp/manager.ts +4 -4
  225. package/src/activities/chat/mcp/types.ts +2 -2
  226. package/src/activities/chat/messages.ts +5 -3
  227. package/src/activities/chat/middleware/builder.ts +1 -1
  228. package/src/activities/chat/middleware/compose.ts +186 -9
  229. package/src/activities/chat/middleware/index.ts +26 -0
  230. package/src/activities/chat/middleware/locks.ts +102 -0
  231. package/src/activities/chat/middleware/pending-turn.ts +47 -0
  232. package/src/activities/chat/middleware/run-disconnect.ts +62 -0
  233. package/src/activities/chat/middleware/run-store.ts +412 -0
  234. package/src/activities/chat/middleware/types.ts +62 -1
  235. package/src/activities/chat/stream/processor.ts +189 -5
  236. package/src/activities/chat/tools/approval-schema.ts +205 -0
  237. package/src/activities/chat/tools/tool-calls.ts +106 -13
  238. package/src/activities/chat/tools/tool-definition.ts +210 -39
  239. package/src/activities/generateAudio/index.ts +20 -3
  240. package/src/activities/generateImage/index.ts +20 -3
  241. package/src/activities/generateSpeech/index.ts +25 -3
  242. package/src/activities/generateTranscription/index.ts +26 -3
  243. package/src/activities/generateVideo/index.ts +345 -82
  244. package/src/activities/middleware/index.ts +2 -0
  245. package/src/activities/middleware/run.ts +31 -0
  246. package/src/activities/middleware/types.ts +49 -5
  247. package/src/activities/stream-generation-result.ts +30 -2
  248. package/src/activities/summarize/chat-stream-summarize.ts +5 -0
  249. package/src/activities/summarize/index.ts +200 -10
  250. package/src/adapter-internals.ts +10 -1
  251. package/src/client.ts +244 -0
  252. package/src/custom-events.ts +107 -0
  253. package/src/delivery-detach.ts +72 -0
  254. package/src/delivery-disconnect.ts +84 -0
  255. package/src/index.ts +138 -0
  256. package/src/interrupt-resume.ts +824 -0
  257. package/src/interrupt-serialization.ts +183 -0
  258. package/src/interrupts.ts +146 -0
  259. package/src/locks.ts +17 -0
  260. package/src/logger/types.ts +1 -1
  261. package/src/middlewares/otel.ts +1 -0
  262. package/src/realtime/index.ts +5 -9
  263. package/src/scope.ts +47 -0
  264. package/src/stream-durability.ts +598 -0
  265. package/src/stream-to-response.ts +1051 -95
  266. package/src/strip-to-spec-middleware.ts +3 -3
  267. package/src/types.ts +416 -24
  268. package/src/utilities/chat-params.ts +245 -55
  269. package/dist/esm/activities/index.js.map +0 -1
  270. package/dist/esm/adapter-internals.js.map +0 -1
  271. package/dist/esm/index.js.map +0 -1
  272. package/dist/esm/middlewares/index.js.map +0 -1
@@ -1,6 +1,26 @@
1
1
  import { toRunErrorPayload } from './activities/error-payload'
2
+ import { isCancelRequestedReason } from './activities/chat/cancel'
3
+ import {
4
+ isRunStatus,
5
+ isTerminalRunStatus,
6
+ } from './activities/chat/middleware/run-store'
7
+ import { wasRunDetached } from './delivery-detach'
8
+ import { notifyRunDisconnected } from './delivery-disconnect'
9
+ import { resolveResumeRunId } from './stream-durability'
10
+ import { EventType } from './types'
11
+ import { resolveDebugOption } from './logger/resolve'
12
+ import type { LockStore } from './activities/chat/middleware/locks'
13
+ import type {
14
+ RunRecord,
15
+ RunStore,
16
+ } from './activities/chat/middleware/run-store'
17
+ import type { InternalLogger } from './logger/internal-logger'
18
+ import type { DebugOption } from './logger/types'
19
+ import type { StreamDurability } from './stream-durability'
2
20
  import type { StreamChunk } from './types'
3
21
 
22
+ export { resolveResumeRunId } from './stream-durability'
23
+
4
24
  /**
5
25
  * Collect all text content from a StreamChunk async iterable and return as a string.
6
26
  *
@@ -13,8 +33,7 @@ import type { StreamChunk } from './types'
13
33
  * @example
14
34
  * ```typescript
15
35
  * const stream = chat({
16
- * adapter: openaiText(),
17
- * model: 'gpt-4o',
36
+ * adapter: openaiText('gpt-5.5'),
18
37
  * messages: [{ role: 'user', content: 'Hello!' }]
19
38
  * });
20
39
  * const text = await streamToText(stream);
@@ -35,6 +54,196 @@ export async function streamToText(
35
54
  return accumulatedContent
36
55
  }
37
56
 
57
+ interface RecordedFailure {
58
+ error: unknown
59
+ }
60
+
61
+ function errorMessage(error: unknown): string {
62
+ return toRunErrorPayload(error).message
63
+ }
64
+
65
+ function combineFailures(
66
+ primary: unknown,
67
+ secondary: unknown,
68
+ phase: string,
69
+ ): unknown {
70
+ if (primary === secondary) return primary
71
+ const errors =
72
+ primary instanceof AggregateError
73
+ ? [...primary.errors, secondary]
74
+ : [primary, secondary]
75
+ return new AggregateError(
76
+ errors,
77
+ `${errorMessage(primary)}; ${phase}: ${errorMessage(secondary)}`,
78
+ )
79
+ }
80
+
81
+ function runErrorChunk(
82
+ error: unknown,
83
+ ): Extract<StreamChunk, { type: 'RUN_ERROR' }> {
84
+ const payload = toRunErrorPayload(error)
85
+ return {
86
+ type: EventType.RUN_ERROR,
87
+ timestamp: Date.now(),
88
+ message: payload.message,
89
+ ...(payload.code === undefined ? {} : { code: payload.code }),
90
+ error: payload,
91
+ }
92
+ }
93
+
94
+ function isAborted(signal: AbortSignal): boolean {
95
+ return signal.aborted
96
+ }
97
+
98
+ /**
99
+ * Whether this abort is an EXPLICIT in-process cancel — the caller aborted with
100
+ * {@link RUN_CANCEL_REASON} rather than the socket going away.
101
+ *
102
+ * Core's own guard, independent of any middleware verdict: a user pressing Stop
103
+ * must always get a closed, terminal log, so the sink refuses to treat that abort
104
+ * as a detach even if the run's middleware published one. A reason-less abort
105
+ * carries a `DOMException`, never a string, so a non-string reason is "no
106
+ * explicit intent" — exactly how `resolveAbortReason` reads it in `chat()`.
107
+ */
108
+ function isExplicitCancel(signal: AbortSignal): boolean {
109
+ const reason: unknown = signal.reason
110
+ return typeof reason === 'string' && isCancelRequestedReason(reason)
111
+ }
112
+
113
+ function needsTerminalPersistence(
114
+ terminalPersisted: boolean,
115
+ cancelled: boolean,
116
+ failed: boolean,
117
+ ): boolean {
118
+ return !terminalPersisted && (cancelled || failed)
119
+ }
120
+
121
+ function toEncodedStream(
122
+ stream: AsyncIterable<StreamChunk>,
123
+ abortController: AbortController | undefined,
124
+ encodeChunk: (chunk: StreamChunk, index: number) => Uint8Array,
125
+ encodeError: (error: unknown) => Uint8Array,
126
+ detachOnCancel = false,
127
+ /**
128
+ * Called once when the response body is cancelled on the detach path, BEFORE
129
+ * returning. The durability branch uses it to tell the run its viewer is gone
130
+ * (see `./delivery-disconnect`) without aborting it.
131
+ */
132
+ onDetachedCancel?: () => void,
133
+ ): ReadableStream<Uint8Array> {
134
+ const cancellation = abortController ?? new AbortController()
135
+ let iterator: AsyncIterator<StreamChunk> | undefined
136
+ let iteratorCleanup: Promise<void> | undefined
137
+ let pumpPromise: Promise<void> = Promise.resolve()
138
+ let pumpFailure: RecordedFailure | undefined
139
+ let cancelled = false
140
+
141
+ const recordPumpFailure = (error: unknown, phase: string): void => {
142
+ pumpFailure = {
143
+ error:
144
+ pumpFailure === undefined
145
+ ? error
146
+ : combineFailures(pumpFailure.error, error, phase),
147
+ }
148
+ }
149
+
150
+ const closeIterator = (): Promise<void> => {
151
+ iteratorCleanup ??= (async () => {
152
+ if (iterator?.return) await iterator.return()
153
+ })()
154
+ return iteratorCleanup
155
+ }
156
+
157
+ return new ReadableStream({
158
+ start(controller) {
159
+ iterator = stream[Symbol.asyncIterator]()
160
+ pumpPromise = (async () => {
161
+ let index = 0
162
+ let iteratorDone = false
163
+
164
+ try {
165
+ while (!isAborted(cancellation.signal)) {
166
+ const result = await iterator.next()
167
+ if (result.done) {
168
+ iteratorDone = true
169
+ break
170
+ }
171
+ if (isAborted(cancellation.signal)) break
172
+ // After a detached cancel the reader is gone but we keep pulling to
173
+ // drain the producer into the durable log; skip enqueuing to the
174
+ // closed controller.
175
+ if (!cancelled) controller.enqueue(encodeChunk(result.value, index))
176
+ index += 1
177
+ }
178
+ } catch (error) {
179
+ recordPumpFailure(error, 'stream iteration failed')
180
+ } finally {
181
+ if (!iteratorDone) {
182
+ try {
183
+ await closeIterator()
184
+ } catch (error) {
185
+ recordPumpFailure(error, 'iterator cleanup failed')
186
+ }
187
+ }
188
+
189
+ if (
190
+ !cancelled &&
191
+ !isAborted(cancellation.signal) &&
192
+ pumpFailure !== undefined
193
+ ) {
194
+ controller.enqueue(encodeError(pumpFailure.error))
195
+ }
196
+ if (!cancelled) controller.close()
197
+ }
198
+ })().catch((error: unknown) => {
199
+ recordPumpFailure(error, 'stream pump failed')
200
+ })
201
+ },
202
+ async cancel(reason) {
203
+ cancelled = true
204
+ // Detached durable delivery: the client is gone (e.g. a page reload), but
205
+ // the run must finish into the durable log so a rejoining client can tail
206
+ // it to the real terminal. Do NOT abort the producer (that would kill the
207
+ // run and seal the log with RUN_ERROR) and do NOT await the pump — it
208
+ // keeps draining `stream` → the log in the background and terminates
209
+ // normally on its own. A genuine caller-driven stop aborts the producer's
210
+ // own AbortController instead, which this path never touches.
211
+ //
212
+ // Notify the run FIRST, and synchronously. This is the only moment the
213
+ // socket-closed fact exists anywhere, and the run cannot observe it on its
214
+ // own: it holds no handle on this response. That notification is what lets a
215
+ // durable run record itself as detached while it KEEPS RUNNING — the
216
+ // alternative applications were driven to (mirroring `request.signal` into
217
+ // `chat()`'s abortController) reaches the middleware only by killing the run,
218
+ // which for a sandboxed run means the agent is never even launched.
219
+ if (detachOnCancel) {
220
+ onDetachedCancel?.()
221
+ return
222
+ }
223
+
224
+ if (!isAborted(cancellation.signal)) cancellation.abort(reason)
225
+
226
+ let cancellationFailure: RecordedFailure | undefined
227
+ try {
228
+ await closeIterator()
229
+ } catch (error) {
230
+ cancellationFailure = { error }
231
+ }
232
+ await pumpPromise
233
+
234
+ if (pumpFailure !== undefined && cancellationFailure !== undefined) {
235
+ throw combineFailures(
236
+ pumpFailure.error,
237
+ cancellationFailure.error,
238
+ 'iterator cancellation failed',
239
+ )
240
+ }
241
+ if (pumpFailure !== undefined) throw pumpFailure.error
242
+ if (cancellationFailure !== undefined) throw cancellationFailure.error
243
+ },
244
+ })
245
+ }
246
+
38
247
  /**
39
248
  * Convert a StreamChunk async iterable to a ReadableStream in Server-Sent Events format
40
249
  *
@@ -45,58 +254,414 @@ export async function streamToText(
45
254
  *
46
255
  * @param stream - AsyncIterable of StreamChunks from chat()
47
256
  * @param abortController - Optional AbortController to abort when stream is cancelled
257
+ * @param getId - Optional per-chunk durability offset; when present, each event gets an `id:` line
48
258
  * @returns ReadableStream in Server-Sent Events format
49
259
  */
50
260
  export function toServerSentEventsStream(
51
261
  stream: AsyncIterable<StreamChunk>,
52
262
  abortController?: AbortController,
263
+ getId?: (chunk: StreamChunk, index: number) => string | undefined,
53
264
  ): ReadableStream<Uint8Array> {
265
+ const { encodeChunk, encodeError } = sseEncoders(getId)
266
+ return toEncodedStream(stream, abortController, encodeChunk, encodeError)
267
+ }
268
+
269
+ /**
270
+ * SSE chunk/error encoders. Shared by the public {@link toServerSentEventsStream}
271
+ * and the internal durability branch (which additionally needs `toEncodedStream`'s
272
+ * private `detachOnCancel`), so the wire format stays identical for both.
273
+ */
274
+ function sseEncoders(
275
+ getId?: (chunk: StreamChunk, index: number) => string | undefined,
276
+ ): {
277
+ encodeChunk: (chunk: StreamChunk, index: number) => Uint8Array
278
+ encodeError: (error: unknown) => Uint8Array
279
+ } {
54
280
  const encoder = new TextEncoder()
281
+ return {
282
+ encodeChunk: (chunk, index) => {
283
+ const id = getId?.(chunk, index)
284
+ const idLine = id === undefined ? '' : `id: ${id}\n`
285
+ return encoder.encode(`${idLine}data: ${JSON.stringify(chunk)}\n\n`)
286
+ },
287
+ encodeError: (error) =>
288
+ encoder.encode(`data: ${JSON.stringify(runErrorChunk(error))}\n\n`),
289
+ }
290
+ }
55
291
 
56
- return new ReadableStream({
57
- async start(controller) {
58
- try {
59
- for await (const chunk of stream) {
60
- // Check if stream was cancelled/aborted
61
- if (abortController?.signal.aborted) {
62
- break
292
+ /** Default number of chunks buffered before a durability `append`. */
293
+ const DEFAULT_DURABILITY_BATCH = 32
294
+
295
+ /**
296
+ * Resolve and validate the durability batch size. A non-positive-integer (0,
297
+ * negative, fractional, or `NaN`) is rejected rather than clamped: silently
298
+ * `Math.max(1, …)`-ing a `NaN` used to disable size-based flushing entirely
299
+ * (`length >= NaN` is always false), which is a subtle footgun.
300
+ */
301
+ function resolveBatchSize(batch: number | undefined): number {
302
+ if (batch === undefined) return DEFAULT_DURABILITY_BATCH
303
+ if (!Number.isInteger(batch) || batch <= 0) {
304
+ throw new Error(
305
+ `Invalid durability batch size: ${batch}. Must be a positive integer.`,
306
+ )
307
+ }
308
+ return batch
309
+ }
310
+
311
+ /**
312
+ * Boundaries at which the batching producer flushes early, regardless of the
313
+ * batch size — the run-start marker, terminal events, and tool-call ends.
314
+ * Flushing here keeps the durability log promptly consistent at semantically
315
+ * meaningful points.
316
+ *
317
+ * `RUN_STARTED` matters especially for one-shot activities (image, speech,
318
+ * transcription, summarize): they emit `RUN_STARTED`, then await the provider
319
+ * for seconds, then a terminal. Without flushing `RUN_STARTED` the log stays
320
+ * empty for the whole run, so a mount-time `joinRun` finds nothing and its
321
+ * empty-log deadline fast-fails as "run gone" — even though the run is alive.
322
+ * Flushing it immediately makes the run resumable from the instant it starts.
323
+ */
324
+ function isDurabilityFlushBoundary(chunk: StreamChunk): boolean {
325
+ return (
326
+ chunk.type === 'RUN_STARTED' ||
327
+ chunk.type === 'RUN_FINISHED' ||
328
+ chunk.type === 'RUN_ERROR' ||
329
+ chunk.type === 'TOOL_CALL_END'
330
+ )
331
+ }
332
+
333
+ /**
334
+ * Name of the synthetic `CUSTOM` chunk a fresh durable producer appends to its
335
+ * log before pulling the first real chunk.
336
+ *
337
+ * Flushing `RUN_STARTED` (above) makes a run joinable from the instant the
338
+ * stream EMITS something — but a `chat()` whose middleware boots a sandbox
339
+ * (create a container, install a CLI) legitimately emits nothing for minutes,
340
+ * and during that window the log is empty. Every joiner's empty-log fail-fast
341
+ * (`memoryStream`'s first-chunk deadline, the client's rejoin connect deadline)
342
+ * then reads the run as gone — and the client clears its resume pointer, so a
343
+ * reload during the boot window permanently orphans a run that is still going.
344
+ *
345
+ * This marker closes the window: it is appended (and flushed) before the
346
+ * producer stream is first pulled, so a join always finds a first chunk within
347
+ * milliseconds of the run being accepted. Takeover alignment is unaffected — a
348
+ * journal replay cannot reproduce the marker, and alignment already skips
349
+ * stored `CUSTOM` chunks as out-of-band for exactly that reason (see
350
+ * `isBridgeCustomChunk` in `@tanstack/ai-sandbox`).
351
+ */
352
+ export const RUN_ACCEPTED_EVENT = 'run.accepted'
353
+
354
+ /**
355
+ * Build the delivery-durable source iterable for a transport helper.
356
+ *
357
+ * - **Resume** (`resumeFrom()` non-null): replay strictly after the offset,
358
+ * reading only from the durability log. The input `stream` is NEVER iterated,
359
+ * so `chat()`'s lazy iterator never fires the provider — the untouched
360
+ * generator is simply GC'd. This is what makes resume free of re-invocation.
361
+ * - **Fresh** (`resumeFrom()` null): iterate `stream`, buffering up to `batch`
362
+ * chunks (flushing early at terminal / tool-call boundaries), `append` each
363
+ * batch to the log, then forward. Appending BEFORE forwarding guarantees a
364
+ * reconnecting client can always replay exactly what it already saw.
365
+ *
366
+ * The returned `getId` maps each forwarded chunk to the exact opaque offset
367
+ * returned by the durability adapter for the SSE `id:` line.
368
+ */
369
+ function durableStreamSource<TOffset extends string>(
370
+ stream: AsyncIterable<StreamChunk>,
371
+ durability: StreamDurability<TOffset>,
372
+ options: {
373
+ abortController: AbortController
374
+ batch?: number
375
+ logger?: InternalLogger
376
+ },
377
+ ): {
378
+ source: AsyncIterable<StreamChunk>
379
+ getId: (chunk: StreamChunk) => string | undefined
380
+ } {
381
+ const resumeOffset = durability.resumeFrom()
382
+ const batchSize = resolveBatchSize(options.batch)
383
+ const abortController = options.abortController
384
+ const logger = options.logger
385
+ const idByChunk = new WeakMap<object, string>()
386
+ const seenOffsets = new Set<string>()
387
+ const getId = (chunk: StreamChunk): string | undefined => idByChunk.get(chunk)
388
+
389
+ const validateOffset = (offset: TOffset): void => {
390
+ // Reject NUL/CR/LF (would corrupt the SSE `id:` line) and any offset that
391
+ // is not invariant under the wire round-trip. The SSE client reads the id
392
+ // with `.trim()`, so an offset with leading/trailing whitespace would come
393
+ // back changed and no longer match on reconnect — fail loud here rather
394
+ // than silently mis-resuming. (NDJSON carries the offset inside the JSON
395
+ // envelope and is unaffected, but the contract must hold for both wires.)
396
+ if (
397
+ offset.length === 0 ||
398
+ offset.includes('\0') ||
399
+ offset.includes('\r') ||
400
+ offset.includes('\n') ||
401
+ offset !== offset.trim()
402
+ ) {
403
+ throw new Error(
404
+ `Invalid durability offset for SSE id: ${JSON.stringify(offset)}`,
405
+ )
406
+ }
407
+ if (seenOffsets.has(offset)) {
408
+ throw new Error(
409
+ `Durability adapter must return a unique offset per chunk: ${JSON.stringify(offset)}`,
410
+ )
411
+ }
412
+ seenOffsets.add(offset)
413
+ }
414
+
415
+ async function* produce(): AsyncIterable<StreamChunk> {
416
+ let batch: Array<StreamChunk> = []
417
+ let terminalPersisted = false
418
+ // Whether a terminal event was actually delivered LIVE to the consumer (as
419
+ // opposed to only appended to the log). Distinguishes "the run already ended
420
+ // on the wire" from "the log has a terminal but the consumer never saw one",
421
+ // which governs whether a late durability-cleanup failure may be rethrown.
422
+ // Only ever assigned inside the nested flush() closure, which TS's
423
+ // control-flow analysis can't observe (see the disable at the read site).
424
+ let terminalForwarded = false
425
+ let failure: RecordedFailure | undefined
426
+ let terminalCause: unknown
427
+ let hasTerminalCause = false
428
+
429
+ const recordFailure = (error: unknown, phase: string): void => {
430
+ failure = {
431
+ error:
432
+ failure === undefined
433
+ ? error
434
+ : combineFailures(failure.error, error, phase),
435
+ }
436
+ }
437
+
438
+ async function* flush(): AsyncIterable<StreamChunk> {
439
+ if (batch.length === 0) return
440
+ const toForward = batch
441
+ batch = []
442
+ // Tag each chunk with the exact backend offset. Requiring one opaque
443
+ // token per chunk preserves exact-once resume at any batch size.
444
+ const offsets = await durability.append(toForward)
445
+ if (offsets.length !== toForward.length) {
446
+ throw new Error(
447
+ `Durability append returned ${offsets.length} offsets for ${toForward.length} chunks`,
448
+ )
449
+ }
450
+ toForward.forEach((chunk, i) => {
451
+ const offset = offsets[i]
452
+ if (offset === undefined) {
453
+ throw new Error(`Durability append omitted offset at index ${i}`)
454
+ }
455
+ validateOffset(offset)
456
+ idByChunk.set(chunk, offset)
457
+ })
458
+ if (
459
+ toForward.some(
460
+ (chunk) =>
461
+ chunk.type === 'RUN_FINISHED' || chunk.type === 'RUN_ERROR',
462
+ )
463
+ ) {
464
+ terminalPersisted = true
465
+ }
466
+ for (const chunk of toForward) {
467
+ if (chunk.type === 'RUN_FINISHED' || chunk.type === 'RUN_ERROR') {
468
+ terminalForwarded = true
469
+ }
470
+ yield chunk
471
+ }
472
+ }
473
+
474
+ try {
475
+ if (isAborted(abortController.signal)) return
476
+ // Make the run joinable BEFORE the producer is first pulled — the pull
477
+ // is what runs the middleware chain, and middleware may take minutes to
478
+ // yield a first chunk. See {@link RUN_ACCEPTED_EVENT}.
479
+ batch.push({
480
+ type: 'CUSTOM',
481
+ name: RUN_ACCEPTED_EVENT,
482
+ value: {},
483
+ timestamp: Date.now(),
484
+ })
485
+ yield* flush()
486
+ for await (const chunk of stream) {
487
+ if (isAborted(abortController.signal)) break
488
+ batch.push(chunk)
489
+ if (batch.length >= batchSize || isDurabilityFlushBoundary(chunk)) {
490
+ yield* flush()
491
+ }
492
+ }
493
+ if (!isAborted(abortController.signal)) yield* flush()
494
+ } catch (error) {
495
+ terminalCause = error
496
+ hasTerminalCause = true
497
+ recordFailure(error, 'producer failed')
498
+ // The provider stream threw. Persist a terminal RUN_ERROR to the
499
+ // durability log so a resumer / joiner learns the run failed (otherwise
500
+ // the log ends with no terminal and they wait forever). Flush any
501
+ // buffered chunks first, then append the terminal WITHOUT forwarding it
502
+ // live — the transport layer synthesizes the live RUN_ERROR on rethrow,
503
+ // so forwarding here too would double-emit.
504
+ if (!isAborted(abortController.signal)) {
505
+ try {
506
+ yield* flush()
507
+ } catch (flushError) {
508
+ recordFailure(flushError, 'flushing buffered chunks failed')
509
+ }
510
+ }
511
+ } finally {
512
+ // The PRODUCER was stopped, which is deliberately not the same question as
513
+ // "did the delivery socket go away". A disconnect alone must leave this
514
+ // false: the run survives it and terminalizes this log itself on its way
515
+ // out, and treating the disconnect as a cancel here would make `detached`
516
+ // true for a run that had already finished — skipping `close()` and parking
517
+ // every later tailer forever on a log nobody will ever continue.
518
+ const cancelled = isAborted(abortController.signal)
519
+
520
+ // Persist any buffered-but-unflushed chunks before terminalizing, so a
521
+ // joiner replaying the log sees everything produced up to a disconnect
522
+ // rather than a truncated prefix. On the abort path the streaming loop
523
+ // broke before its trailing flush; drain flush() here for its persistence
524
+ // side effect only (the delivery socket is gone, so the yielded chunks are
525
+ // discarded). The normal and provider-throw paths already flushed, so
526
+ // `batch` is empty for them and this is a no-op.
527
+ if (batch.length > 0) {
528
+ try {
529
+ for await (const _chunk of flush()) {
530
+ // persist-only: nothing consumes these
63
531
  }
532
+ } catch (flushError) {
533
+ recordFailure(flushError, 'flushing buffered chunks on exit failed')
534
+ }
535
+ }
64
536
 
65
- // Send each chunk as Server-Sent Events format
66
- controller.enqueue(
67
- encoder.encode(`data: ${JSON.stringify(chunk)}\n\n`),
68
- )
537
+ // Was this abort a DETACH? Only the run's own middleware can say — it is
538
+ // the only actor that has resolved both out-of-band cancel bands and
539
+ // `detachOnDisconnect` — and it says so on the stream itself (see
540
+ // `./delivery-detach`). Read only AFTER the try block above has exited,
541
+ // which is what awaits the chat generator's `return()` and therefore the
542
+ // whole `onAbort` chain that publishes the verdict.
543
+ //
544
+ // Every conjunct is load bearing. `cancelled` keeps a normal finish on
545
+ // today's path. `!isExplicitCancel` is core's own belt-and-braces refusal to
546
+ // spare a run the user deliberately stopped, whatever a middleware claims.
547
+ // `!hasTerminalCause` keeps a GENUINE provider failure
548
+ // terminal even if the socket died too, so a real error is never mistaken
549
+ // for a detach. And `wasRunDetached` is false for an
550
+ // explicit cancel (either band), for a non-detachable disconnect, and for
551
+ // every app that has not wired durability — all of which keep terminalizing
552
+ // and closing exactly as before.
553
+ //
554
+ // What is ALREADY IN THE LOG is deliberately NOT a conjunct. An agent-loop
555
+ // run emits one `RUN_FINISHED` PER ITERATION — the intermediate
556
+ // `finishReason: 'tool_calls'` terminal is flushed at its boundary
557
+ // mid-run — so `terminalPersisted` means "some terminal is in the log",
558
+ // never "the run ended". Gating on it terminalized the log of a healthy,
559
+ // still-running agent for every tool-calling run. Nor can the sink
560
+ // distinguish a final terminal from an intermediate one by its
561
+ // `finishReason`: only the run's middleware knows, and that is exactly
562
+ // what the verdict reports. So a published detach verdict WINS — it
563
+ // already means "the agent is alive and a successor will terminalize this
564
+ // log".
565
+ const detached =
566
+ cancelled &&
567
+ !isExplicitCancel(abortController.signal) &&
568
+ !hasTerminalCause &&
569
+ wasRunDetached(stream)
570
+
571
+ if (
572
+ !detached &&
573
+ needsTerminalPersistence(terminalPersisted, cancelled, hasTerminalCause)
574
+ ) {
575
+ // Prefer the real provider error even when the delivery socket was also
576
+ // aborted: if the run genuinely failed, a joiner should see that cause,
577
+ // not a generic AbortError that masks it. AbortError is only used for a
578
+ // pure cancellation with no underlying failure.
579
+ const cause = hasTerminalCause ? terminalCause : { name: 'AbortError' }
580
+ try {
581
+ await durability.append([runErrorChunk(cause)])
582
+ terminalPersisted = true
583
+ } catch (terminalError) {
584
+ // Rethrown to the live consumer below, but a joiner replaying the log
585
+ // only ever sees a generic incomplete error — so record the real
586
+ // cause server-side where an operator can act on it.
587
+ logger?.errors('persisting terminal RUN_ERROR failed', {
588
+ error: terminalError,
589
+ })
590
+ recordFailure(terminalError, 'persisting terminal RUN_ERROR failed')
69
591
  }
592
+ }
70
593
 
71
- controller.close()
72
- } catch (error: unknown) {
73
- // Don't send error if aborted
74
- if (abortController?.signal.aborted) {
75
- controller.close()
76
- return
594
+ // A detached run's log is deliberately left OPEN: the run is still going,
595
+ // and `close()` would terminalize the log the takeover has to continue —
596
+ // a tailing attach would stop at the prefix, and a stored synthetic
597
+ // `RUN_ERROR` would additionally diverge the takeover's journal replay and
598
+ // record a healthy run as failed.
599
+ //
600
+ // This is NOT the general "fence the close" that `ai-sandbox`'s claim.ts
601
+ // rules out. That fence would suppress `close()` for a run nobody will ever
602
+ // drive again, wedging the record at `'running'` with every tailer parked
603
+ // forever. The skip here is conditional on a verdict that means the exact
604
+ // opposite: the agent is alive and a successor WILL terminalize this log
605
+ // (its own producer exit runs this same `finally`). Keep that distinction —
606
+ // widening this condition to "any abort" re-introduces the wedge.
607
+ if (!detached) {
608
+ try {
609
+ await durability.close()
610
+ } catch (closeError) {
611
+ // A failed close leaves the durable log unterminated for joiners; the
612
+ // live consumer gets the rethrow, but log it for the joiner's sake.
613
+ logger?.errors('closing durability stream failed', {
614
+ error: closeError,
615
+ })
616
+ recordFailure(closeError, 'closing durability stream failed')
77
617
  }
618
+ }
78
619
 
79
- // Send error event (AG-UI RUN_ERROR)
80
- controller.enqueue(
81
- encoder.encode(
82
- `data: ${JSON.stringify({
83
- type: 'RUN_ERROR',
84
- timestamp: Date.now(),
85
- error: toRunErrorPayload(error),
86
- })}\n\n`,
87
- ),
620
+ // Rethrow a terminalization/close failure to the live consumer ONLY when
621
+ // no terminal reached it yet — the transport then synthesizes a live
622
+ // RUN_ERROR so the consumer isn't left without a terminal. If a terminal
623
+ // was already forwarded (the run ended on the wire), a late failure is a
624
+ // server-side cleanup issue; rethrowing it would append a contradictory
625
+ // second terminal (RUN_ERROR after RUN_FINISHED) on the wire. Suppress the
626
+ // rethrow, but never let the cause vanish — record it server-side, the
627
+ // same as the close / terminal-append failures above. (This also covers a
628
+ // provider that throws AFTER emitting its own terminal, whose error is
629
+ // otherwise neither delivered nor logged.)
630
+ if (failure !== undefined) {
631
+ // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- terminalForwarded is set only inside the flush() closure, which TS CFA narrows away here
632
+ if (!terminalForwarded) {
633
+ // eslint-disable-next-line no-unsafe-finally
634
+ throw failure.error
635
+ }
636
+ logger?.errors(
637
+ 'durability failure after a terminal event was forwarded',
638
+ {
639
+ error: failure.error,
640
+ },
88
641
  )
89
- controller.close()
90
- }
91
- },
92
- cancel() {
93
- // When the ReadableStream is cancelled (e.g., client disconnects),
94
- // abort the underlying stream
95
- if (abortController) {
96
- abortController.abort()
97
642
  }
98
- },
99
- })
643
+ }
644
+ }
645
+
646
+ async function* replay(offset: TOffset): AsyncIterable<StreamChunk> {
647
+ // Thread the consumer's abort signal into the read so a live-tailing join
648
+ // (a mid-stream reconnect) that is aborted — or that hit a runId with no
649
+ // in-process producer — stops parking and ends instead of hanging forever.
650
+ for await (const { offset: eventOffset, chunk } of durability.read(
651
+ offset,
652
+ abortController.signal,
653
+ )) {
654
+ if (isAborted(abortController.signal)) break
655
+ validateOffset(eventOffset)
656
+ idByChunk.set(chunk, eventOffset)
657
+ yield chunk
658
+ }
659
+ }
660
+
661
+ return {
662
+ source: resumeOffset !== null ? replay(resumeOffset) : produce(),
663
+ getId,
664
+ }
100
665
  }
101
666
 
102
667
  /**
@@ -107,21 +672,42 @@ export function toServerSentEventsStream(
107
672
  * - Each chunk is followed by "\n\n"
108
673
  * - Stream ends when the underlying iterable is exhausted (RUN_FINISHED is the terminal event)
109
674
  *
675
+ * Pass a `durability` sink (`memoryStream(request)` / `durableStream(request)`)
676
+ * to make the stream resumable: fresh runs are appended to the log and each SSE
677
+ * event is tagged with an `id:` offset; a reconnect (native `Last-Event-ID`) or
678
+ * a `?offset` join replays from the log without re-running the producer. `batch`
679
+ * controls how many chunks are buffered per `append` (default 32).
680
+ *
110
681
  * @param stream - AsyncIterable of StreamChunks from chat()
111
- * @param init - Optional Response initialization options (including `abortController`)
682
+ * @param init - Optional Response initialization options (including `abortController`, `durability` with its optional `batch`, and `debug`)
112
683
  * @returns Response in Server-Sent Events format
113
684
  *
114
685
  * @example
115
686
  * ```typescript
116
- * const stream = chat({ adapter: openaiText(), model: "gpt-4o", messages: [...] });
117
- * return toServerSentEventsResponse(stream, { abortController });
687
+ * export async function POST(request: Request) {
688
+ * const stream = chat({ adapter: openaiText('gpt-5.5'), messages: [...] });
689
+ * return toServerSentEventsResponse(stream, { durability: { adapter: memoryStream(request) } });
690
+ * }
118
691
  * ```
119
692
  */
120
- export function toServerSentEventsResponse(
693
+ export function toServerSentEventsResponse<TOffset extends string = string>(
121
694
  stream: AsyncIterable<StreamChunk>,
122
- init?: ResponseInit & { abortController?: AbortController },
695
+ init?: ResponseInit & {
696
+ abortController?: AbortController
697
+ durability?: { adapter: StreamDurability<TOffset>; batch?: number }
698
+ /**
699
+ * Customize logging for durability failure paths (terminal-append and
700
+ * close). These failures are always logged server-side by default (the
701
+ * `errors` category is on even without `debug`, via a `ConsoleLogger`);
702
+ * pass `debug` to route them to a custom `Logger` or raise verbosity. A
703
+ * joiner replaying the log only ever sees a generic incomplete error, so
704
+ * server-side logging is where the real cause is recoverable.
705
+ */
706
+ debug?: DebugOption
707
+ },
123
708
  ): Response {
124
- const { headers, abortController, ...responseInit } = init ?? {}
709
+ const { headers, abortController, durability, debug, ...responseInit } =
710
+ init ?? {}
125
711
 
126
712
  // Start with default SSE headers
127
713
  const mergedHeaders = new Headers({
@@ -139,12 +725,292 @@ export function toServerSentEventsResponse(
139
725
  })
140
726
  }
141
727
 
142
- return new Response(toServerSentEventsStream(stream, abortController), {
728
+ let body: ReadableStream<Uint8Array>
729
+ if (durability) {
730
+ // A fresh run (not a resume/replay) drains into the durable log under its
731
+ // OWN producer controller, decoupled from the HTTP response: a response
732
+ // cancel (page reload) detaches and keeps draining in the background so a
733
+ // rejoining client tails the log to the real terminal, rather than killing
734
+ // the run and sealing the log with RUN_ERROR. The producer is aborted only
735
+ // by a caller-supplied `abortController` (a genuine stop()). On the resume
736
+ // path the response IS a reader, so a cancel should stop the read normally.
737
+ const isFresh = durability.adapter.resumeFrom() === null
738
+ const producerAbortController = abortController ?? new AbortController()
739
+ const deliveryAbortController = isFresh
740
+ ? new AbortController()
741
+ : producerAbortController
742
+ const { source, getId } = durableStreamSource(stream, durability.adapter, {
743
+ abortController: producerAbortController,
744
+ batch: durability.batch,
745
+ // `errors` category is on by default even when `debug` is undefined, so
746
+ // durability terminal-append / close failures always surface server-side —
747
+ // including on the client-disconnect path where there is no live consumer.
748
+ logger: resolveDebugOption(debug),
749
+ })
750
+ const { encodeChunk, encodeError } = sseEncoders(getId)
751
+ body = toEncodedStream(
752
+ source,
753
+ deliveryAbortController,
754
+ encodeChunk,
755
+ encodeError,
756
+ isFresh,
757
+ // Fresh runs only: a resume response IS a reader, so its cancel is an
758
+ // ordinary read being stopped, not a producer losing its viewer.
759
+ isFresh ? () => notifyRunDisconnected(stream) : undefined,
760
+ )
761
+ } else {
762
+ body = toServerSentEventsStream(stream, abortController)
763
+ }
764
+
765
+ return new Response(body, {
143
766
  ...responseInit,
144
767
  headers: mergedHeaders,
145
768
  })
146
769
  }
147
770
 
771
+ /**
772
+ * A resume is served entirely from the durability log, so there is no producer
773
+ * to iterate. This empty source satisfies the response helpers' signature; on a
774
+ * resume `durableStreamSource` replays from the log and never touches it.
775
+ */
776
+ function emptyDurableSource(): AsyncIterable<StreamChunk> {
777
+ return (async function* () {})()
778
+ }
779
+
780
+ /**
781
+ * Everything the resume helpers need to take a run over as a side effect of
782
+ * serving its log.
783
+ *
784
+ * `claim` and `pipe` are **injected**, not imported. The two mechanisms a
785
+ * takeover needs (`withRunClaim` and `pipeToRunLog`) live in
786
+ * `@tanstack/ai-sandbox`, and `@tanstack/ai` must not depend on that package —
787
+ * that layering inversion is exactly what moving `LockStore` into core was meant
788
+ * to prevent, and it would make core depend on the sandbox package to serve a
789
+ * plain chat run. Injecting them keeps only the *shape* of a takeover in core
790
+ * (parse the run id, read the record, skip if terminal, claim, drive) and lets a
791
+ * background-worker-driven run supply its own pair.
792
+ * `@tanstack/ai-sandbox`'s `sandboxRunDriver` fills both in.
793
+ */
794
+ export interface RunDriverOptions {
795
+ /** The attach request; its run id is read with {@link resolveResumeRunId}. */
796
+ request: Request
797
+ runs: RunStore
798
+ locks: LockStore
799
+ /** Produce the run's remaining events. Called only once the claim is held. */
800
+ drive: (input: {
801
+ runId: string
802
+ threadId: string
803
+ signal: AbortSignal
804
+ }) => AsyncIterable<StreamChunk>
805
+ /** Run `fn` under exclusive ownership of the run, or reject if refused. */
806
+ claim: <T>(
807
+ input: { runs: RunStore; locks: LockStore; runId: string },
808
+ fn: (claim: {
809
+ runId: string
810
+ epoch: number
811
+ signal: AbortSignal
812
+ }) => Promise<T>,
813
+ ) => Promise<T>
814
+ /** Persist the driven stream to the run's producer-side durability log. */
815
+ pipe: (
816
+ stream: AsyncIterable<StreamChunk>,
817
+ input: { runId: string; threadId: string; signal: AbortSignal },
818
+ ) => Promise<unknown>
819
+ /** Platform keep-alive (e.g. `ctx.waitUntil`) for the background drive. */
820
+ waitUntil?: (promise: Promise<unknown>) => void
821
+ logger?: InternalLogger
822
+ }
823
+
824
+ /** Shared options for the resume-only response helpers. */
825
+ type ResumeResponseOptions<TOffset extends string> = ResponseInit & {
826
+ adapter: StreamDurability<TOffset>
827
+ batch?: number
828
+ debug?: DebugOption
829
+ /**
830
+ * Take the run over while serving its log. Omit to serve the log only —
831
+ * the response is byte-identical either way.
832
+ */
833
+ driver?: RunDriverOptions
834
+ }
835
+
836
+ /**
837
+ * Take over an in-flight run as a side effect of serving its log.
838
+ *
839
+ * The response itself is unchanged: it still replays from the durability log via
840
+ * `emptyDurableSource()`. The drive runs BESIDE it, appending to the run's own
841
+ * producer-side log through the injected `pipe`, and the response tails what
842
+ * lands. That separation is what lets a taken-over run keep `chat()`'s normal
843
+ * middleware path — `withPersistence.onFinish` is what saves the transcript, so a
844
+ * parallel translation path would lose the history of any run that completed
845
+ * while detached.
846
+ *
847
+ * TOTAL BY CONSTRUCTION. Every failure is logged and swallowed:
848
+ *
849
+ * - No run id, no record, or a terminal record → serve the log, drive nothing.
850
+ * A second tab attaching to a finished run must still see the transcript.
851
+ * - The claim is refused (another host is already driving) → serve the log,
852
+ * drive nothing. That is the documented "two hosts attach at once: one wins
853
+ * the lease and drives, the other tails the log" behavior.
854
+ * - The drive throws → logged. It cannot be reported to this response, which is
855
+ * already streaming the log; the run's own `RUN_ERROR` event is the channel.
856
+ *
857
+ * A rejection escaping here would be an unhandled rejection with nobody to
858
+ * report it to — process-fatal on modern Node and instance-fatal inside a
859
+ * Durable Object.
860
+ */
861
+ function startRunDriver(driver: RunDriverOptions): void {
862
+ const logger = driver.logger
863
+ const promise = (async () => {
864
+ const runId = resolveResumeRunId(driver.request)
865
+ if (runId === null) return
866
+ let record: RunRecord | null = null
867
+ try {
868
+ record = await driver.runs.get(runId)
869
+ } catch (error) {
870
+ logger?.errors('resume driver: reading the run record failed', {
871
+ runId,
872
+ error,
873
+ })
874
+ return
875
+ }
876
+ // Validated, not trusted: `record.status` is typed `RunStatus` but comes off
877
+ // a user-implemented `RunStore`, so the type is a claim about a storage
878
+ // column and nothing checked it. An unrecognized value means the run cannot
879
+ // be reasoned about at all — the record says nothing trustworthy about
880
+ // whether an agent is already driving it — so refuse the drive the same way
881
+ // a terminal record does, and still serve the log so a corrupt row does not
882
+ // also blank the transcript.
883
+ if (record !== null && !isRunStatus(record.status)) {
884
+ logger?.errors(
885
+ 'resume driver: the run record has an unrecognized status',
886
+ {
887
+ runId,
888
+ status: record.status,
889
+ },
890
+ )
891
+ return
892
+ }
893
+ if (record === null || isTerminalRunStatus(record.status)) return
894
+ // A recorded cancel is NOT a status. `requestRunCancel` deliberately writes
895
+ // only `cancelRequested`, so a run cancelled out of band while its driving
896
+ // host had already died stays `'running'` — and the status gate above waves
897
+ // it straight through. Driving it resurrects a run the user explicitly
898
+ // stopped and burns tokens until the TTL expires. The log is still served, so
899
+ // an attaching tab sees the transcript; only the drive is refused.
900
+ //
901
+ // This is the "don't START one" half. Aborting a drive that is ALREADY live
902
+ // when a cancel lands afterwards is a separate, still-open concern.
903
+ if (record.cancelRequested === true) return
904
+ // Captured after narrowing so the closure below sees a definite record
905
+ // rather than the re-widened `let`.
906
+ const active = record
907
+
908
+ try {
909
+ await driver.claim(
910
+ { runs: driver.runs, locks: driver.locks, runId },
911
+ async (claim) => {
912
+ // A viewer is attached again, so the detached clock stops. Cleared
913
+ // under the claim so it cannot race the reaper's read.
914
+ //
915
+ // THE REAPER: do NOT reuse `startRunDriver` for reclaiming detached
916
+ // runs. `@tanstack/ai-sandbox`'s `reapDetachedRuns` deliberately does
917
+ // the opposite of this line — it ACTS ON `detachedSince` and must
918
+ // leave the marker intact for its own TTL accounting — so borrowing
919
+ // this path would erase the very evidence the reaper selected the run
920
+ // on, resetting the TTL on every sweep so a detached run could never
921
+ // expire. That is why the reaper has its own drive path.
922
+ //
923
+ // LOG AND CONTINUE. This write is BOOKKEEPING for the reaper's TTL
924
+ // accounting; the claim is already held and the takeover is the
925
+ // valuable part. Letting a rejection propagate would land in the catch
926
+ // below — the channel reserved for the normal "someone else won the
927
+ // lease" case — so one transient store error would silently cost the
928
+ // whole drive, logged only on the `provider` debug channel and
929
+ // therefore invisible at default log levels. The worst case of
930
+ // continuing is a stale `detachedSince` the reaper may act on later;
931
+ // the worst case of vetoing is a run nobody drives at all.
932
+ try {
933
+ await driver.runs.update(runId, { detachedSince: undefined })
934
+ } catch (error) {
935
+ logger?.errors('resume driver: clearing detachedSince failed', {
936
+ runId,
937
+ error,
938
+ })
939
+ }
940
+ await driver.pipe(
941
+ driver.drive({
942
+ runId,
943
+ threadId: active.threadId,
944
+ signal: claim.signal,
945
+ }),
946
+ { runId, threadId: active.threadId, signal: claim.signal },
947
+ )
948
+ },
949
+ )
950
+ } catch (error) {
951
+ // Includes RunClaimNotAcquiredError (someone else is driving) and
952
+ // RunClaimLostError (we were superseded mid-drive). Both are normal.
953
+ logger?.provider('resume driver: not driving this run', { runId, error })
954
+ }
955
+ })()
956
+
957
+ if (driver.waitUntil) {
958
+ driver.waitUntil(promise)
959
+ } else {
960
+ // No platform keep-alive: at least ensure the rejection is handled. The
961
+ // async body above already catches everything, so this is belt-and-braces.
962
+ void promise.catch(() => {})
963
+ }
964
+ }
965
+
966
+ /**
967
+ * The single wiring point both resume helpers call, so the SSE and NDJSON
968
+ * halves cannot drift: a fix here applies to both. Called AFTER each helper's
969
+ * `resumeFrom() === null` 400 check — an attach with no offset has nothing to
970
+ * replay, and driving a run whose response will 400 would start an agent
971
+ * nobody is watching.
972
+ */
973
+ function maybeStartRunDriver(driver: RunDriverOptions | undefined): void {
974
+ if (driver) startRunDriver(driver)
975
+ }
976
+
977
+ const NO_RESUME_OFFSET =
978
+ 'No resume offset provided (expected a Last-Event-ID header or an ?offset query parameter).'
979
+
980
+ /**
981
+ * Serve a resumable run from its durability log over Server-Sent Events, without
982
+ * re-running the model. Use this in a `GET` handler so a reload or a second tab
983
+ * can re-attach to an in-flight or finished run.
984
+ *
985
+ * The adapter (`memoryStream(request)` / `durableStream(request)`) captures the
986
+ * resume offset from the request. If there is none (no `Last-Event-ID` header
987
+ * and no `?offset`), there is nothing to replay and this returns a 400.
988
+ *
989
+ * @example
990
+ * ```typescript
991
+ * export async function GET(request: Request) {
992
+ * return resumeServerSentEventsResponse({ adapter: memoryStream(request) });
993
+ * }
994
+ * ```
995
+ */
996
+ export function resumeServerSentEventsResponse<TOffset extends string = string>(
997
+ options: ResumeResponseOptions<TOffset>,
998
+ ): Response {
999
+ // `driver` MUST be destructured out: `responseInit` is spread into
1000
+ // `new Response(body, init)`, so leaving it in would leak the driver object
1001
+ // (and its Request) into the response init.
1002
+ const { adapter, batch, debug, driver, ...responseInit } = options
1003
+ if (adapter.resumeFrom() === null) {
1004
+ return new Response(NO_RESUME_OFFSET, { status: 400 })
1005
+ }
1006
+ maybeStartRunDriver(driver)
1007
+ return toServerSentEventsResponse(emptyDurableSource(), {
1008
+ ...responseInit,
1009
+ durability: { adapter, batch },
1010
+ debug,
1011
+ })
1012
+ }
1013
+
148
1014
  /**
149
1015
  * Convert a StreamChunk async iterable to a ReadableStream in HTTP stream format (newline-delimited JSON)
150
1016
  *
@@ -154,13 +1020,20 @@ export function toServerSentEventsResponse(
154
1020
  *
155
1021
  * This format is compatible with `fetchHttpStream` connection adapter.
156
1022
  *
1023
+ * When `getId` is supplied (delivery durability), each chunk is emitted as an
1024
+ * envelope `{"id":"<offset>","chunk":{…}}` instead of a bare chunk. NDJSON has
1025
+ * no native event-id field like SSE's `id:` line, so the resumable offset rides
1026
+ * inside the payload. Untagged chunks (no id) stay bare, so a non-durable
1027
+ * stream is byte-identical to before and the client auto-detects either form.
1028
+ *
157
1029
  * @param stream - AsyncIterable of StreamChunks from chat()
158
1030
  * @param abortController - Optional AbortController to abort when stream is cancelled
1031
+ * @param getId - Optional per-chunk durability offset; when present, chunks are envelope-encoded
159
1032
  * @returns ReadableStream in HTTP stream format (newline-delimited JSON)
160
1033
  *
161
1034
  * @example
162
1035
  * ```typescript
163
- * const stream = chat({ adapter: openaiText(), model: "gpt-4o", messages: [...] });
1036
+ * const stream = chat({ adapter: openaiText('gpt-5.5'), messages: [...] });
164
1037
  * const readableStream = toHttpStream(stream);
165
1038
  * // Use with Response for HTTP streaming (not SSE)
166
1039
  * return new Response(readableStream, {
@@ -171,51 +1044,33 @@ export function toServerSentEventsResponse(
171
1044
  export function toHttpStream(
172
1045
  stream: AsyncIterable<StreamChunk>,
173
1046
  abortController?: AbortController,
1047
+ getId?: (chunk: StreamChunk, index: number) => string | undefined,
174
1048
  ): ReadableStream<Uint8Array> {
175
- const encoder = new TextEncoder()
176
-
177
- return new ReadableStream({
178
- async start(controller) {
179
- try {
180
- for await (const chunk of stream) {
181
- // Check if stream was cancelled/aborted
182
- if (abortController?.signal.aborted) {
183
- break
184
- }
185
-
186
- // Send each chunk as newline-delimited JSON
187
- controller.enqueue(encoder.encode(`${JSON.stringify(chunk)}\n`))
188
- }
189
-
190
- controller.close()
191
- } catch (error: unknown) {
192
- // Don't send error if aborted
193
- if (abortController?.signal.aborted) {
194
- controller.close()
195
- return
196
- }
1049
+ const { encodeChunk, encodeError } = ndjsonEncoders(getId)
1050
+ return toEncodedStream(stream, abortController, encodeChunk, encodeError)
1051
+ }
197
1052
 
198
- // Send error event (AG-UI RUN_ERROR)
199
- controller.enqueue(
200
- encoder.encode(
201
- `${JSON.stringify({
202
- type: 'RUN_ERROR',
203
- timestamp: Date.now(),
204
- error: toRunErrorPayload(error),
205
- })}\n`,
206
- ),
207
- )
208
- controller.close()
209
- }
210
- },
211
- cancel() {
212
- // When the ReadableStream is cancelled (e.g., client disconnects),
213
- // abort the underlying stream
214
- if (abortController) {
215
- abortController.abort()
216
- }
1053
+ /**
1054
+ * NDJSON chunk/error encoders. Shared by {@link toHttpStream} and the internal
1055
+ * durability branch (see {@link sseEncoders}).
1056
+ */
1057
+ function ndjsonEncoders(
1058
+ getId?: (chunk: StreamChunk, index: number) => string | undefined,
1059
+ ): {
1060
+ encodeChunk: (chunk: StreamChunk, index: number) => Uint8Array
1061
+ encodeError: (error: unknown) => Uint8Array
1062
+ } {
1063
+ const encoder = new TextEncoder()
1064
+ return {
1065
+ encodeChunk: (chunk, index) => {
1066
+ const id = getId?.(chunk, index)
1067
+ const line =
1068
+ id === undefined ? JSON.stringify(chunk) : JSON.stringify({ id, chunk })
1069
+ return encoder.encode(`${line}\n`)
217
1070
  },
218
- })
1071
+ encodeError: (error) =>
1072
+ encoder.encode(`${JSON.stringify(runErrorChunk(error))}\n`),
1073
+ }
219
1074
  }
220
1075
 
221
1076
  /**
@@ -227,21 +1082,122 @@ export function toHttpStream(
227
1082
  *
228
1083
  * This format is compatible with `fetchHttpStream` connection adapter.
229
1084
  *
1085
+ * Pass a `durability` sink (`memoryStream(request)` / `durableStream(request)`)
1086
+ * to make the stream resumable: fresh runs are appended to the log and each
1087
+ * NDJSON line is emitted as an `{ id, chunk }` envelope carrying an opaque
1088
+ * offset; a reconnect (native `Last-Event-ID` header) or a `?offset` join
1089
+ * replays from the log without re-running the producer. `batch` controls how
1090
+ * many chunks are buffered per `append` (default 32). This shares the exact
1091
+ * `durableStreamSource` used by `toServerSentEventsResponse` — only the wire
1092
+ * encoding differs.
1093
+ *
230
1094
  * @param stream - AsyncIterable of StreamChunks from chat()
231
- * @param init - Optional Response initialization options (including `abortController`)
1095
+ * @param init - Optional Response initialization options (including `abortController`, `durability` with its optional `batch`, and `debug`)
232
1096
  * @returns Response in HTTP stream format (newline-delimited JSON)
233
1097
  *
234
1098
  * @example
235
1099
  * ```typescript
236
- * const stream = chat({ adapter: openaiText(), model: "gpt-4o", messages: [...] });
237
- * return toHttpResponse(stream, { abortController });
1100
+ * export async function POST(request: Request) {
1101
+ * const stream = chat({ adapter: openaiText('gpt-5.5'), messages: [...] });
1102
+ * return toHttpResponse(stream, { durability: { adapter: memoryStream(request) } });
1103
+ * }
238
1104
  * ```
239
1105
  */
240
- export function toHttpResponse(
1106
+ export function toHttpResponse<TOffset extends string = string>(
241
1107
  stream: AsyncIterable<StreamChunk>,
242
- init?: ResponseInit & { abortController?: AbortController },
1108
+ init?: ResponseInit & {
1109
+ abortController?: AbortController
1110
+ durability?: { adapter: StreamDurability<TOffset>; batch?: number }
1111
+ /**
1112
+ * Customize logging for durability failure paths (terminal-append and
1113
+ * close). These failures are always logged server-side by default (the
1114
+ * `errors` category is on even without `debug`, via a `ConsoleLogger`);
1115
+ * pass `debug` to route them to a custom `Logger` or raise verbosity. A
1116
+ * joiner replaying the log only ever sees a generic incomplete error, so
1117
+ * server-side logging is where the real cause is recoverable.
1118
+ */
1119
+ debug?: DebugOption
1120
+ },
1121
+ ): Response {
1122
+ const { abortController, durability, debug, headers, ...responseInit } =
1123
+ init ?? {}
1124
+
1125
+ // Default to a streaming NDJSON content type (with no-cache), overridable by
1126
+ // user headers. Without an explicit streaming type some intermediaries buffer
1127
+ // the response, defeating incremental delivery. Mirrors the SSE helper.
1128
+ const mergedHeaders = new Headers({
1129
+ 'Content-Type': 'application/x-ndjson',
1130
+ 'Cache-Control': 'no-cache',
1131
+ })
1132
+ if (headers) {
1133
+ const userHeaders = new Headers(headers)
1134
+ userHeaders.forEach((value, key) => {
1135
+ mergedHeaders.set(key, value)
1136
+ })
1137
+ }
1138
+
1139
+ let body: ReadableStream<Uint8Array>
1140
+ if (durability) {
1141
+ // See toServerSentEventsResponse: a fresh run drains into the durable log
1142
+ // under its own producer controller, so a response cancel (reload) detaches
1143
+ // and keeps draining in the background instead of killing the run; a resume
1144
+ // response is a reader whose cancel stops the read normally.
1145
+ const isFresh = durability.adapter.resumeFrom() === null
1146
+ const producerAbortController = abortController ?? new AbortController()
1147
+ const deliveryAbortController = isFresh
1148
+ ? new AbortController()
1149
+ : producerAbortController
1150
+ const { source, getId } = durableStreamSource(stream, durability.adapter, {
1151
+ abortController: producerAbortController,
1152
+ batch: durability.batch,
1153
+ // Errors-on-by-default logger (see toServerSentEventsResponse).
1154
+ logger: resolveDebugOption(debug),
1155
+ })
1156
+ const { encodeChunk, encodeError } = ndjsonEncoders(getId)
1157
+ body = toEncodedStream(
1158
+ source,
1159
+ deliveryAbortController,
1160
+ encodeChunk,
1161
+ encodeError,
1162
+ isFresh,
1163
+ // See the SSE helper: fresh runs only.
1164
+ isFresh ? () => notifyRunDisconnected(stream) : undefined,
1165
+ )
1166
+ } else {
1167
+ body = toHttpStream(stream, abortController)
1168
+ }
1169
+
1170
+ return new Response(body, {
1171
+ ...responseInit,
1172
+ headers: mergedHeaders,
1173
+ })
1174
+ }
1175
+
1176
+ /**
1177
+ * Serve a resumable run from its durability log over NDJSON, without re-running
1178
+ * the model. The NDJSON counterpart of {@link resumeServerSentEventsResponse};
1179
+ * pair it with a `toHttpResponse` producer. Returns a 400 when the request
1180
+ * carries no resume offset (no `Last-Event-ID` header and no `?offset`).
1181
+ *
1182
+ * @example
1183
+ * ```typescript
1184
+ * export async function GET(request: Request) {
1185
+ * return resumeHttpResponse({ adapter: memoryStream(request) });
1186
+ * }
1187
+ * ```
1188
+ */
1189
+ export function resumeHttpResponse<TOffset extends string = string>(
1190
+ options: ResumeResponseOptions<TOffset>,
243
1191
  ): Response {
244
- return new Response(toHttpStream(stream, init?.abortController), {
245
- ...init,
1192
+ // See `resumeServerSentEventsResponse`: `driver` must not reach `responseInit`.
1193
+ const { adapter, batch, debug, driver, ...responseInit } = options
1194
+ if (adapter.resumeFrom() === null) {
1195
+ return new Response(NO_RESUME_OFFSET, { status: 400 })
1196
+ }
1197
+ maybeStartRunDriver(driver)
1198
+ return toHttpResponse(emptyDurableSource(), {
1199
+ ...responseInit,
1200
+ durability: { adapter, batch },
1201
+ debug,
246
1202
  })
247
1203
  }