@tanstack/ai 0.42.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 +5 -36
  5. package/dist/esm/activities/chat/agent-loop-strategies.js +75 -21
  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 -21
  11. package/dist/esm/activities/chat/index.js +2100 -1813
  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 +24 -6
  134. package/dist/esm/index.js +30 -98
  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 +321 -42
  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 +98 -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 -61
  221. package/src/activities/chat/agent-loop-strategies.ts +5 -39
  222. package/src/activities/chat/cancel.ts +81 -0
  223. package/src/activities/chat/index.ts +1091 -200
  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 -1
  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 +405 -45
  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
@@ -11,6 +11,17 @@ import { stripToSpecMiddleware } from '../../strip-to-spec-middleware'
11
11
  import { streamToText } from '../../stream-to-response.js'
12
12
  import { resolveDebugOption } from '../../logger/resolve'
13
13
  import { EventType } from '../../types'
14
+ import {
15
+ INTERRUPT_BINDING_METADATA_KEY,
16
+ InterruptResumeValidationError,
17
+ readUnopenedInterruptBinding,
18
+ validateInterruptResumeBatch,
19
+ } from '../../interrupt-resume'
20
+ import { INTERRUPT_BINDING_VERSION } from '../../interrupts'
21
+ import {
22
+ canonicalInterruptJson,
23
+ digestInterruptJson,
24
+ } from '../../interrupt-serialization'
14
25
  import { normalizeToolResult } from '../../utilities/tool-result'
15
26
  import { isProviderExecutedToolCall } from '../../utilities/provider-executed'
16
27
  import { LazyToolManager } from './tools/lazy-tool-manager'
@@ -25,18 +36,33 @@ import {
25
36
  isStandardSchema,
26
37
  parseWithStandardSchema,
27
38
  } from './tools/schema-converter'
39
+ import {
40
+ hashSchemaInput,
41
+ normalizeApprovalSchema,
42
+ } from './tools/approval-schema'
28
43
  import { maxIterations as maxIterationsStrategy } from './agent-loop-strategies'
44
+ import { isCancelRequestedReason } from './cancel'
29
45
  import { convertMessagesToModelMessages, generateMessageId } from './messages'
30
46
  import { MiddlewareRunner } from './middleware/compose'
47
+ import { getRunDetached } from './middleware/run-store'
48
+ import { publishRunDetachedSignal } from '../../delivery-detach'
49
+ import { publishRunDisconnectHandler } from '../../delivery-disconnect'
31
50
  import { provideSandboxRuntime } from './middleware/sandbox-runtime'
51
+ import { provideRunDisconnect } from './middleware/run-disconnect'
32
52
  import { CapabilityRegistry } from './middleware/capabilities'
33
53
  import { validateCapabilities } from './middleware/validate'
34
54
  import { MCPManager } from './mcp/manager'
55
+ import type {
56
+ InterruptBinding,
57
+ InterruptSubmissionError,
58
+ ToolApprovalResolution,
59
+ } from '../../interrupts'
35
60
  import type {
36
61
  ApprovalRequest,
37
62
  ClientToolRequest,
38
63
  ToolResult,
39
64
  } from './tools/tool-calls'
65
+ import type { ApprovalSchemaConfig } from './tools/tool-definition'
40
66
  import type {
41
67
  AnyTextAdapter,
42
68
  StructuredOutputOptions,
@@ -45,13 +71,15 @@ import type {
45
71
  import type {
46
72
  AgentLoopStrategy,
47
73
  AnyTool,
48
- ChatStream,
49
74
  ConstrainedModelMessage,
50
75
  CustomEvent,
51
76
  InferSchemaType,
77
+ Interrupt,
52
78
  JSONSchema,
53
79
  LazyToolsConfig,
80
+ MessagesSnapshotEvent,
54
81
  ModelMessage,
82
+ ProviderTool,
55
83
  RunFinishedEvent,
56
84
  SchemaInput,
57
85
  StreamChunk,
@@ -63,6 +91,7 @@ import type {
63
91
  ToolCallArgsEvent,
64
92
  ToolCallEndEvent,
65
93
  ToolCallStartEvent,
94
+ TypedStreamChunk,
66
95
  UIMessage,
67
96
  } from '../../types'
68
97
  import type {
@@ -70,6 +99,7 @@ import type {
70
99
  ChatMiddleware,
71
100
  ChatMiddlewareConfig,
72
101
  ChatMiddlewareContext,
102
+ ChatResumeToolState,
73
103
  SandboxFileHookEvent,
74
104
  StructuredOutputMiddlewareConfig,
75
105
  } from './middleware/types'
@@ -77,7 +107,6 @@ import type { CheckCoverage } from './middleware/builder'
77
107
  import type { SystemPrompt } from '../../system-prompts'
78
108
  import type { InternalLogger } from '../../logger/internal-logger'
79
109
  import type { DebugOption } from '../../logger/types'
80
- import type { ProviderTool } from '../../tools/provider-tool'
81
110
  import type {
82
111
  ContextFromMiddleware,
83
112
  ContextFromTool,
@@ -95,6 +124,150 @@ import type { ChatMCPOptions } from './mcp/types'
95
124
  export const kind = 'text' as const
96
125
 
97
126
  type AnyRuntimeTool = AnyTool
127
+ type RuntimeToolWithApproval = AnyRuntimeTool & {
128
+ approvalSchema?: ApprovalSchemaConfig
129
+ }
130
+ const interruptBindingMetadataKey = INTERRUPT_BINDING_METADATA_KEY
131
+
132
+ interface StructuralInterruptFailure {
133
+ error: Error
134
+ errors: ReadonlyArray<InterruptSubmissionError>
135
+ }
136
+
137
+ function isInterruptSubmissionError(
138
+ value: unknown,
139
+ ): value is InterruptSubmissionError {
140
+ if (value === null || typeof value !== 'object' || Array.isArray(value)) {
141
+ return false
142
+ }
143
+ if (
144
+ !('scope' in value) ||
145
+ !('code' in value) ||
146
+ !('message' in value) ||
147
+ !('source' in value) ||
148
+ !('retryable' in value) ||
149
+ !('threadId' in value) ||
150
+ !('interruptedRunId' in value) ||
151
+ !('generation' in value) ||
152
+ typeof value.code !== 'string' ||
153
+ typeof value.message !== 'string' ||
154
+ typeof value.retryable !== 'boolean' ||
155
+ typeof value.threadId !== 'string' ||
156
+ typeof value.interruptedRunId !== 'string' ||
157
+ typeof value.generation !== 'number'
158
+ ) {
159
+ return false
160
+ }
161
+ if (value.scope === 'item') {
162
+ return (
163
+ 'interruptId' in value &&
164
+ typeof value.interruptId === 'string' &&
165
+ (value.source === 'client' || value.source === 'server')
166
+ )
167
+ }
168
+ return (
169
+ value.scope === 'batch' &&
170
+ 'interruptIds' in value &&
171
+ Array.isArray(value.interruptIds) &&
172
+ value.interruptIds.every((id) => typeof id === 'string') &&
173
+ (value.source === 'client' ||
174
+ value.source === 'server' ||
175
+ value.source === 'transport')
176
+ )
177
+ }
178
+
179
+ function structuralInterruptFailure(
180
+ error: unknown,
181
+ ): StructuralInterruptFailure | undefined {
182
+ if (
183
+ !(error instanceof Error) ||
184
+ error.name !== 'InterruptResumeValidationError' ||
185
+ !('errors' in error) ||
186
+ !Array.isArray(error.errors) ||
187
+ error.errors.length === 0 ||
188
+ !error.errors.every(isInterruptSubmissionError)
189
+ ) {
190
+ return undefined
191
+ }
192
+ return {
193
+ error,
194
+ errors: error.errors,
195
+ }
196
+ }
197
+
198
+ function normalizePublicInterruptBinding(
199
+ value: unknown,
200
+ expectedInterruptId: string,
201
+ ): InterruptBinding | undefined {
202
+ if (value === null || typeof value !== 'object' || Array.isArray(value)) {
203
+ return undefined
204
+ }
205
+ const binding: Record<string, unknown> = Object.fromEntries(
206
+ Object.entries(value),
207
+ )
208
+ if (
209
+ binding.interruptId !== expectedInterruptId ||
210
+ // A binding version we don't recognise belongs to another producer. Drop
211
+ // it rather than reading our fields out of it.
212
+ (binding.v !== undefined && binding.v !== INTERRUPT_BINDING_VERSION) ||
213
+ typeof binding.interruptedRunId !== 'string' ||
214
+ typeof binding.generation !== 'number' ||
215
+ !Number.isInteger(binding.generation) ||
216
+ binding.generation < 0 ||
217
+ typeof binding.responseSchemaHash !== 'string' ||
218
+ (binding.expiresAt !== undefined && typeof binding.expiresAt !== 'string')
219
+ ) {
220
+ return undefined
221
+ }
222
+ const base = {
223
+ v: INTERRUPT_BINDING_VERSION,
224
+ interruptId: binding.interruptId,
225
+ interruptedRunId: binding.interruptedRunId,
226
+ generation: binding.generation,
227
+ responseSchemaHash: binding.responseSchemaHash,
228
+ ...(typeof binding.expiresAt === 'string'
229
+ ? { expiresAt: binding.expiresAt }
230
+ : {}),
231
+ }
232
+ if (binding.kind === 'generic') {
233
+ return { kind: binding.kind, ...base }
234
+ }
235
+ if (
236
+ typeof binding.toolName !== 'string' ||
237
+ typeof binding.toolCallId !== 'string'
238
+ ) {
239
+ return undefined
240
+ }
241
+ if (
242
+ binding.kind === 'client-tool-execution' &&
243
+ typeof binding.outputSchemaHash === 'string'
244
+ ) {
245
+ return {
246
+ kind: binding.kind,
247
+ ...base,
248
+ toolName: binding.toolName,
249
+ toolCallId: binding.toolCallId,
250
+ outputSchemaHash: binding.outputSchemaHash,
251
+ }
252
+ }
253
+ if (
254
+ binding.kind === 'tool-approval' &&
255
+ Object.prototype.hasOwnProperty.call(binding, 'originalArgs') &&
256
+ typeof binding.inputSchemaHash === 'string' &&
257
+ typeof binding.approvalSchemaHash === 'string'
258
+ ) {
259
+ return {
260
+ kind: binding.kind,
261
+ ...base,
262
+ toolName: binding.toolName,
263
+ toolCallId: binding.toolCallId,
264
+ originalArgs: binding.originalArgs,
265
+ inputSchemaHash: binding.inputSchemaHash,
266
+ approvalSchemaHash: binding.approvalSchemaHash,
267
+ }
268
+ }
269
+ return undefined
270
+ }
98
271
 
99
272
  // The leaf context-inference primitives (KnownContext, MergeContext,
100
273
  // UnionToIntersection, DefinedContext, ContextFromTool, ContextFromMiddleware)
@@ -171,6 +344,7 @@ type TextActivityOptionsWithContext<
171
344
  * @template TAdapter - The text adapter type (created by a provider function)
172
345
  * @template TSchema - Optional Standard Schema for structured output
173
346
  * @template TStream - Whether to stream the output (default: true)
347
+ * @template TContext - Runtime context value threaded to middleware hooks and server tools
174
348
  */
175
349
  export interface TextActivityOptions<
176
350
  TAdapter extends AnyTextAdapter,
@@ -178,7 +352,7 @@ export interface TextActivityOptions<
178
352
  TStream extends boolean,
179
353
  TContext = unknown,
180
354
  > {
181
- /** The text adapter to use (created by a provider function like openaiText('gpt-4o')) */
355
+ /** The text adapter to use (created by a provider function like openaiText('gpt-5.5')) */
182
356
  adapter: TAdapter
183
357
  /**
184
358
  * Conversation messages. Accepts:
@@ -221,7 +395,7 @@ export interface TextActivityOptions<
221
395
  * compile-time error on the array element.
222
396
  */
223
397
  tools?:
224
- | Array<
398
+ | ReadonlyArray<
225
399
  | (AnyRuntimeTool & { readonly '~toolKind'?: never })
226
400
  | ProviderTool<string, TAdapter['~types']['toolCapabilities'][number]>
227
401
  >
@@ -240,11 +414,6 @@ export interface TextActivityOptions<
240
414
  abortController?: TextOptions['abortController']
241
415
  /** Strategy for controlling the agent loop */
242
416
  agentLoopStrategy?: TextOptions['agentLoopStrategy']
243
- /**
244
- * Cap how many tool calls from a single model turn are executed.
245
- * Excess calls receive error results. See {@link TextOptions.maxToolCallsPerTurn}.
246
- */
247
- maxToolCallsPerTurn?: TextOptions['maxToolCallsPerTurn']
248
417
  /**
249
418
  * Optional configuration for lazy-tool discovery (tools marked `lazy: true`).
250
419
  * Tunes how much of each lazy tool's description appears in the discovery
@@ -259,6 +428,13 @@ export interface TextActivityOptions<
259
428
  runId?: TextOptions['runId']
260
429
  /** Parent run ID for AG-UI protocol nested run correlation. */
261
430
  parentRunId?: TextOptions['parentRunId']
431
+ /** Application state mirrored in a STATE_SNAPSHOT before an interrupt terminal. */
432
+ state?: TextOptions['state']
433
+ /**
434
+ * AG-UI interrupt resume responses. Persistence middleware validates these
435
+ * before accepting new input on a thread with pending interrupts.
436
+ */
437
+ resume?: TextOptions['resume']
262
438
  /**
263
439
  * Optional Standard Schema for structured output.
264
440
  * When provided, the activity will:
@@ -270,7 +446,7 @@ export interface TextActivityOptions<
270
446
  * @example
271
447
  * ```ts
272
448
  * const result = await chat({
273
- * adapter: openaiText('gpt-4o'),
449
+ * adapter: openaiText('gpt-5.5'),
274
450
  * messages: [{ role: 'user', content: 'Generate a person' }],
275
451
  * outputSchema: z.object({ name: z.string(), age: z.number() })
276
452
  * })
@@ -280,7 +456,7 @@ export interface TextActivityOptions<
280
456
  outputSchema?: TSchema
281
457
  /**
282
458
  * Whether to stream the text result.
283
- * When true (default), returns an AsyncIterable<StreamChunk> for streaming output.
459
+ * When true (default), returns an AsyncIterable<TypedStreamChunk<TTools>> for streaming output.
284
460
  * When false, returns a Promise<string> with the collected text content.
285
461
  *
286
462
  * Note: If outputSchema is provided, this option is ignored and the result
@@ -291,7 +467,7 @@ export interface TextActivityOptions<
291
467
  * @example Non-streaming text
292
468
  * ```ts
293
469
  * const text = await chat({
294
- * adapter: openaiText('gpt-4o'),
470
+ * adapter: openaiText('gpt-5.5'),
295
471
  * messages: [{ role: 'user', content: 'Hello!' }],
296
472
  * stream: false
297
473
  * })
@@ -306,7 +482,7 @@ export interface TextActivityOptions<
306
482
  * @example
307
483
  * ```ts
308
484
  * const stream = chat({
309
- * adapter: openaiText('gpt-4o'),
485
+ * adapter: openaiText('gpt-5.5'),
310
486
  * messages: [...],
311
487
  * middleware: [loggingMiddleware, redactionMiddleware],
312
488
  * })
@@ -372,12 +548,18 @@ export function createChatOptions<
372
548
  TTools,
373
549
  TMiddleware
374
550
  >,
375
- ): TextActivityOptions<
376
- TAdapter,
377
- TSchema,
378
- TStream,
379
- InferredContext<TTools, TMiddleware>
380
- > {
551
+ // Preserve the concrete `tools` tuple on the returned options (so a later
552
+ // `chat({ ...opts })` still narrows tool-call events to the tool names)
553
+ // while threading the inferred runtime context like the bare options type.
554
+ ): Omit<
555
+ TextActivityOptions<
556
+ TAdapter,
557
+ TSchema,
558
+ TStream,
559
+ InferredContext<TTools, TMiddleware>
560
+ >,
561
+ 'tools'
562
+ > & { tools?: TTools } {
381
563
  return options
382
564
  }
383
565
 
@@ -394,7 +576,10 @@ export function createChatOptions<
394
576
  * - If outputSchema is provided without explicit stream:true:
395
577
  * Promise<InferSchemaType<TSchema>>.
396
578
  * - If stream is explicitly false (no schema): Promise<string>.
397
- * - Otherwise (default): AsyncIterable<StreamChunk>.
579
+ * - Otherwise (default): AsyncIterable<TypedStreamChunk<TTools>>.
580
+ *
581
+ * When tools with typed schemas are provided, the stream chunks include
582
+ * type-safe `toolName` and `input` fields on tool call events.
398
583
  *
399
584
  * `[TStream] extends [true]` is used (not `TStream extends true`) so that the
400
585
  * default `boolean` value of `TStream` does *not* match the streaming branch.
@@ -404,13 +589,23 @@ export function createChatOptions<
404
589
  export type TextActivityResult<
405
590
  TSchema extends SchemaInput | undefined,
406
591
  TStream extends boolean = boolean,
592
+ // Unconstrained so `chat()` can forward its inferred `options['tools']` type
593
+ // (which may be `undefined` or the broad `AnyRuntimeTool | ProviderTool`
594
+ // array) directly; non-tool-array inputs normalize to the default below.
595
+ TTools = ReadonlyArray<AnyTool>,
407
596
  > = TSchema extends SchemaInput
408
597
  ? [TStream] extends [true]
409
598
  ? StructuredOutputStream<InferSchemaType<TSchema>>
410
599
  : Promise<InferSchemaType<TSchema>>
411
600
  : [TStream] extends [false]
412
601
  ? Promise<string>
413
- : ChatStream
602
+ : AsyncIterable<
603
+ TypedStreamChunk<
604
+ TTools extends ReadonlyArray<AnyTool>
605
+ ? TTools
606
+ : ReadonlyArray<AnyTool>
607
+ >
608
+ >
414
609
 
415
610
  // ===========================
416
611
  // ChatEngine Implementation
@@ -478,23 +673,6 @@ interface TextEngineConfig<
478
673
  type ToolPhaseResult = 'continue' | 'stop' | 'wait'
479
674
  type CyclePhase = 'processText' | 'executeToolCalls'
480
675
 
481
- /**
482
- * Validate and normalize `maxToolCallsPerTurn`.
483
- * Unset → unlimited. `0` → execute none. Negatives / non-finite → throw
484
- * (Array#slice treats negatives as "from end", which is not a useful cap).
485
- */
486
- function resolveMaxToolCallsPerTurn(
487
- cap: number | undefined,
488
- ): number | undefined {
489
- if (cap == null) return undefined
490
- if (!Number.isFinite(cap) || cap < 0) {
491
- throw new Error(
492
- `maxToolCallsPerTurn must be a non-negative finite number, got ${cap}`,
493
- )
494
- }
495
- return Math.floor(cap)
496
- }
497
-
498
676
  /**
499
677
  * Combine two optional AbortSignals into one that aborts when either does.
500
678
  * Returns the other signal directly when one is absent or already aborted.
@@ -559,13 +737,17 @@ class TextEngine<
559
737
  private eventOptions?: Record<string, unknown> | undefined
560
738
  private eventToolNames?: Array<string>
561
739
  private finishedEvent: RunFinishedEvent | null = null
740
+ private deferredToolCallRunFinishedChunks: Array<StreamChunk> = []
562
741
  private earlyTermination = false
563
742
  private toolPhase: ToolPhaseResult = 'continue'
564
743
  private cyclePhase: CyclePhase = 'processText'
565
- private readonly maxToolCallsPerTurn: number | undefined
566
744
  // Client state extracted from initial messages (before conversion to ModelMessage)
567
- private readonly initialApprovals: Map<string, boolean>
745
+ private readonly initialApprovals: Map<string, ToolApprovalResolution>
568
746
  private readonly initialClientToolResults: Map<string, any>
747
+ private readonly resumeApprovals = new Map<string, ToolApprovalResolution>()
748
+ private readonly resumeClientToolResults = new Map<string, any>()
749
+ private readonly resumeDeniedToolResults = new Map<string, unknown>()
750
+ private readonly resumeCancelledToolCallIds = new Set<string>()
569
751
 
570
752
  // AG-UI protocol IDs
571
753
  private readonly threadId: string
@@ -583,6 +765,14 @@ class TextEngine<
583
765
  // observe both cancellation sources via ctx.abortSignal.
584
766
  private readonly toolAbortSignal?: AbortSignal
585
767
  private terminalHookCalled = false
768
+ /**
769
+ * Latched the first time the delivery socket closes; see `notifyDisconnected`.
770
+ * Also read by `subscribe` so a listener registered AFTER the disconnect (a
771
+ * middleware whose `setup` was still running at the time — the common case) is
772
+ * called immediately rather than never.
773
+ */
774
+ private disconnected = false
775
+ private readonly disconnectListeners: Array<() => void | Promise<void>> = []
586
776
 
587
777
  private readonly logger: InternalLogger
588
778
 
@@ -628,9 +818,6 @@ class TextEngine<
628
818
  this.systemPrompts = config.params.systemPrompts || []
629
819
  this.loopStrategy =
630
820
  config.params.agentLoopStrategy || maxIterationsStrategy(5)
631
- this.maxToolCallsPerTurn = resolveMaxToolCallsPerTurn(
632
- config.params.maxToolCallsPerTurn,
633
- )
634
821
  this.initialMessageCount = config.params.messages.length
635
822
 
636
823
  // Extract client state (approvals, client tool results) from original messages BEFORE conversion
@@ -692,6 +879,7 @@ class TextEngine<
692
879
  requestId: this.requestId,
693
880
  streamId: this.streamId,
694
881
  runId: this.runIdOverride ?? this.requestId,
882
+ parentRunId: this.parentRunIdOverride,
695
883
  threadId: this.threadId,
696
884
  // Legacy alias kept on the ctx so middleware that reads
697
885
  // `ctx.conversationId` keeps working. Always equals `threadId`.
@@ -739,6 +927,22 @@ class TextEngine<
739
927
  provide: (capability, value) => capability[1](this.middlewareCtx, value),
740
928
  }
741
929
 
930
+ // Provide the internal RunDisconnect capability BEFORE `setup` runs, so a
931
+ // middleware can subscribe from inside its own `setup` — which is where the
932
+ // subscription has to happen, because `setup` is the long await the common
933
+ // disconnect lands in.
934
+ //
935
+ // `subscribe` calls back IMMEDIATELY when the socket has already closed. That
936
+ // ordering is load-bearing rather than defensive: a middleware whose `setup`
937
+ // was still running during the disconnect would otherwise register a listener
938
+ // for an event that has already been and gone, and silently never detach.
939
+ provideRunDisconnect(this.middlewareCtx, {
940
+ subscribe: (listener) => {
941
+ this.disconnectListeners.push(listener)
942
+ if (this.disconnected) this.runDisconnectListener(listener)
943
+ },
944
+ })
945
+
742
946
  // Provide the internal SandboxRuntime capability so harness adapters and
743
947
  // sandbox middleware can emit file events. The sink logs, fans the event
744
948
  // out through the middleware `onFile*` hooks (fire-and-forget), and queues
@@ -829,6 +1033,7 @@ class TextEngine<
829
1033
  initialConfig,
830
1034
  )
831
1035
  this.applyMiddlewareConfig(transformedConfig)
1036
+ await this.applyEphemeralInterruptResume(transformedConfig)
832
1037
 
833
1038
  // Run onStart (devtools middleware emits text:request:started and initial messages here)
834
1039
  await this.middlewareRunner.runOnStart(this.middlewareCtx)
@@ -882,7 +1087,7 @@ class TextEngine<
882
1087
  }
883
1088
 
884
1089
  this.endCycle()
885
- } while (this.shouldContinue())
1090
+ } while (await this.shouldContinue())
886
1091
  }
887
1092
 
888
1093
  this.logger.agentLoop('run finished', {
@@ -893,12 +1098,15 @@ class TextEngine<
893
1098
  // requested AND the run hasn't already errored/aborted, run it through
894
1099
  // the middleware pipeline. The terminal hook fires once at the very
895
1100
  // end (after finalization), not after the agent loop.
1101
+ // Actionable waits already emitted a RUN_FINISHED interrupt terminal, so
1102
+ // do not run finalization after `processToolCalls()` pauses the stream.
896
1103
  //
897
1104
  // Native combined mode takes a different path: the agent loop's final-
898
1105
  // turn text IS the schema-constrained JSON, so we harvest it from
899
1106
  // `accumulatedContent` instead of issuing a second provider call.
900
1107
  if (
901
1108
  this.finalStructuredOutput &&
1109
+ this.toolPhase !== 'wait' &&
902
1110
  !this.isCancelled() &&
903
1111
  !this.finalizationError
904
1112
  ) {
@@ -946,6 +1154,41 @@ class TextEngine<
946
1154
  }
947
1155
  }
948
1156
  } catch (error: unknown) {
1157
+ if (
1158
+ error instanceof Error &&
1159
+ error.name === 'InterruptReplaySignal' &&
1160
+ 'continuationRunId' in error &&
1161
+ typeof error.continuationRunId === 'string'
1162
+ ) {
1163
+ this.terminalHookCalled = true
1164
+ yield {
1165
+ type: EventType.RUN_FINISHED,
1166
+ timestamp: Date.now(),
1167
+ threadId: this.threadId,
1168
+ runId: this.runIdOverride ?? this.requestId,
1169
+ finishReason: 'stop',
1170
+ outcome: { type: 'success' },
1171
+ result: {
1172
+ replayed: true,
1173
+ continuationRunId: error.continuationRunId,
1174
+ },
1175
+ }
1176
+ return
1177
+ }
1178
+ const interruptFailure = structuralInterruptFailure(error)
1179
+ if (interruptFailure) {
1180
+ this.terminalHookCalled = true
1181
+ this.logger.errors('chat interrupt resume failed', {
1182
+ error,
1183
+ threadId: this.middlewareCtx.threadId,
1184
+ })
1185
+ await this.middlewareRunner.runOnError(this.middlewareCtx, {
1186
+ error: interruptFailure.error,
1187
+ duration: Date.now() - this.streamStartTime,
1188
+ })
1189
+ yield this.buildInterruptRunErrorChunk(error)
1190
+ return
1191
+ }
949
1192
  if (!this.terminalHookCalled) {
950
1193
  this.terminalHookCalled = true
951
1194
  if (error instanceof MiddlewareAbortError) {
@@ -954,6 +1197,7 @@ class TextEngine<
954
1197
  await this.middlewareRunner.runOnAbort(this.middlewareCtx, {
955
1198
  reason: error.message,
956
1199
  duration: Date.now() - this.streamStartTime,
1200
+ cancelRequested: isCancelRequestedReason(error.message),
957
1201
  })
958
1202
  } else {
959
1203
  // Genuine error — call onError
@@ -975,9 +1219,11 @@ class TextEngine<
975
1219
  // Check for abort terminal hook
976
1220
  if (!this.terminalHookCalled && this.isCancelled()) {
977
1221
  this.terminalHookCalled = true
1222
+ const reason = this.resolveAbortReason()
978
1223
  await this.middlewareRunner.runOnAbort(this.middlewareCtx, {
979
- reason: this.abortReason,
1224
+ reason,
980
1225
  duration: Date.now() - this.streamStartTime,
1226
+ cancelRequested: isCancelRequestedReason(reason),
981
1227
  })
982
1228
  }
983
1229
 
@@ -1080,6 +1326,15 @@ class TextEngine<
1080
1326
  ? this.finalStructuredOutput.jsonSchema
1081
1327
  : undefined
1082
1328
 
1329
+ const { approvals } = this.collectClientState()
1330
+ const adapterApprovals = new Map<string, boolean>()
1331
+ for (const [approvalId, resolution] of approvals) {
1332
+ adapterApprovals.set(
1333
+ approvalId,
1334
+ typeof resolution === 'boolean' ? resolution : resolution.approved,
1335
+ )
1336
+ }
1337
+
1083
1338
  for await (const chunk of this.adapter.chatStream({
1084
1339
  model: this.params.model,
1085
1340
  messages: this.messages,
@@ -1095,7 +1350,7 @@ class TextEngine<
1095
1350
  // Expose provided capabilities (e.g. sandbox) to harness adapters.
1096
1351
  capabilities: this.middlewareCtx,
1097
1352
  // Client approval decisions, for harness interactive-approval resolution.
1098
- approvals: this.initialApprovals,
1353
+ approvals: adapterApprovals,
1099
1354
  ...(combinedSchema ? { outputSchema: combinedSchema } : {}),
1100
1355
  })) {
1101
1356
  if (this.isCancelled()) {
@@ -1170,6 +1425,10 @@ class TextEngine<
1170
1425
  ) {
1171
1426
  continue
1172
1427
  }
1428
+ if (this.shouldDeferToolCallRunFinished(outputChunk)) {
1429
+ this.deferredToolCallRunFinishedChunks.push(outputChunk)
1430
+ continue
1431
+ }
1173
1432
  this.logger.output(`type=${outputChunk.type}`, { chunk: outputChunk })
1174
1433
  yield outputChunk
1175
1434
  this.middlewareCtx.chunkIndex++
@@ -1334,14 +1593,13 @@ class TextEngine<
1334
1593
 
1335
1594
  const finishEvent = this.createSyntheticFinishedEvent()
1336
1595
 
1337
- // Same fan-out budget as live model turns (seeded history / resume).
1338
1596
  // Count is deduped so wait→resume after a live turn does not double-count.
1339
- const { toExecute: budgetedToolCalls, skippedResults } =
1340
- this.applyToolCallBudget(pendingToolCalls)
1597
+ // Per-turn execution caps are app middleware via onBeforeToolCall skip.
1598
+ this.recordToolCalls(pendingToolCalls)
1341
1599
 
1342
1600
  // Handle undiscovered lazy tool calls with self-correcting error messages
1343
1601
  const undiscoveredLazyResults: Array<ToolResult> = []
1344
- const executablePendingCalls = budgetedToolCalls.filter((tc) => {
1602
+ const executablePendingCalls = pendingToolCalls.filter((tc) => {
1345
1603
  if (this.lazyToolManager.isUndiscoveredLazyTool(tc.function.name)) {
1346
1604
  undiscoveredLazyResults.push({
1347
1605
  toolCallId: tc.id,
@@ -1358,9 +1616,10 @@ class TextEngine<
1358
1616
  return true
1359
1617
  })
1360
1618
 
1361
- // Non-executed outcomes (undiscovered lazy + per-turn fan-out skips).
1362
- // Emitted after executed results so the stream prefers real results first.
1363
- const deferredErrorResults = [...undiscoveredLazyResults, ...skippedResults]
1619
+ // Non-executed outcomes (undiscovered lazy). Emitted after executed
1620
+ // results so the stream prefers real results first. Per-turn skips are
1621
+ // produced by middleware via onBeforeToolCall and appear in execution results.
1622
+ const deferredErrorResults = [...undiscoveredLazyResults]
1364
1623
 
1365
1624
  // Build args lookup so buildToolResultChunks can emit TOOL_CALL_START +
1366
1625
  // TOOL_CALL_ARGS before TOOL_CALL_END during continuation re-executions.
@@ -1421,6 +1680,10 @@ class TextEngine<
1421
1680
  },
1422
1681
  this.middlewareCtx.context,
1423
1682
  this.toolAbortSignal,
1683
+ {
1684
+ deniedToolResults: this.resumeDeniedToolResults,
1685
+ cancelledToolCallIds: this.resumeCancelledToolCallIds,
1686
+ },
1424
1687
  )
1425
1688
 
1426
1689
  // Consume the async generator, yielding custom events and collecting the return value
@@ -1446,39 +1709,27 @@ class TextEngine<
1446
1709
  executionResult.needsApproval.length > 0 ||
1447
1710
  executionResult.needsClientExecution.length > 0
1448
1711
  ) {
1712
+ this.discardDeferredToolCallRunFinishedChunks()
1713
+
1449
1714
  if (allResults.length > 0) {
1450
1715
  for (const chunk of this.buildToolResultChunks(
1451
1716
  allResults,
1452
1717
  finishEvent,
1453
- argsMap,
1454
1718
  )) {
1455
1719
  yield* this.pipeThroughMiddleware(chunk)
1456
1720
  }
1457
1721
  }
1458
1722
 
1459
- for (const chunk of this.buildApprovalChunks(
1460
- executionResult.needsApproval,
1723
+ const emitted = yield* this.emitActionableInterruptBoundary(
1461
1724
  finishEvent,
1462
- )) {
1463
- yield* this.pipeThroughMiddleware(chunk)
1464
- }
1465
-
1466
- for (const chunk of this.buildClientToolChunks(
1725
+ executionResult.needsApproval,
1467
1726
  executionResult.needsClientExecution,
1468
- finishEvent,
1469
- )) {
1470
- yield* this.pipeThroughMiddleware(chunk)
1471
- }
1472
-
1473
- this.setToolPhase('wait')
1474
- return 'wait'
1727
+ )
1728
+ this.setToolPhase(emitted ? 'wait' : 'stop')
1729
+ return emitted ? 'wait' : 'stop'
1475
1730
  }
1476
1731
 
1477
- const toolResultChunks = this.buildToolResultChunks(
1478
- allResults,
1479
- finishEvent,
1480
- argsMap,
1481
- )
1732
+ const toolResultChunks = this.buildToolResultChunks(allResults, finishEvent)
1482
1733
 
1483
1734
  for (const chunk of toolResultChunks) {
1484
1735
  yield* this.pipeThroughMiddleware(chunk)
@@ -1504,15 +1755,15 @@ class TextEngine<
1504
1755
  return
1505
1756
  }
1506
1757
 
1507
- // Count every model-emitted tool call (including ones we may skip).
1508
- const { toExecute: budgetedToolCalls, skippedResults } =
1509
- this.applyToolCallBudget(toolCalls)
1758
+ // Count every model-emitted tool call. Per-turn execution caps are app
1759
+ // middleware via onBeforeToolCall skip.
1760
+ this.recordToolCalls(toolCalls)
1510
1761
 
1511
1762
  this.addAssistantToolCallMessage(toolCalls)
1512
1763
 
1513
1764
  // Handle undiscovered lazy tool calls with self-correcting error messages
1514
1765
  const undiscoveredLazyResults: Array<ToolResult> = []
1515
- const executableToolCalls = budgetedToolCalls.filter((tc) => {
1766
+ const executableToolCalls = toolCalls.filter((tc) => {
1516
1767
  if (this.lazyToolManager.isUndiscoveredLazyTool(tc.function.name)) {
1517
1768
  undiscoveredLazyResults.push({
1518
1769
  toolCallId: tc.id,
@@ -1529,13 +1780,14 @@ class TextEngine<
1529
1780
  return true
1530
1781
  })
1531
1782
 
1532
- // Non-executed outcomes (undiscovered lazy + per-turn fan-out skips).
1533
- // Emitted after executed results so the stream prefers real results first.
1534
- const deferredErrorResults = [...undiscoveredLazyResults, ...skippedResults]
1783
+ // Non-executed outcomes (undiscovered lazy). Per-turn skips come from
1784
+ // middleware and appear in execution results.
1785
+ const deferredErrorResults = [...undiscoveredLazyResults]
1535
1786
 
1536
1787
  if (executableToolCalls.length === 0) {
1537
- // All tool calls were undiscovered lazy tools and/or skipped by the
1538
- // per-turn fan-out cap — errors emitted, continue loop (strategy may stop).
1788
+ yield* this.flushDeferredToolCallRunFinishedChunks()
1789
+ // All tool calls were undiscovered lazy tools — errors emitted, continue
1790
+ // loop (strategy / onShouldContinue may stop).
1539
1791
  if (deferredErrorResults.length > 0) {
1540
1792
  for (const chunk of this.buildToolResultChunks(
1541
1793
  deferredErrorResults,
@@ -1589,6 +1841,10 @@ class TextEngine<
1589
1841
  },
1590
1842
  this.middlewareCtx.context,
1591
1843
  this.toolAbortSignal,
1844
+ {
1845
+ deniedToolResults: this.resumeDeniedToolResults,
1846
+ cancelledToolCallIds: this.resumeCancelledToolCallIds,
1847
+ },
1592
1848
  )
1593
1849
 
1594
1850
  // Consume the async generator, yielding custom events and collecting the return value
@@ -1626,24 +1882,17 @@ class TextEngine<
1626
1882
  }
1627
1883
  }
1628
1884
 
1629
- for (const chunk of this.buildApprovalChunks(
1630
- executionResult.needsApproval,
1885
+ const emitted = yield* this.emitActionableInterruptBoundary(
1631
1886
  finishEvent,
1632
- )) {
1633
- yield* this.pipeThroughMiddleware(chunk)
1634
- }
1635
-
1636
- for (const chunk of this.buildClientToolChunks(
1887
+ executionResult.needsApproval,
1637
1888
  executionResult.needsClientExecution,
1638
- finishEvent,
1639
- )) {
1640
- yield* this.pipeThroughMiddleware(chunk)
1641
- }
1642
-
1643
- this.setToolPhase('wait')
1889
+ )
1890
+ this.setToolPhase(emitted ? 'wait' : 'stop')
1644
1891
  return
1645
1892
  }
1646
1893
 
1894
+ yield* this.flushDeferredToolCallRunFinishedChunks()
1895
+
1647
1896
  const toolResultChunks = this.buildToolResultChunks(allResults, finishEvent)
1648
1897
 
1649
1898
  for (const chunk of toolResultChunks) {
@@ -1666,6 +1915,28 @@ class TextEngine<
1666
1915
  this.setToolPhase('continue')
1667
1916
  }
1668
1917
 
1918
+ private shouldDeferToolCallRunFinished(chunk: StreamChunk): boolean {
1919
+ return (
1920
+ chunk.type === EventType.RUN_FINISHED &&
1921
+ this.finishedEvent?.finishReason === 'tool_calls' &&
1922
+ this.tools.length > 0 &&
1923
+ this.toolCallManager.hasToolCalls()
1924
+ )
1925
+ }
1926
+
1927
+ private *flushDeferredToolCallRunFinishedChunks(): Generator<StreamChunk> {
1928
+ for (const chunk of this.deferredToolCallRunFinishedChunks) {
1929
+ this.logger.output(`type=${chunk.type}`, { chunk })
1930
+ yield chunk
1931
+ this.middlewareCtx.chunkIndex++
1932
+ }
1933
+ this.deferredToolCallRunFinishedChunks = []
1934
+ }
1935
+
1936
+ private discardDeferredToolCallRunFinishedChunks(): void {
1937
+ this.deferredToolCallRunFinishedChunks = []
1938
+ }
1939
+
1669
1940
  private shouldExecuteToolPhase(): boolean {
1670
1941
  return (
1671
1942
  this.finishedEvent?.finishReason === 'tool_calls' &&
@@ -1688,6 +1959,7 @@ class TextEngine<
1688
1959
  }),
1689
1960
  },
1690
1961
  ]
1962
+ this.middlewareCtx.messages = this.messages
1691
1963
  }
1692
1964
 
1693
1965
  /**
@@ -1698,10 +1970,10 @@ class TextEngine<
1698
1970
  private extractClientStateFromOriginalMessages(
1699
1971
  originalMessages: Array<any>,
1700
1972
  ): {
1701
- approvals: Map<string, boolean>
1973
+ approvals: Map<string, ToolApprovalResolution>
1702
1974
  clientToolResults: Map<string, any>
1703
1975
  } {
1704
- const approvals = new Map<string, boolean>()
1976
+ const approvals = new Map<string, ToolApprovalResolution>()
1705
1977
  const clientToolResults = new Map<string, any>()
1706
1978
 
1707
1979
  for (const message of originalMessages) {
@@ -1730,12 +2002,18 @@ class TextEngine<
1730
2002
  }
1731
2003
 
1732
2004
  private collectClientState(): {
1733
- approvals: Map<string, boolean>
2005
+ approvals: Map<string, ToolApprovalResolution>
1734
2006
  clientToolResults: Map<string, any>
1735
2007
  } {
1736
2008
  // Start with the initial client state extracted from original messages
1737
2009
  const approvals = new Map(this.initialApprovals)
1738
2010
  const clientToolResults = new Map(this.initialClientToolResults)
2011
+ for (const [approvalId, approved] of this.resumeApprovals) {
2012
+ approvals.set(approvalId, approved)
2013
+ }
2014
+ for (const [toolCallId, result] of this.resumeClientToolResults) {
2015
+ clientToolResults.set(toolCallId, result)
2016
+ }
1739
2017
 
1740
2018
  // Also check current messages for any additional tool results (from server tools)
1741
2019
  for (const message of this.messages) {
@@ -1774,54 +2052,301 @@ class TextEngine<
1774
2052
  return { approvals, clientToolResults }
1775
2053
  }
1776
2054
 
1777
- private buildApprovalChunks(
2055
+ private buildActionableInterrupts(
1778
2056
  approvals: Array<ApprovalRequest>,
1779
- finishEvent: RunFinishedEvent,
1780
- ): Array<StreamChunk> {
1781
- const chunks: Array<StreamChunk> = []
2057
+ clientRequests: Array<ClientToolRequest>,
2058
+ ): Array<Interrupt> {
2059
+ const interrupts: Array<Interrupt> = []
1782
2060
 
1783
2061
  for (const approval of approvals) {
1784
- chunks.push({
1785
- type: 'CUSTOM',
1786
- timestamp: Date.now(),
1787
- model: finishEvent.model,
1788
- name: 'approval-requested',
1789
- value: {
1790
- toolCallId: approval.toolCallId,
2062
+ const tool = this.tools.find(
2063
+ (candidate) => candidate.name === approval.toolName,
2064
+ ) as RuntimeToolWithApproval | undefined
2065
+ const normalized = normalizeApprovalSchema(
2066
+ tool?.approvalSchema,
2067
+ tool?.inputSchema,
2068
+ )
2069
+ interrupts.push({
2070
+ id: approval.approvalId,
2071
+ // Display hint only. `reason` is free-form AG-UI text that another
2072
+ // producer can also spell `tool_call`, so it never decides ownership —
2073
+ // the binding in `metadata` does.
2074
+ reason: 'tool_call',
2075
+ message: `Approval required to run ${approval.toolName}`,
2076
+ toolCallId: approval.toolCallId,
2077
+ responseSchema: normalized.responseSchema,
2078
+ metadata: {
2079
+ kind: 'approval',
1791
2080
  toolName: approval.toolName,
1792
2081
  input: approval.input,
1793
- approval: {
1794
- id: approval.approvalId,
1795
- needsApproval: true,
2082
+ [interruptBindingMetadataKey]: {
2083
+ v: INTERRUPT_BINDING_VERSION,
2084
+ kind: 'tool-approval',
2085
+ interruptId: approval.approvalId,
2086
+ toolName: approval.toolName,
2087
+ toolCallId: approval.toolCallId,
2088
+ originalArgs: approval.input,
2089
+ inputSchemaHash: hashSchemaInput(tool?.inputSchema),
2090
+ approvalSchemaHash: normalized.approvalSchemaHash,
2091
+ responseSchemaHash: normalized.responseSchemaHash,
1796
2092
  },
1797
2093
  },
1798
- } as StreamChunk)
2094
+ })
1799
2095
  }
1800
2096
 
1801
- return chunks
2097
+ for (const clientTool of clientRequests) {
2098
+ const tool = this.tools.find(
2099
+ (candidate) => candidate.name === clientTool.toolName,
2100
+ )
2101
+ const responseSchema = convertSchemaToJsonSchema(tool?.outputSchema) ?? {}
2102
+ interrupts.push({
2103
+ id: `client_tool_${clientTool.toolCallId}`,
2104
+ reason: 'tanstack:client_tool_execution',
2105
+ message: `Client tool ${clientTool.toolName} is ready to run`,
2106
+ toolCallId: clientTool.toolCallId,
2107
+ responseSchema,
2108
+ metadata: {
2109
+ kind: 'client_tool',
2110
+ toolName: clientTool.toolName,
2111
+ input: clientTool.input,
2112
+ [interruptBindingMetadataKey]: {
2113
+ v: INTERRUPT_BINDING_VERSION,
2114
+ kind: 'client-tool-execution',
2115
+ interruptId: `client_tool_${clientTool.toolCallId}`,
2116
+ toolName: clientTool.toolName,
2117
+ toolCallId: clientTool.toolCallId,
2118
+ outputSchemaHash: hashSchemaInput(tool?.outputSchema),
2119
+ responseSchemaHash: digestInterruptJson(
2120
+ canonicalInterruptJson(responseSchema),
2121
+ ),
2122
+ },
2123
+ },
2124
+ })
2125
+ }
2126
+
2127
+ return interrupts
1802
2128
  }
1803
2129
 
1804
- private buildClientToolChunks(
2130
+ private buildInterruptFinishedChunk(
2131
+ finishEvent: RunFinishedEvent,
2132
+ approvals: Array<ApprovalRequest>,
1805
2133
  clientRequests: Array<ClientToolRequest>,
2134
+ ): StreamChunk {
2135
+ return {
2136
+ ...finishEvent,
2137
+ timestamp: Date.now(),
2138
+ outcome: {
2139
+ type: 'interrupt',
2140
+ interrupts: this.buildActionableInterrupts(approvals, clientRequests),
2141
+ },
2142
+ }
2143
+ }
2144
+
2145
+ private buildMessagesSnapshotChunk(): StreamChunk {
2146
+ const messages: MessagesSnapshotEvent['messages'] = this.messages.map(
2147
+ (message, index) => {
2148
+ const content =
2149
+ typeof message.content === 'string'
2150
+ ? message.content
2151
+ : message.content === null
2152
+ ? undefined
2153
+ : JSON.stringify(message.content)
2154
+ return {
2155
+ id: `snapshot_${this.runIdOverride ?? this.requestId}_${index}`,
2156
+ role: message.role,
2157
+ ...(content !== undefined ? { content } : {}),
2158
+ ...('toolCalls' in message && message.toolCalls
2159
+ ? { toolCalls: message.toolCalls }
2160
+ : {}),
2161
+ ...('toolCallId' in message && message.toolCallId
2162
+ ? { toolCallId: message.toolCallId }
2163
+ : {}),
2164
+ } as MessagesSnapshotEvent['messages'][number]
2165
+ },
2166
+ )
2167
+ return {
2168
+ type: EventType.MESSAGES_SNAPSHOT,
2169
+ timestamp: Date.now(),
2170
+ model: this.params.model,
2171
+ messages,
2172
+ }
2173
+ }
2174
+
2175
+ private publicInterruptTerminal(chunk: StreamChunk): StreamChunk {
2176
+ if (
2177
+ chunk.type !== EventType.RUN_FINISHED ||
2178
+ chunk.outcome?.type !== 'interrupt'
2179
+ ) {
2180
+ return chunk
2181
+ }
2182
+ return {
2183
+ ...chunk,
2184
+ outcome: {
2185
+ ...chunk.outcome,
2186
+ interrupts: chunk.outcome.interrupts.map((interrupt) => {
2187
+ if (
2188
+ !interrupt.metadata ||
2189
+ typeof interrupt.metadata !== 'object' ||
2190
+ Array.isArray(interrupt.metadata)
2191
+ ) {
2192
+ return interrupt
2193
+ }
2194
+ const metadata = { ...interrupt.metadata }
2195
+ const binding = normalizePublicInterruptBinding(
2196
+ metadata[interruptBindingMetadataKey],
2197
+ interrupt.id,
2198
+ )
2199
+ if (binding) {
2200
+ metadata[interruptBindingMetadataKey] = binding
2201
+ } else {
2202
+ delete metadata[interruptBindingMetadataKey]
2203
+ }
2204
+ return { ...interrupt, metadata }
2205
+ }),
2206
+ },
2207
+ }
2208
+ }
2209
+
2210
+ private interruptFailure(error: unknown): {
2211
+ message: string
2212
+ code: string
2213
+ errors?: ReadonlyArray<InterruptSubmissionError>
2214
+ } {
2215
+ const structured = structuralInterruptFailure(error)
2216
+ if (structured) {
2217
+ return {
2218
+ message: structured.error.message,
2219
+ code: structured.errors[0]?.code ?? 'server',
2220
+ errors: structured.errors,
2221
+ }
2222
+ }
2223
+ if (error && typeof error === 'object' && 'errors' in error) {
2224
+ const errors = error.errors
2225
+ if (Array.isArray(errors)) {
2226
+ const first = errors[0]
2227
+ if (first && typeof first === 'object') {
2228
+ const message =
2229
+ 'message' in first && typeof first.message === 'string'
2230
+ ? first.message
2231
+ : 'Interrupt persistence failed.'
2232
+ const code =
2233
+ 'code' in first && typeof first.code === 'string'
2234
+ ? first.code
2235
+ : 'server'
2236
+ return { message, code }
2237
+ }
2238
+ }
2239
+ }
2240
+ return {
2241
+ message:
2242
+ error instanceof Error
2243
+ ? error.message
2244
+ : 'Interrupt persistence failed.',
2245
+ code: 'server',
2246
+ }
2247
+ }
2248
+
2249
+ private buildInterruptRunErrorChunk(error: unknown): StreamChunk {
2250
+ const failure = this.interruptFailure(error)
2251
+ return {
2252
+ type: EventType.RUN_ERROR,
2253
+ timestamp: Date.now(),
2254
+ runId: this.runIdOverride ?? this.requestId,
2255
+ threadId: this.threadId,
2256
+ message: failure.message,
2257
+ code: failure.code,
2258
+ error: { message: failure.message, code: failure.code },
2259
+ ...(failure.errors !== undefined
2260
+ ? { 'tanstack:interruptErrors': failure.errors }
2261
+ : {}),
2262
+ }
2263
+ }
2264
+
2265
+ private async *emitInterruptRunError(
2266
+ error: unknown,
2267
+ ): AsyncGenerator<StreamChunk, void, void> {
2268
+ const failure = this.interruptFailure(error)
2269
+ this.finalizationError = {
2270
+ message: failure.message,
2271
+ code: failure.code,
2272
+ cause: error,
2273
+ }
2274
+ yield* this.pipeThroughMiddleware(this.buildInterruptRunErrorChunk(error))
2275
+ }
2276
+
2277
+ private async *emitActionableInterruptBoundary(
1806
2278
  finishEvent: RunFinishedEvent,
1807
- ): Array<StreamChunk> {
1808
- const chunks: Array<StreamChunk> = []
2279
+ approvals: Array<ApprovalRequest>,
2280
+ clientRequests: Array<ClientToolRequest>,
2281
+ ): AsyncGenerator<StreamChunk, boolean, void> {
2282
+ const terminal = this.completeEphemeralInterruptBindings(
2283
+ this.buildInterruptFinishedChunk(finishEvent, approvals, clientRequests),
2284
+ )
2285
+ let terminalOutputs: Array<StreamChunk>
2286
+ try {
2287
+ terminalOutputs = await this.middlewareRunner.runOnChunk(
2288
+ this.middlewareCtx,
2289
+ terminal,
2290
+ )
2291
+ } catch (error) {
2292
+ yield* this.emitInterruptRunError(error)
2293
+ return false
2294
+ }
1809
2295
 
1810
- for (const clientTool of clientRequests) {
1811
- chunks.push({
1812
- type: 'CUSTOM',
2296
+ yield* this.pipeThroughMiddleware(this.buildMessagesSnapshotChunk())
2297
+ if (this.params.state !== undefined) {
2298
+ yield* this.pipeThroughMiddleware({
2299
+ type: EventType.STATE_SNAPSHOT,
1813
2300
  timestamp: Date.now(),
1814
- model: finishEvent.model,
1815
- name: 'tool-input-available',
1816
- value: {
1817
- toolCallId: clientTool.toolCallId,
1818
- toolName: clientTool.toolName,
1819
- input: clientTool.input,
1820
- },
1821
- } as StreamChunk)
2301
+ model: this.params.model,
2302
+ snapshot: this.params.state,
2303
+ })
1822
2304
  }
2305
+ for (const output of terminalOutputs) {
2306
+ yield this.publicInterruptTerminal(output)
2307
+ this.middlewareCtx.chunkIndex++
2308
+ }
2309
+ return true
2310
+ }
1823
2311
 
1824
- return chunks
2312
+ private completeEphemeralInterruptBindings(chunk: StreamChunk): StreamChunk {
2313
+ if (
2314
+ chunk.type !== EventType.RUN_FINISHED ||
2315
+ chunk.outcome?.type !== 'interrupt'
2316
+ ) {
2317
+ return chunk
2318
+ }
2319
+ const interruptedRunId = this.runIdOverride ?? this.requestId
2320
+ return {
2321
+ ...chunk,
2322
+ outcome: {
2323
+ ...chunk.outcome,
2324
+ interrupts: chunk.outcome.interrupts.map((interrupt) => {
2325
+ if (
2326
+ !interrupt.metadata ||
2327
+ typeof interrupt.metadata !== 'object' ||
2328
+ Array.isArray(interrupt.metadata)
2329
+ ) {
2330
+ return interrupt
2331
+ }
2332
+ const metadata = { ...interrupt.metadata }
2333
+ const unopened = metadata[interruptBindingMetadataKey]
2334
+ if (
2335
+ unopened === null ||
2336
+ typeof unopened !== 'object' ||
2337
+ Array.isArray(unopened)
2338
+ ) {
2339
+ return interrupt
2340
+ }
2341
+ metadata[interruptBindingMetadataKey] = {
2342
+ ...unopened,
2343
+ interruptedRunId,
2344
+ generation: 0,
2345
+ }
2346
+ return { ...interrupt, metadata }
2347
+ }),
2348
+ },
2349
+ }
1825
2350
  }
1826
2351
 
1827
2352
  private buildToolResultChunks(
@@ -1845,6 +2370,7 @@ class TextEngine<
1845
2370
  // argsMap is set only on continuation re-executions, where the adapter
1846
2371
  // never streamed these calls. Otherwise it already emitted END, so a
1847
2372
  // second one here would be an orphan that fails verifyEvents (#519).
2373
+ // When we do emit END, attach parsed `input`/`output` for TypedStreamChunk.
1848
2374
  if (argsMap) {
1849
2375
  chunks.push({
1850
2376
  type: 'TOOL_CALL_START',
@@ -1853,7 +2379,7 @@ class TextEngine<
1853
2379
  toolCallId: result.toolCallId,
1854
2380
  toolCallName: result.toolName,
1855
2381
  toolName: result.toolName,
1856
- } as StreamChunk)
2382
+ })
1857
2383
 
1858
2384
  const args = argsMap.get(result.toolCallId) ?? '{}'
1859
2385
  chunks.push({
@@ -1873,8 +2399,10 @@ class TextEngine<
1873
2399
  toolCallName: result.toolName,
1874
2400
  toolName: result.toolName,
1875
2401
  result: wireContent,
2402
+ ...(result.input !== undefined && { input: result.input }),
2403
+ ...(result.output !== undefined && { output: result.output }),
1876
2404
  ...(result.state !== undefined && { state: result.state }),
1877
- } as StreamChunk)
2405
+ })
1878
2406
  }
1879
2407
 
1880
2408
  // AG-UI spec TOOL_CALL_RESULT event (content is string-only per spec)
@@ -1922,6 +2450,7 @@ class TextEngine<
1922
2450
  } else {
1923
2451
  this.messages = [...this.messages, newToolMessage]
1924
2452
  }
2453
+ this.middlewareCtx.messages = this.messages
1925
2454
  }
1926
2455
 
1927
2456
  return chunks
@@ -1976,6 +2505,54 @@ class TextEngine<
1976
2505
  return pending
1977
2506
  }
1978
2507
 
2508
+ /**
2509
+ * Find a tool call by id in message history (including already-completed ones).
2510
+ * Used when the client has already attached a tool result for UI before resume.
2511
+ */
2512
+ private findToolCallInMessages(toolCallId: string): ToolCall | undefined {
2513
+ for (const message of this.messages) {
2514
+ if (message.role !== 'assistant' || !message.toolCalls) continue
2515
+ for (const toolCall of message.toolCalls) {
2516
+ if (toolCall.id === toolCallId) return toolCall
2517
+ }
2518
+ }
2519
+ return undefined
2520
+ }
2521
+
2522
+ /**
2523
+ * Tool calls that must be reconstructed as interrupt pending for ephemeral
2524
+ * resume. Includes outstanding tools plus client tools that already have
2525
+ * results in history when the resume batch still carries `client_tool_*`
2526
+ * entries (the client writes local tool results before submitting resume).
2527
+ */
2528
+ private getToolCallsForEphemeralResume(
2529
+ resume: ReadonlyArray<{ interruptId: string }> | undefined,
2530
+ ): Array<ToolCall> {
2531
+ const pending = this.getPendingToolCallsFromMessages()
2532
+ const byId = new Map(pending.map((toolCall) => [toolCall.id, toolCall]))
2533
+ for (const entry of resume ?? []) {
2534
+ // Recover tool calls the client already finalized in history for two
2535
+ // resume-batch cases that no longer look "pending":
2536
+ // - `client_tool_*`: a client tool wrote its output before resuming.
2537
+ // - `approval_*`: a DENIED approval wrote its denial result, so the
2538
+ // call reads as completed. Without this it drops out of the
2539
+ // reconstructed batch and the resume entry fails as unknown-interrupt.
2540
+ let toolCallId: string | undefined
2541
+ if (entry.interruptId.startsWith('client_tool_')) {
2542
+ toolCallId = entry.interruptId.slice('client_tool_'.length)
2543
+ } else if (entry.interruptId.startsWith('approval_')) {
2544
+ toolCallId = entry.interruptId.slice('approval_'.length)
2545
+ }
2546
+ if (toolCallId === undefined || byId.has(toolCallId)) continue
2547
+ const toolCall = this.findToolCallInMessages(toolCallId)
2548
+ if (toolCall && !isProviderExecutedToolCall(toolCall)) {
2549
+ pending.push(toolCall)
2550
+ byId.set(toolCallId, toolCall)
2551
+ }
2552
+ }
2553
+ return pending
2554
+ }
2555
+
1979
2556
  private createSyntheticFinishedEvent(): RunFinishedEvent {
1980
2557
  return {
1981
2558
  type: 'RUN_FINISHED',
@@ -1987,35 +2564,44 @@ class TextEngine<
1987
2564
  } as RunFinishedEvent
1988
2565
  }
1989
2566
 
1990
- private shouldContinue(): boolean {
2567
+ private async shouldContinue(): Promise<boolean> {
2568
+ // Always enter the tool-execution half-cycle after a model turn.
1991
2569
  if (this.cyclePhase === 'executeToolCalls') {
1992
2570
  return true
1993
2571
  }
1994
2572
 
2573
+ const state = {
2574
+ iterationCount: this.iterationCount,
2575
+ messages: this.messages,
2576
+ finishReason: this.lastFinishReason,
2577
+ toolCallCount: this.toolCallCount,
2578
+ lastTurnToolCallCount: this.lastTurnToolCallCount,
2579
+ }
2580
+
2581
+ // Evaluate strategy and middleware unconditionally (even when the
2582
+ // strategy already says stop) so every onShouldContinue observer still
2583
+ // sees the final counters; AND all three at the end.
2584
+ const strategyContinues = this.loopStrategy(state)
2585
+ const middlewareContinues = await this.middlewareRunner.runOnShouldContinue(
2586
+ this.middlewareCtx,
2587
+ state,
2588
+ )
2589
+
1995
2590
  return (
1996
- this.loopStrategy({
1997
- iterationCount: this.iterationCount,
1998
- messages: this.messages,
1999
- finishReason: this.lastFinishReason,
2000
- toolCallCount: this.toolCallCount,
2001
- lastTurnToolCallCount: this.lastTurnToolCallCount,
2002
- }) && this.toolPhase === 'continue'
2591
+ strategyContinues && middlewareContinues && this.toolPhase === 'continue'
2003
2592
  )
2004
2593
  }
2005
2594
 
2006
2595
  /**
2007
- * Record tool calls (deduped by id) and return the subset that should be
2008
- * executed after applying `maxToolCallsPerTurn`. Excess calls get synthetic
2009
- * error results so every tool_call still has a matching result.
2596
+ * Record tool calls (deduped by id) toward `toolCallCount` /
2597
+ * `lastTurnToolCallCount` for strategies and middleware `onShouldContinue`.
2010
2598
  *
2011
2599
  * Used for both live model turns and pending/resume batches. IDs already
2012
2600
  * counted in this run (e.g. wait→resume after a live turn) are not
2013
- * re-added to `toolCallCount`.
2601
+ * re-added to `toolCallCount`. Per-turn execution caps are app middleware
2602
+ * (`onBeforeToolCall` skip), not engine policy.
2014
2603
  */
2015
- private applyToolCallBudget(toolCalls: Array<ToolCall>): {
2016
- toExecute: Array<ToolCall>
2017
- skippedResults: Array<ToolResult>
2018
- } {
2604
+ private recordToolCalls(toolCalls: Array<ToolCall>): void {
2019
2605
  this.lastTurnToolCallCount = toolCalls.length
2020
2606
  let newlyCounted = 0
2021
2607
  for (const tc of toolCalls) {
@@ -2025,34 +2611,6 @@ class TextEngine<
2025
2611
  }
2026
2612
  }
2027
2613
  this.toolCallCount += newlyCounted
2028
-
2029
- const cap = this.maxToolCallsPerTurn
2030
- if (cap == null || toolCalls.length <= cap) {
2031
- return { toExecute: toolCalls, skippedResults: [] }
2032
- }
2033
-
2034
- this.logger.agentLoop(
2035
- `maxToolCallsPerTurn=${cap} skipped=${toolCalls.length - cap}`,
2036
- {
2037
- maxToolCallsPerTurn: cap,
2038
- emitted: toolCalls.length,
2039
- skipped: toolCalls.length - cap,
2040
- },
2041
- )
2042
-
2043
- const toExecute = toolCalls.slice(0, cap)
2044
- const skippedResults: Array<ToolResult> = toolCalls
2045
- .slice(cap)
2046
- .map((tc) => ({
2047
- toolCallId: tc.id,
2048
- toolName: tc.function.name,
2049
- result: {
2050
- error: `Skipped: exceeded maxToolCallsPerTurn (${cap})`,
2051
- },
2052
- state: 'output-error' as const,
2053
- }))
2054
-
2055
- return { toExecute, skippedResults }
2056
2614
  }
2057
2615
 
2058
2616
  private isAborted(): boolean {
@@ -2067,6 +2625,100 @@ class TextEngine<
2067
2625
  return this.isAborted() || this.isMiddlewareAborted()
2068
2626
  }
2069
2627
 
2628
+ /**
2629
+ * The reason to report on `AbortInfo` for a cancelled run.
2630
+ *
2631
+ * `this.abortReason` only ever holds a *middleware*-initiated reason
2632
+ * (`ctx.abort(reason)` / `MiddlewareAbortError`). A caller that aborts its own
2633
+ * controller — `abortController.abort(RUN_CANCEL_REASON)`, the in-process
2634
+ * cancel channel — never touches that field, so the reason has to be read back
2635
+ * off the caller's signal, which is the signal `isCancelled()` consults via
2636
+ * `isAborted()`. A signal aborted with no reason carries a DOMException rather
2637
+ * than a string, so non-string reasons are reported as absent.
2638
+ */
2639
+ private resolveAbortReason(): string | undefined {
2640
+ if (this.abortReason !== undefined) return this.abortReason
2641
+ const signalReason: unknown = this.effectiveSignal?.reason
2642
+ return typeof signalReason === 'string' ? signalReason : undefined
2643
+ }
2644
+
2645
+ /**
2646
+ * Whether this run's teardown declared its abort a DETACH — see
2647
+ * {@link RunDetachedCapability}. Only `withSandbox`'s `onAbort` publishes it,
2648
+ * and only for a plain, intentless disconnect of a detachable run, so every
2649
+ * other exit path answers `false`.
2650
+ *
2651
+ * Surfaced on the engine (rather than the ctx being handed out) so the
2652
+ * capability read stays inside core, and so the delivery sink learns the
2653
+ * verdict through {@link publishRunDetachedSignal} instead of reaching into a
2654
+ * middleware context it has no business holding.
2655
+ *
2656
+ * @internal
2657
+ */
2658
+ wasDetached(): boolean {
2659
+ return getRunDetached(this.middlewareCtx, { optional: true }) === true
2660
+ }
2661
+
2662
+ /**
2663
+ * The delivery socket closed while this run was still going.
2664
+ *
2665
+ * Notifies every subscriber (see {@link RunDisconnectCapability}) and RETURNS
2666
+ * IMMEDIATELY. Synchronous on purpose: it is called from
2667
+ * `ReadableStream.cancel()`, which must not be made to wait on a run-store
2668
+ * write, and the caller ({@link notifyRunDisconnected}) has no consumer left to
2669
+ * report to anyway.
2670
+ *
2671
+ * Subscribers therefore run CONCURRENTLY with the still-executing run — which is
2672
+ * the entire point. The run is typically suspended inside a slow middleware
2673
+ * `setup` at this moment, so anything dispatched from the run's own unwinding
2674
+ * would be minutes late. Nothing on this path aborts the run: a durable run
2675
+ * outlives its viewer.
2676
+ *
2677
+ * Each subscriber's promise is parked on `deferredPromises`, which the run awaits
2678
+ * in its `finally`, so bookkeeping cannot be lost to a race with the run's own
2679
+ * completion even though nothing awaits it here.
2680
+ *
2681
+ * IDEMPOTENT. A second cancel, or one arriving after a terminal hook already ran,
2682
+ * is ignored: the terminal hooks own the run's outcome, and re-stamping
2683
+ * `detachedSince` on a run that has already finished would hand a completed run
2684
+ * to the reaper as reclaimable work.
2685
+ *
2686
+ * @internal
2687
+ */
2688
+ notifyDisconnected(): void {
2689
+ if (this.disconnected || this.terminalHookCalled) return
2690
+ this.disconnected = true
2691
+ for (const listener of this.disconnectListeners) {
2692
+ this.runDisconnectListener(listener)
2693
+ }
2694
+ }
2695
+
2696
+ /**
2697
+ * Invoke one disconnect listener, isolated and with its failure SWALLOWED after
2698
+ * logging.
2699
+ *
2700
+ * There is no caller left to report to — the socket this would report on is the
2701
+ * one that just closed — and a rejection parked on `deferredPromises` would
2702
+ * surface as the run's failure, replacing a healthy outcome with a bookkeeping
2703
+ * error. Isolation matters for the usual reason too: one subscriber's failing
2704
+ * write must not skip the next one's.
2705
+ */
2706
+ private runDisconnectListener(listener: () => void | Promise<void>): void {
2707
+ let result: void | Promise<void>
2708
+ try {
2709
+ result = listener()
2710
+ } catch (error) {
2711
+ this.logger.errors('run disconnect listener failed', { error })
2712
+ return
2713
+ }
2714
+ if (result === undefined) return
2715
+ this.deferredPromises.push(
2716
+ result.catch((error: unknown) => {
2717
+ this.logger.errors('run disconnect listener failed', { error })
2718
+ }),
2719
+ )
2720
+ }
2721
+
2070
2722
  /**
2071
2723
  * Run the final structured-output adapter call through the middleware
2072
2724
  * pipeline. Yields chunks to the caller only when
@@ -2622,12 +3274,181 @@ class TextEngine<
2622
3274
  messages: this.messages,
2623
3275
  systemPrompts: [...this.systemPrompts],
2624
3276
  tools: [...this.tools],
3277
+ resume: this.params.resume,
3278
+ resumeToolState: {
3279
+ approvals: this.resumeApprovals,
3280
+ clientToolResults: this.resumeClientToolResults,
3281
+ deniedToolResults: this.resumeDeniedToolResults,
3282
+ cancelledToolCallIds: this.resumeCancelledToolCallIds,
3283
+ },
2625
3284
  metadata: this.params.metadata,
2626
3285
  modelOptions: this.params.modelOptions,
2627
3286
  }
2628
3287
  }
2629
3288
 
3289
+ private async applyEphemeralInterruptResume(
3290
+ config: ChatMiddlewareConfig,
3291
+ ): Promise<void> {
3292
+ if ((config.resume?.length ?? 0) === 0) {
3293
+ return
3294
+ }
3295
+
3296
+ const interruptedRunId = this.parentRunIdOverride
3297
+ if (!interruptedRunId) {
3298
+ throw new InterruptResumeValidationError([
3299
+ {
3300
+ scope: 'batch',
3301
+ threadId: this.threadId,
3302
+ interruptedRunId: this.runIdOverride ?? this.requestId,
3303
+ generation: 0,
3304
+ interruptIds: config.resume?.map((entry) => entry.interruptId) ?? [],
3305
+ code: 'stale',
3306
+ message:
3307
+ 'Interrupt continuation requires parentRunId to identify the interrupted run.',
3308
+ source: 'server',
3309
+ retryable: false,
3310
+ },
3311
+ ])
3312
+ }
3313
+
3314
+ const approvalRequests: Array<ApprovalRequest> = []
3315
+ const clientRequests: Array<ClientToolRequest> = []
3316
+ // Prefer resume-aware reconstruction so client-tool outputs already written
3317
+ // into history for UI still validate against the resume batch.
3318
+ const pendingToolCalls = this.getToolCallsForEphemeralResume(config.resume)
3319
+ const resumeInterruptIds = new Set(
3320
+ config.resume?.map((entry) => entry.interruptId),
3321
+ )
3322
+ const toolInputs = new Map<string, unknown>()
3323
+ const toolsByCallId = new Map<string, AnyRuntimeTool>()
3324
+ const clientExecutionCallIds = new Set<string>()
3325
+
3326
+ for (const toolCall of pendingToolCalls) {
3327
+ const tool = this.tools.find(
3328
+ (candidate) => candidate.name === toolCall.function.name,
3329
+ )
3330
+ if (!tool) continue
3331
+ toolsByCallId.set(toolCall.id, tool)
3332
+ let input: unknown = {}
3333
+ try {
3334
+ const parsed = JSON.parse(toolCall.function.arguments.trim() || '{}')
3335
+ input = parsed && typeof parsed === 'object' ? parsed : {}
3336
+ } catch {
3337
+ input = {}
3338
+ }
3339
+ toolInputs.set(toolCall.id, input)
3340
+ if (
3341
+ !tool.execute &&
3342
+ resumeInterruptIds.has(`client_tool_${toolCall.id}`)
3343
+ ) {
3344
+ clientExecutionCallIds.add(toolCall.id)
3345
+ }
3346
+ }
3347
+
3348
+ // Mirror executeToolCalls' scheduling boundary. Server execution remains
3349
+ // gated while any approval is outstanding, but plain client tools are
3350
+ // represented in the same interrupt batch because requesting their output
3351
+ // does not execute a server-side effect.
3352
+ for (const toolCall of pendingToolCalls) {
3353
+ const tool = toolsByCallId.get(toolCall.id)
3354
+ if (tool?.needsApproval && !clientExecutionCallIds.has(toolCall.id)) {
3355
+ approvalRequests.push({
3356
+ toolCallId: toolCall.id,
3357
+ toolName: toolCall.function.name,
3358
+ input: toolInputs.get(toolCall.id) ?? {},
3359
+ approvalId: `approval_${toolCall.id}`,
3360
+ })
3361
+ }
3362
+ }
3363
+
3364
+ for (const toolCall of pendingToolCalls) {
3365
+ const tool = toolsByCallId.get(toolCall.id)
3366
+ if (
3367
+ tool !== undefined &&
3368
+ !tool.execute &&
3369
+ (!tool.needsApproval || clientExecutionCallIds.has(toolCall.id))
3370
+ ) {
3371
+ clientRequests.push({
3372
+ toolCallId: toolCall.id,
3373
+ toolName: toolCall.function.name,
3374
+ input: toolInputs.get(toolCall.id) ?? {},
3375
+ })
3376
+ }
3377
+ }
3378
+
3379
+ const pending = this.buildActionableInterrupts(
3380
+ approvalRequests,
3381
+ clientRequests,
3382
+ ).flatMap((descriptor) => {
3383
+ const unopened = readUnopenedInterruptBinding(descriptor)
3384
+ return unopened
3385
+ ? [
3386
+ {
3387
+ interruptId: descriptor.id,
3388
+ payload: descriptor,
3389
+ binding: {
3390
+ ...unopened,
3391
+ interruptedRunId,
3392
+ generation: 0,
3393
+ } satisfies InterruptBinding,
3394
+ },
3395
+ ]
3396
+ : []
3397
+ })
3398
+ const validated = await validateInterruptResumeBatch({
3399
+ threadId: this.threadId,
3400
+ interruptedRunId,
3401
+ generation: 0,
3402
+ pending,
3403
+ resume: config.resume,
3404
+ tools: this.tools,
3405
+ })
3406
+ if (validated.errors.length > 0 || !validated.resumeToolState) {
3407
+ throw new InterruptResumeValidationError(validated.errors)
3408
+ }
3409
+
3410
+ // A client-tool execution interrupt can only be emitted after an
3411
+ // approval-required client tool was approved in the preceding ephemeral
3412
+ // run. Reconstruct that phase marker from the trusted `client_tool_*`
3413
+ // continuation so executeToolCalls consumes the validated client output
3414
+ // instead of asking for approval again.
3415
+ const approvals = new Map(validated.resumeToolState.approvals)
3416
+ for (const request of clientRequests) {
3417
+ if (toolsByCallId.get(request.toolCallId)?.needsApproval) {
3418
+ approvals.set(request.toolCallId, true)
3419
+ }
3420
+ }
3421
+ this.applyResumeToolState({
3422
+ ...validated.resumeToolState,
3423
+ approvals,
3424
+ })
3425
+ }
3426
+
3427
+ private applyResumeToolState(state: ChatResumeToolState | undefined): void {
3428
+ if (state?.approvals) {
3429
+ for (const [approvalId, resolution] of state.approvals) {
3430
+ this.resumeApprovals.set(approvalId, resolution)
3431
+ }
3432
+ }
3433
+ if (state?.clientToolResults) {
3434
+ for (const [toolCallId, result] of state.clientToolResults) {
3435
+ this.resumeClientToolResults.set(toolCallId, result)
3436
+ }
3437
+ }
3438
+ if (state?.deniedToolResults) {
3439
+ for (const [toolCallId, result] of state.deniedToolResults) {
3440
+ this.resumeDeniedToolResults.set(toolCallId, result)
3441
+ }
3442
+ }
3443
+ if (state?.cancelledToolCallIds) {
3444
+ for (const toolCallId of state.cancelledToolCallIds) {
3445
+ this.resumeCancelledToolCallIds.add(toolCallId)
3446
+ }
3447
+ }
3448
+ }
3449
+
2630
3450
  private applyMiddlewareConfig(config: ChatMiddlewareConfig): void {
3451
+ this.applyResumeToolState(config.resumeToolState)
2631
3452
  this.messages = config.messages
2632
3453
  this.systemPrompts = config.systemPrompts
2633
3454
  this.tools = config.tools
@@ -2718,7 +3539,7 @@ class TextEngine<
2718
3539
  model: this.params.model,
2719
3540
  name: eventName,
2720
3541
  value,
2721
- } as CustomEvent
3542
+ }
2722
3543
  }
2723
3544
 
2724
3545
  private createId(prefix: string): string {
@@ -2745,7 +3566,7 @@ class TextEngine<
2745
3566
  * import { openaiText } from '@tanstack/ai-openai'
2746
3567
  *
2747
3568
  * for await (const chunk of chat({
2748
- * adapter: openaiText('gpt-4o'),
3569
+ * adapter: openaiText('gpt-5.5'),
2749
3570
  * messages: [{ role: 'user', content: 'What is the weather?' }],
2750
3571
  * tools: [weatherTool]
2751
3572
  * })) {
@@ -2758,7 +3579,7 @@ class TextEngine<
2758
3579
  * @example One-shot text (streaming without tools)
2759
3580
  * ```ts
2760
3581
  * for await (const chunk of chat({
2761
- * adapter: openaiText('gpt-4o'),
3582
+ * adapter: openaiText('gpt-5.5'),
2762
3583
  * messages: [{ role: 'user', content: 'Hello!' }]
2763
3584
  * })) {
2764
3585
  * console.log(chunk)
@@ -2768,7 +3589,7 @@ class TextEngine<
2768
3589
  * @example Non-streaming text (stream: false)
2769
3590
  * ```ts
2770
3591
  * const text = await chat({
2771
- * adapter: openaiText('gpt-4o'),
3592
+ * adapter: openaiText('gpt-5.5'),
2772
3593
  * messages: [{ role: 'user', content: 'Hello!' }],
2773
3594
  * stream: false
2774
3595
  * })
@@ -2780,7 +3601,7 @@ class TextEngine<
2780
3601
  * import { z } from 'zod'
2781
3602
  *
2782
3603
  * const result = await chat({
2783
- * adapter: openaiText('gpt-4o'),
3604
+ * adapter: openaiText('gpt-5.5'),
2784
3605
  * messages: [{ role: 'user', content: 'Research and summarize the topic' }],
2785
3606
  * tools: [researchTool, analyzeTool],
2786
3607
  * outputSchema: z.object({
@@ -2820,7 +3641,7 @@ export function chat<
2820
3641
  TTools,
2821
3642
  TMiddleware
2822
3643
  >,
2823
- ): TextActivityResult<TSchema, TStream> {
3644
+ ): TextActivityResult<TSchema, TStream, TTools> {
2824
3645
  validateCapabilities(options.middleware ?? [], options.adapter)
2825
3646
 
2826
3647
  const { outputSchema, stream } = options
@@ -2833,7 +3654,7 @@ export function chat<
2833
3654
  ...options,
2834
3655
  outputSchema,
2835
3656
  stream,
2836
- }) as TextActivityResult<TSchema, TStream>
3657
+ }) as TextActivityResult<TSchema, TStream, TTools>
2837
3658
  }
2838
3659
 
2839
3660
  // If outputSchema is provided, run agentic structured output (Promise<T>)
@@ -2841,7 +3662,7 @@ export function chat<
2841
3662
  return runAgenticStructuredOutput({
2842
3663
  ...options,
2843
3664
  outputSchema,
2844
- }) as TextActivityResult<TSchema, TStream>
3665
+ }) as TextActivityResult<TSchema, TStream, TTools>
2845
3666
  }
2846
3667
 
2847
3668
  // If stream is explicitly false, run non-streaming text
@@ -2850,7 +3671,7 @@ export function chat<
2850
3671
  ...options,
2851
3672
  outputSchema: undefined,
2852
3673
  stream,
2853
- }) as TextActivityResult<TSchema, TStream>
3674
+ }) as TextActivityResult<TSchema, TStream, TTools>
2854
3675
  }
2855
3676
 
2856
3677
  // Otherwise, run streaming text (default)
@@ -2858,14 +3679,70 @@ export function chat<
2858
3679
  ...options,
2859
3680
  outputSchema: undefined,
2860
3681
  stream,
2861
- }) as TextActivityResult<TSchema, TStream>
3682
+ }) as TextActivityResult<TSchema, TStream, TTools>
3683
+ }
3684
+
3685
+ /**
3686
+ * The slice of the engine that the durable delivery sink reaches back into, in
3687
+ * BOTH directions: it reads the detach verdict (`wasDetached`) and pushes the
3688
+ * socket-closed fact in (`notifyDisconnected`). Filled by the generator body as
3689
+ * soon as its engine exists.
3690
+ */
3691
+ interface DeliveryEngineRef {
3692
+ current?: {
3693
+ wasDetached: () => boolean
3694
+ notifyDisconnected: () => void
3695
+ }
2862
3696
  }
2863
3697
 
2864
3698
  /**
2865
- * Run streaming text (agentic or one-shot depending on tools)
3699
+ * Publish both delivery-side seams for `stream`.
3700
+ *
3701
+ * Shared by the two streaming paths so they cannot drift apart — the
3702
+ * structured-output path having been wired for one seam and not the other is
3703
+ * exactly the bug `publishRunDetachedSignal` picked up last time (a durable
3704
+ * `chat({ outputSchema, stream: true })` could never detach).
2866
3705
  */
2867
- async function* runStreamingText<TContext = unknown>(
3706
+ function publishDeliverySeams(
3707
+ stream: object,
3708
+ engineRef: DeliveryEngineRef,
3709
+ ): void {
3710
+ // A thunk, evaluated on the sink's teardown path: the engine does not exist
3711
+ // yet, and the verdict it will report is only written during `onAbort`.
3712
+ publishRunDetachedSignal(
3713
+ stream,
3714
+ () => engineRef.current?.wasDetached() === true,
3715
+ )
3716
+ // The inbound direction. Dropped if the socket closes before the body has run
3717
+ // far enough to have an engine, which is correct: there is no run state to
3718
+ // record yet, and `setup` has not begun, so nothing is leaked by not knowing.
3719
+ publishRunDisconnectHandler(stream, () => {
3720
+ engineRef.current?.notifyDisconnected()
3721
+ })
3722
+ }
3723
+
3724
+ /**
3725
+ * Run streaming text (agentic or one-shot depending on tools).
3726
+ *
3727
+ * A thin, NON-generator wrapper, because the stream object is also the key the
3728
+ * durable delivery sink looks the run's detach verdict up under (see
3729
+ * `../../delivery-detach`) and delivers its disconnect notification through (see
3730
+ * `../../delivery-disconnect`). A generator function cannot reach the generator it
3731
+ * returns, so the identity has to be minted out here and the engine reached back
3732
+ * through `engineRef`, which the body fills as soon as its engine exists.
3733
+ */
3734
+ function runStreamingText<TContext = unknown>(
2868
3735
  options: TextActivityOptions<AnyTextAdapter, undefined, true, TContext>,
3736
+ ): AsyncIterable<StreamChunk> {
3737
+ const engineRef: DeliveryEngineRef = {}
3738
+ const stream = streamTextChunks(options, engineRef)
3739
+ publishDeliverySeams(stream, engineRef)
3740
+ return stream
3741
+ }
3742
+
3743
+ async function* streamTextChunks<TContext = unknown>(
3744
+ options: TextActivityOptions<AnyTextAdapter, undefined, true, TContext>,
3745
+ engineRef: DeliveryEngineRef,
2869
3746
  ): AsyncIterable<StreamChunk> {
2870
3747
  const { adapter, middleware, context, debug, mcp, ...textOptions } = options
2871
3748
  const model = adapter.model
@@ -2890,6 +3767,7 @@ async function* runStreamingText<TContext = unknown>(
2890
3767
  },
2891
3768
  logger,
2892
3769
  )
3770
+ engineRef.current = engine
2893
3771
 
2894
3772
  try {
2895
3773
  for await (const chunk of engine.run()) {
@@ -2909,7 +3787,7 @@ function runNonStreamingText<TContext = unknown>(
2909
3787
  ): Promise<string> {
2910
3788
  // Run the streaming text and collect all text using streamToText.
2911
3789
  const stream = runStreamingText(
2912
- // eslint-disable-next-line no-restricted-syntax -- generic-stream remap: caller is non-streaming (false), but runStreamingText is invoked internally to collect text; concrete `false`→`true` literals don't structurally overlap.
3790
+ // oxlint-disable-next-line eslint-js/no-restricted-syntax -- generic-stream remap: caller is non-streaming (false), but runStreamingText is invoked internally to collect text; concrete `false`→`true` literals don't structurally overlap.
2913
3791
  options as unknown as TextActivityOptions<
2914
3792
  AnyTextAdapter,
2915
3793
  undefined,
@@ -3233,25 +4111,36 @@ function runStreamingStructuredOutput<
3233
4111
  undoNullWidening(data, nullWideningMap)
3234
4112
 
3235
4113
  // The implementation generator yields the broader internal type
3236
- // (`StreamChunk | StructuredOutputCompleteEvent<T>`) so agent-loop
3237
- // CustomEvents can flow through; the public-facing type narrows to
3238
- // `Exclude<StreamChunk, CustomEvent> | StructuredOutputCompleteEvent<T>`
3239
- // which lets consumers narrow `chunk.value` cleanly. The widen→narrow
3240
- // is contained here so consumers see only the strict type.
3241
- return runStreamingStructuredOutputImpl(
4114
+ // (`StreamChunk | StructuredOutputCompleteEvent<T>`) so middleware and
4115
+ // tool-emitted CustomEvents can flow through. Core approval/client-tool
4116
+ // waits are represented by RUN_FINISHED interrupt outcomes, not by direct
4117
+ // CUSTOM wait events.
4118
+ // The contained cast keeps the public stream type focused on
4119
+ // structured-output completion.
4120
+ //
4121
+ // Same seam as `runStreamingText`: this wrapper is NOT a generator, so the
4122
+ // stream identity can be minted here and the engine reached back through
4123
+ // `engineRef` once the impl body has its engine. Without this a durable
4124
+ // structured-output stream could never detach — the sink would find no verdict
4125
+ // and terminalize a healthy detached run's log — nor survive a disconnect.
4126
+ const engineRef: DeliveryEngineRef = {}
4127
+ const stream = runStreamingStructuredOutputImpl(
3242
4128
  options,
3243
4129
  jsonSchema,
3244
4130
  normalize,
3245
- ) as StructuredOutputStream<InferSchemaType<TSchema>>
4131
+ engineRef,
4132
+ )
4133
+ publishDeliverySeams(stream, engineRef)
4134
+ return stream as StructuredOutputStream<InferSchemaType<TSchema>>
3246
4135
  }
3247
4136
 
3248
4137
  /**
3249
4138
  * Internal generator return type — broader than the public
3250
- * `StructuredOutputStream<T>`. The public type pins three tagged `CUSTOM`
3251
- * events (`structured-output.complete`, `approval-requested`,
3252
- * `tool-input-available`) so consumers can narrow `chunk.value` cleanly by
3253
- * literal `name`. At runtime, tools can also emit arbitrary user-defined
3254
- * `CustomEvent`s through the `emitCustomEvent` context API; those flow
4139
+ * `StructuredOutputStream<T>`. The structured-output completion event remains
4140
+ * the pinned public CUSTOM event for this stream; approval and client-tool
4141
+ * waits now surface as RUN_FINISHED interrupt outcomes. At runtime, tools can
4142
+ * still emit arbitrary user-defined `CustomEvent`s through the
4143
+ * `emitCustomEvent` context API; those flow
3255
4144
  * through this generator with `name: string` and are widened out at the
3256
4145
  * public boundary because keeping them would collapse the typed narrow back
3257
4146
  * to `any`. The cast inside `runStreamingStructuredOutput` is where that
@@ -3268,6 +4157,7 @@ async function* runStreamingStructuredOutputImpl<
3268
4157
  options: TextActivityOptions<AnyTextAdapter, TSchema, true, TContext>,
3269
4158
  jsonSchema: NonNullable<ReturnType<typeof convertSchemaToJsonSchema>>,
3270
4159
  normalize: (data: unknown) => unknown,
4160
+ engineRef: DeliveryEngineRef,
3271
4161
  ): StructuredOutputStreamInternal<InferSchemaType<TSchema>> {
3272
4162
  const {
3273
4163
  adapter,
@@ -3318,6 +4208,7 @@ async function* runStreamingStructuredOutputImpl<
3318
4208
  },
3319
4209
  logger,
3320
4210
  )
4211
+ engineRef.current = engine
3321
4212
 
3322
4213
  try {
3323
4214
  for await (const chunk of engine.run()) {