@tanstack/ai 0.41.0 → 0.43.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (272) hide show
  1. package/README.md +15 -1
  2. package/dist/esm/activities/chat/adapter.js +23 -16
  3. package/dist/esm/activities/chat/adapter.js.map +1 -1
  4. package/dist/esm/activities/chat/agent-loop-strategies.d.ts +10 -4
  5. package/dist/esm/activities/chat/agent-loop-strategies.js +75 -17
  6. package/dist/esm/activities/chat/agent-loop-strategies.js.map +1 -1
  7. package/dist/esm/activities/chat/cancel.d.ts +40 -0
  8. package/dist/esm/activities/chat/cancel.js +54 -0
  9. package/dist/esm/activities/chat/cancel.js.map +1 -0
  10. package/dist/esm/activities/chat/index.d.ts +28 -16
  11. package/dist/esm/activities/chat/index.js +2100 -1744
  12. package/dist/esm/activities/chat/index.js.map +1 -1
  13. package/dist/esm/activities/chat/mcp/manager.d.ts +2 -2
  14. package/dist/esm/activities/chat/mcp/manager.js +90 -77
  15. package/dist/esm/activities/chat/mcp/manager.js.map +1 -1
  16. package/dist/esm/activities/chat/mcp/types.d.ts +2 -2
  17. package/dist/esm/activities/chat/messages.js +397 -346
  18. package/dist/esm/activities/chat/messages.js.map +1 -1
  19. package/dist/esm/activities/chat/middleware/builder.js +17 -15
  20. package/dist/esm/activities/chat/middleware/builder.js.map +1 -1
  21. package/dist/esm/activities/chat/middleware/capabilities.js +78 -43
  22. package/dist/esm/activities/chat/middleware/capabilities.js.map +1 -1
  23. package/dist/esm/activities/chat/middleware/compose.d.ts +94 -1
  24. package/dist/esm/activities/chat/middleware/compose.js +623 -531
  25. package/dist/esm/activities/chat/middleware/compose.js.map +1 -1
  26. package/dist/esm/activities/chat/middleware/define.js +12 -5
  27. package/dist/esm/activities/chat/middleware/define.js.map +1 -1
  28. package/dist/esm/activities/chat/middleware/index.d.ts +5 -1
  29. package/dist/esm/activities/chat/middleware/locks.d.ts +50 -0
  30. package/dist/esm/activities/chat/middleware/locks.js +71 -0
  31. package/dist/esm/activities/chat/middleware/locks.js.map +1 -0
  32. package/dist/esm/activities/chat/middleware/pending-turn.d.ts +15 -0
  33. package/dist/esm/activities/chat/middleware/pending-turn.js +35 -0
  34. package/dist/esm/activities/chat/middleware/pending-turn.js.map +1 -0
  35. package/dist/esm/activities/chat/middleware/run-disconnect.d.ts +23 -0
  36. package/dist/esm/activities/chat/middleware/run-disconnect.js +42 -0
  37. package/dist/esm/activities/chat/middleware/run-disconnect.js.map +1 -0
  38. package/dist/esm/activities/chat/middleware/run-store.d.ts +283 -0
  39. package/dist/esm/activities/chat/middleware/run-store.js +176 -0
  40. package/dist/esm/activities/chat/middleware/run-store.js.map +1 -0
  41. package/dist/esm/activities/chat/middleware/sandbox-runtime.js +14 -8
  42. package/dist/esm/activities/chat/middleware/sandbox-runtime.js.map +1 -1
  43. package/dist/esm/activities/chat/middleware/tool-cache-middleware.js +79 -70
  44. package/dist/esm/activities/chat/middleware/tool-cache-middleware.js.map +1 -1
  45. package/dist/esm/activities/chat/middleware/types.d.ts +59 -2
  46. package/dist/esm/activities/chat/middleware/validate.js +23 -28
  47. package/dist/esm/activities/chat/middleware/validate.js.map +1 -1
  48. package/dist/esm/activities/chat/stream/json-parser.js +39 -25
  49. package/dist/esm/activities/chat/stream/json-parser.js.map +1 -1
  50. package/dist/esm/activities/chat/stream/message-updaters.js +275 -234
  51. package/dist/esm/activities/chat/stream/message-updaters.js.map +1 -1
  52. package/dist/esm/activities/chat/stream/processor.d.ts +24 -4
  53. package/dist/esm/activities/chat/stream/processor.js +1341 -1542
  54. package/dist/esm/activities/chat/stream/processor.js.map +1 -1
  55. package/dist/esm/activities/chat/stream/strategies.js +69 -53
  56. package/dist/esm/activities/chat/stream/strategies.js.map +1 -1
  57. package/dist/esm/activities/chat/tools/approval-schema.d.ts +19 -0
  58. package/dist/esm/activities/chat/tools/approval-schema.js +117 -0
  59. package/dist/esm/activities/chat/tools/approval-schema.js.map +1 -0
  60. package/dist/esm/activities/chat/tools/lazy-tool-manager.js +164 -191
  61. package/dist/esm/activities/chat/tools/lazy-tool-manager.js.map +1 -1
  62. package/dist/esm/activities/chat/tools/lazy-tools.js +24 -12
  63. package/dist/esm/activities/chat/tools/lazy-tools.js.map +1 -1
  64. package/dist/esm/activities/chat/tools/schema-converter.js +293 -146
  65. package/dist/esm/activities/chat/tools/schema-converter.js.map +1 -1
  66. package/dist/esm/activities/chat/tools/tool-calls.d.ts +18 -2
  67. package/dist/esm/activities/chat/tools/tool-calls.js +522 -531
  68. package/dist/esm/activities/chat/tools/tool-calls.js.map +1 -1
  69. package/dist/esm/activities/chat/tools/tool-definition.d.ts +75 -16
  70. package/dist/esm/activities/chat/tools/tool-definition.js +95 -23
  71. package/dist/esm/activities/chat/tools/tool-definition.js.map +1 -1
  72. package/dist/esm/activities/error-payload.js +85 -47
  73. package/dist/esm/activities/error-payload.js.map +1 -1
  74. package/dist/esm/activities/generateAudio/adapter.js +22 -15
  75. package/dist/esm/activities/generateAudio/adapter.js.map +1 -1
  76. package/dist/esm/activities/generateAudio/index.d.ts +4 -0
  77. package/dist/esm/activities/generateAudio/index.js +141 -105
  78. package/dist/esm/activities/generateAudio/index.js.map +1 -1
  79. package/dist/esm/activities/generateImage/adapter.js +22 -15
  80. package/dist/esm/activities/generateImage/adapter.js.map +1 -1
  81. package/dist/esm/activities/generateImage/index.d.ts +4 -0
  82. package/dist/esm/activities/generateImage/index.js +155 -111
  83. package/dist/esm/activities/generateImage/index.js.map +1 -1
  84. package/dist/esm/activities/generateSpeech/adapter.js +22 -15
  85. package/dist/esm/activities/generateSpeech/adapter.js.map +1 -1
  86. package/dist/esm/activities/generateSpeech/index.d.ts +4 -0
  87. package/dist/esm/activities/generateSpeech/index.js +159 -110
  88. package/dist/esm/activities/generateSpeech/index.js.map +1 -1
  89. package/dist/esm/activities/generateTranscription/adapter.js +22 -15
  90. package/dist/esm/activities/generateTranscription/adapter.js.map +1 -1
  91. package/dist/esm/activities/generateTranscription/index.d.ts +4 -0
  92. package/dist/esm/activities/generateTranscription/index.js +159 -100
  93. package/dist/esm/activities/generateTranscription/index.js.map +1 -1
  94. package/dist/esm/activities/generateVideo/adapter.js +36 -29
  95. package/dist/esm/activities/generateVideo/adapter.js.map +1 -1
  96. package/dist/esm/activities/generateVideo/index.d.ts +143 -19
  97. package/dist/esm/activities/generateVideo/index.js +456 -279
  98. package/dist/esm/activities/generateVideo/index.js.map +1 -1
  99. package/dist/esm/activities/generateVideo/snap.js +60 -48
  100. package/dist/esm/activities/generateVideo/snap.js.map +1 -1
  101. package/dist/esm/activities/index.js +8 -34
  102. package/dist/esm/activities/middleware/index.d.ts +1 -1
  103. package/dist/esm/activities/middleware/run.d.ts +10 -0
  104. package/dist/esm/activities/middleware/run.js +53 -29
  105. package/dist/esm/activities/middleware/run.js.map +1 -1
  106. package/dist/esm/activities/middleware/types.d.ts +44 -6
  107. package/dist/esm/activities/stream-generation-result.d.ts +4 -1
  108. package/dist/esm/activities/stream-generation-result.js +79 -44
  109. package/dist/esm/activities/stream-generation-result.js.map +1 -1
  110. package/dist/esm/activities/summarize/adapter.js +22 -15
  111. package/dist/esm/activities/summarize/adapter.js.map +1 -1
  112. package/dist/esm/activities/summarize/chat-stream-summarize.js +252 -202
  113. package/dist/esm/activities/summarize/chat-stream-summarize.js.map +1 -1
  114. package/dist/esm/activities/summarize/index.d.ts +27 -0
  115. package/dist/esm/activities/summarize/index.js +268 -102
  116. package/dist/esm/activities/summarize/index.js.map +1 -1
  117. package/dist/esm/adapter-internals.d.ts +2 -1
  118. package/dist/esm/adapter-internals.js +4 -11
  119. package/dist/esm/client.d.ts +25 -3
  120. package/dist/esm/client.js +131 -64
  121. package/dist/esm/client.js.map +1 -1
  122. package/dist/esm/custom-events.d.ts +76 -0
  123. package/dist/esm/custom-events.js +37 -0
  124. package/dist/esm/custom-events.js.map +1 -0
  125. package/dist/esm/delivery-detach.d.ts +50 -0
  126. package/dist/esm/delivery-detach.js +71 -0
  127. package/dist/esm/delivery-detach.js.map +1 -0
  128. package/dist/esm/delivery-disconnect.d.ts +62 -0
  129. package/dist/esm/delivery-disconnect.js +81 -0
  130. package/dist/esm/delivery-disconnect.js.map +1 -0
  131. package/dist/esm/extend-adapter.js +19 -17
  132. package/dist/esm/extend-adapter.js.map +1 -1
  133. package/dist/esm/index.d.ts +23 -5
  134. package/dist/esm/index.js +30 -97
  135. package/dist/esm/interrupt-resume.d.ts +71 -0
  136. package/dist/esm/interrupt-resume.js +438 -0
  137. package/dist/esm/interrupt-resume.js.map +1 -0
  138. package/dist/esm/interrupt-serialization.d.ts +12 -0
  139. package/dist/esm/interrupt-serialization.js +178 -0
  140. package/dist/esm/interrupt-serialization.js.map +1 -0
  141. package/dist/esm/interrupts.d.ts +84 -0
  142. package/dist/esm/interrupts.js +31 -0
  143. package/dist/esm/interrupts.js.map +1 -0
  144. package/dist/esm/locks.d.ts +10 -0
  145. package/dist/esm/locks.js +2 -0
  146. package/dist/esm/logger/console-logger.js +101 -78
  147. package/dist/esm/logger/console-logger.js.map +1 -1
  148. package/dist/esm/logger/internal-logger.js +104 -89
  149. package/dist/esm/logger/internal-logger.js.map +1 -1
  150. package/dist/esm/logger/resolve.js +54 -49
  151. package/dist/esm/logger/resolve.js.map +1 -1
  152. package/dist/esm/logger/types.d.ts +1 -1
  153. package/dist/esm/middlewares/content-guard.js +142 -148
  154. package/dist/esm/middlewares/content-guard.js.map +1 -1
  155. package/dist/esm/middlewares/index.js +2 -6
  156. package/dist/esm/middlewares/otel.js +598 -732
  157. package/dist/esm/middlewares/otel.js.map +1 -1
  158. package/dist/esm/middlewares/usage-attributes.js +47 -40
  159. package/dist/esm/middlewares/usage-attributes.js.map +1 -1
  160. package/dist/esm/realtime/event-emitter.js +24 -25
  161. package/dist/esm/realtime/event-emitter.js.map +1 -1
  162. package/dist/esm/realtime/index.d.ts +5 -9
  163. package/dist/esm/realtime/index.js +29 -6
  164. package/dist/esm/realtime/index.js.map +1 -1
  165. package/dist/esm/scope.d.ts +47 -0
  166. package/dist/esm/stream-durability.d.ts +171 -0
  167. package/dist/esm/stream-durability.js +295 -0
  168. package/dist/esm/stream-durability.js.map +1 -0
  169. package/dist/esm/stream-to-response.d.ts +178 -13
  170. package/dist/esm/stream-to-response.js +663 -115
  171. package/dist/esm/stream-to-response.js.map +1 -1
  172. package/dist/esm/strip-to-spec-middleware.js +30 -16
  173. package/dist/esm/strip-to-spec-middleware.js.map +1 -1
  174. package/dist/esm/system-prompts.js +27 -21
  175. package/dist/esm/system-prompts.js.map +1 -1
  176. package/dist/esm/tool-registry.js +72 -45
  177. package/dist/esm/tool-registry.js.map +1 -1
  178. package/dist/esm/tools/provider-tool.js +14 -5
  179. package/dist/esm/tools/provider-tool.js.map +1 -1
  180. package/dist/esm/types.d.ts +332 -21
  181. package/dist/esm/types.js +2 -0
  182. package/dist/esm/utilities/ag-ui-wire.js +79 -93
  183. package/dist/esm/utilities/ag-ui-wire.js.map +1 -1
  184. package/dist/esm/utilities/chat-params.d.ts +26 -4
  185. package/dist/esm/utilities/chat-params.js +218 -92
  186. package/dist/esm/utilities/chat-params.js.map +1 -1
  187. package/dist/esm/utilities/errors.js +28 -18
  188. package/dist/esm/utilities/errors.js.map +1 -1
  189. package/dist/esm/utilities/media-prompt.js +46 -41
  190. package/dist/esm/utilities/media-prompt.js.map +1 -1
  191. package/dist/esm/utilities/numbers.js +13 -10
  192. package/dist/esm/utilities/numbers.js.map +1 -1
  193. package/dist/esm/utilities/provider-executed.js +20 -11
  194. package/dist/esm/utilities/provider-executed.js.map +1 -1
  195. package/dist/esm/utilities/sampling-keys.js +31 -19
  196. package/dist/esm/utilities/sampling-keys.js.map +1 -1
  197. package/dist/esm/utilities/tool-result.js +42 -30
  198. package/dist/esm/utilities/tool-result.js.map +1 -1
  199. package/dist/esm/utilities/usage.js +27 -9
  200. package/dist/esm/utilities/usage.js.map +1 -1
  201. package/dist/esm/utils.js +26 -18
  202. package/dist/esm/utils.js.map +1 -1
  203. package/package.json +10 -6
  204. package/skills/ai-core/SKILL.md +69 -18
  205. package/skills/ai-core/adapter-configuration/SKILL.md +44 -21
  206. package/skills/ai-core/adapter-configuration/references/anthropic-adapter.md +1 -3
  207. package/skills/ai-core/adapter-configuration/references/byteplus-adapter.md +148 -0
  208. package/skills/ai-core/adapter-configuration/references/gemini-adapter.md +2 -6
  209. package/skills/ai-core/adapter-configuration/references/groq-adapter.md +2 -6
  210. package/skills/ai-core/adapter-configuration/references/openai-adapter.md +1 -3
  211. package/skills/ai-core/ag-ui-protocol/SKILL.md +1 -1
  212. package/skills/ai-core/chat-experience/SKILL.md +156 -11
  213. package/skills/ai-core/client-persistence/SKILL.md +277 -0
  214. package/skills/ai-core/custom-backend-integration/SKILL.md +1 -1
  215. package/skills/ai-core/debug-logging/SKILL.md +1 -1
  216. package/skills/ai-core/locks/SKILL.md +143 -0
  217. package/skills/ai-core/media-generation/SKILL.md +144 -12
  218. package/skills/ai-core/middleware/SKILL.md +258 -33
  219. package/skills/ai-core/structured-outputs/SKILL.md +1 -1
  220. package/skills/ai-core/tool-calling/SKILL.md +54 -59
  221. package/src/activities/chat/agent-loop-strategies.ts +10 -4
  222. package/src/activities/chat/cancel.ts +81 -0
  223. package/src/activities/chat/index.ts +1152 -153
  224. package/src/activities/chat/mcp/manager.ts +4 -4
  225. package/src/activities/chat/mcp/types.ts +2 -2
  226. package/src/activities/chat/messages.ts +5 -3
  227. package/src/activities/chat/middleware/builder.ts +1 -1
  228. package/src/activities/chat/middleware/compose.ts +186 -9
  229. package/src/activities/chat/middleware/index.ts +26 -0
  230. package/src/activities/chat/middleware/locks.ts +102 -0
  231. package/src/activities/chat/middleware/pending-turn.ts +47 -0
  232. package/src/activities/chat/middleware/run-disconnect.ts +62 -0
  233. package/src/activities/chat/middleware/run-store.ts +412 -0
  234. package/src/activities/chat/middleware/types.ts +62 -1
  235. package/src/activities/chat/stream/processor.ts +189 -5
  236. package/src/activities/chat/tools/approval-schema.ts +205 -0
  237. package/src/activities/chat/tools/tool-calls.ts +106 -13
  238. package/src/activities/chat/tools/tool-definition.ts +210 -39
  239. package/src/activities/generateAudio/index.ts +20 -3
  240. package/src/activities/generateImage/index.ts +20 -3
  241. package/src/activities/generateSpeech/index.ts +25 -3
  242. package/src/activities/generateTranscription/index.ts +26 -3
  243. package/src/activities/generateVideo/index.ts +345 -82
  244. package/src/activities/middleware/index.ts +2 -0
  245. package/src/activities/middleware/run.ts +31 -0
  246. package/src/activities/middleware/types.ts +49 -5
  247. package/src/activities/stream-generation-result.ts +30 -2
  248. package/src/activities/summarize/chat-stream-summarize.ts +5 -0
  249. package/src/activities/summarize/index.ts +200 -10
  250. package/src/adapter-internals.ts +10 -1
  251. package/src/client.ts +244 -0
  252. package/src/custom-events.ts +107 -0
  253. package/src/delivery-detach.ts +72 -0
  254. package/src/delivery-disconnect.ts +84 -0
  255. package/src/index.ts +138 -0
  256. package/src/interrupt-resume.ts +824 -0
  257. package/src/interrupt-serialization.ts +183 -0
  258. package/src/interrupts.ts +146 -0
  259. package/src/locks.ts +17 -0
  260. package/src/logger/types.ts +1 -1
  261. package/src/middlewares/otel.ts +1 -0
  262. package/src/realtime/index.ts +5 -9
  263. package/src/scope.ts +47 -0
  264. package/src/stream-durability.ts +598 -0
  265. package/src/stream-to-response.ts +1051 -95
  266. package/src/strip-to-spec-middleware.ts +3 -3
  267. package/src/types.ts +416 -24
  268. package/src/utilities/chat-params.ts +245 -55
  269. package/dist/esm/activities/index.js.map +0 -1
  270. package/dist/esm/adapter-internals.js.map +0 -1
  271. package/dist/esm/index.js.map +0 -1
  272. package/dist/esm/middlewares/index.js.map +0 -1
@@ -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
  >
@@ -254,6 +428,13 @@ export interface TextActivityOptions<
254
428
  runId?: TextOptions['runId']
255
429
  /** Parent run ID for AG-UI protocol nested run correlation. */
256
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']
257
438
  /**
258
439
  * Optional Standard Schema for structured output.
259
440
  * When provided, the activity will:
@@ -265,7 +446,7 @@ export interface TextActivityOptions<
265
446
  * @example
266
447
  * ```ts
267
448
  * const result = await chat({
268
- * adapter: openaiText('gpt-4o'),
449
+ * adapter: openaiText('gpt-5.5'),
269
450
  * messages: [{ role: 'user', content: 'Generate a person' }],
270
451
  * outputSchema: z.object({ name: z.string(), age: z.number() })
271
452
  * })
@@ -275,7 +456,7 @@ export interface TextActivityOptions<
275
456
  outputSchema?: TSchema
276
457
  /**
277
458
  * Whether to stream the text result.
278
- * When true (default), returns an AsyncIterable<StreamChunk> for streaming output.
459
+ * When true (default), returns an AsyncIterable<TypedStreamChunk<TTools>> for streaming output.
279
460
  * When false, returns a Promise<string> with the collected text content.
280
461
  *
281
462
  * Note: If outputSchema is provided, this option is ignored and the result
@@ -286,7 +467,7 @@ export interface TextActivityOptions<
286
467
  * @example Non-streaming text
287
468
  * ```ts
288
469
  * const text = await chat({
289
- * adapter: openaiText('gpt-4o'),
470
+ * adapter: openaiText('gpt-5.5'),
290
471
  * messages: [{ role: 'user', content: 'Hello!' }],
291
472
  * stream: false
292
473
  * })
@@ -301,7 +482,7 @@ export interface TextActivityOptions<
301
482
  * @example
302
483
  * ```ts
303
484
  * const stream = chat({
304
- * adapter: openaiText('gpt-4o'),
485
+ * adapter: openaiText('gpt-5.5'),
305
486
  * messages: [...],
306
487
  * middleware: [loggingMiddleware, redactionMiddleware],
307
488
  * })
@@ -367,12 +548,18 @@ export function createChatOptions<
367
548
  TTools,
368
549
  TMiddleware
369
550
  >,
370
- ): TextActivityOptions<
371
- TAdapter,
372
- TSchema,
373
- TStream,
374
- InferredContext<TTools, TMiddleware>
375
- > {
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 } {
376
563
  return options
377
564
  }
378
565
 
@@ -389,7 +576,10 @@ export function createChatOptions<
389
576
  * - If outputSchema is provided without explicit stream:true:
390
577
  * Promise<InferSchemaType<TSchema>>.
391
578
  * - If stream is explicitly false (no schema): Promise<string>.
392
- * - 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.
393
583
  *
394
584
  * `[TStream] extends [true]` is used (not `TStream extends true`) so that the
395
585
  * default `boolean` value of `TStream` does *not* match the streaming branch.
@@ -399,13 +589,23 @@ export function createChatOptions<
399
589
  export type TextActivityResult<
400
590
  TSchema extends SchemaInput | undefined,
401
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>,
402
596
  > = TSchema extends SchemaInput
403
597
  ? [TStream] extends [true]
404
598
  ? StructuredOutputStream<InferSchemaType<TSchema>>
405
599
  : Promise<InferSchemaType<TSchema>>
406
600
  : [TStream] extends [false]
407
601
  ? Promise<string>
408
- : ChatStream
602
+ : AsyncIterable<
603
+ TypedStreamChunk<
604
+ TTools extends ReadonlyArray<AnyTool>
605
+ ? TTools
606
+ : ReadonlyArray<AnyTool>
607
+ >
608
+ >
409
609
 
410
610
  // ===========================
411
611
  // ChatEngine Implementation
@@ -519,6 +719,12 @@ class TextEngine<
519
719
 
520
720
  private messages: Array<ModelMessage>
521
721
  private iterationCount = 0
722
+ /** Cumulative tool calls counted in this run (emitted + pending resume). */
723
+ private toolCallCount = 0
724
+ /** Tool calls in the most recent budgeted batch (0 when none). */
725
+ private lastTurnToolCallCount = 0
726
+ /** Tool call IDs already counted toward `toolCallCount` (avoids double-count on resume). */
727
+ private readonly countedToolCallIds = new Set<string>()
522
728
  private lastFinishReason: string | null = null
523
729
  private streamStartTime = 0
524
730
  private totalChunkCount = 0
@@ -531,12 +737,17 @@ class TextEngine<
531
737
  private eventOptions?: Record<string, unknown> | undefined
532
738
  private eventToolNames?: Array<string>
533
739
  private finishedEvent: RunFinishedEvent | null = null
740
+ private deferredToolCallRunFinishedChunks: Array<StreamChunk> = []
534
741
  private earlyTermination = false
535
742
  private toolPhase: ToolPhaseResult = 'continue'
536
743
  private cyclePhase: CyclePhase = 'processText'
537
744
  // Client state extracted from initial messages (before conversion to ModelMessage)
538
- private readonly initialApprovals: Map<string, boolean>
745
+ private readonly initialApprovals: Map<string, ToolApprovalResolution>
539
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>()
540
751
 
541
752
  // AG-UI protocol IDs
542
753
  private readonly threadId: string
@@ -554,6 +765,14 @@ class TextEngine<
554
765
  // observe both cancellation sources via ctx.abortSignal.
555
766
  private readonly toolAbortSignal?: AbortSignal
556
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>> = []
557
776
 
558
777
  private readonly logger: InternalLogger
559
778
 
@@ -660,6 +879,7 @@ class TextEngine<
660
879
  requestId: this.requestId,
661
880
  streamId: this.streamId,
662
881
  runId: this.runIdOverride ?? this.requestId,
882
+ parentRunId: this.parentRunIdOverride,
663
883
  threadId: this.threadId,
664
884
  // Legacy alias kept on the ctx so middleware that reads
665
885
  // `ctx.conversationId` keeps working. Always equals `threadId`.
@@ -707,6 +927,22 @@ class TextEngine<
707
927
  provide: (capability, value) => capability[1](this.middlewareCtx, value),
708
928
  }
709
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
+
710
946
  // Provide the internal SandboxRuntime capability so harness adapters and
711
947
  // sandbox middleware can emit file events. The sink logs, fans the event
712
948
  // out through the middleware `onFile*` hooks (fire-and-forget), and queues
@@ -797,6 +1033,7 @@ class TextEngine<
797
1033
  initialConfig,
798
1034
  )
799
1035
  this.applyMiddlewareConfig(transformedConfig)
1036
+ await this.applyEphemeralInterruptResume(transformedConfig)
800
1037
 
801
1038
  // Run onStart (devtools middleware emits text:request:started and initial messages here)
802
1039
  await this.middlewareRunner.runOnStart(this.middlewareCtx)
@@ -850,7 +1087,7 @@ class TextEngine<
850
1087
  }
851
1088
 
852
1089
  this.endCycle()
853
- } while (this.shouldContinue())
1090
+ } while (await this.shouldContinue())
854
1091
  }
855
1092
 
856
1093
  this.logger.agentLoop('run finished', {
@@ -861,12 +1098,15 @@ class TextEngine<
861
1098
  // requested AND the run hasn't already errored/aborted, run it through
862
1099
  // the middleware pipeline. The terminal hook fires once at the very
863
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.
864
1103
  //
865
1104
  // Native combined mode takes a different path: the agent loop's final-
866
1105
  // turn text IS the schema-constrained JSON, so we harvest it from
867
1106
  // `accumulatedContent` instead of issuing a second provider call.
868
1107
  if (
869
1108
  this.finalStructuredOutput &&
1109
+ this.toolPhase !== 'wait' &&
870
1110
  !this.isCancelled() &&
871
1111
  !this.finalizationError
872
1112
  ) {
@@ -914,6 +1154,41 @@ class TextEngine<
914
1154
  }
915
1155
  }
916
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
+ }
917
1192
  if (!this.terminalHookCalled) {
918
1193
  this.terminalHookCalled = true
919
1194
  if (error instanceof MiddlewareAbortError) {
@@ -922,6 +1197,7 @@ class TextEngine<
922
1197
  await this.middlewareRunner.runOnAbort(this.middlewareCtx, {
923
1198
  reason: error.message,
924
1199
  duration: Date.now() - this.streamStartTime,
1200
+ cancelRequested: isCancelRequestedReason(error.message),
925
1201
  })
926
1202
  } else {
927
1203
  // Genuine error — call onError
@@ -943,9 +1219,11 @@ class TextEngine<
943
1219
  // Check for abort terminal hook
944
1220
  if (!this.terminalHookCalled && this.isCancelled()) {
945
1221
  this.terminalHookCalled = true
1222
+ const reason = this.resolveAbortReason()
946
1223
  await this.middlewareRunner.runOnAbort(this.middlewareCtx, {
947
- reason: this.abortReason,
1224
+ reason,
948
1225
  duration: Date.now() - this.streamStartTime,
1226
+ cancelRequested: isCancelRequestedReason(reason),
949
1227
  })
950
1228
  }
951
1229
 
@@ -1048,6 +1326,15 @@ class TextEngine<
1048
1326
  ? this.finalStructuredOutput.jsonSchema
1049
1327
  : undefined
1050
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
+
1051
1338
  for await (const chunk of this.adapter.chatStream({
1052
1339
  model: this.params.model,
1053
1340
  messages: this.messages,
@@ -1063,7 +1350,7 @@ class TextEngine<
1063
1350
  // Expose provided capabilities (e.g. sandbox) to harness adapters.
1064
1351
  capabilities: this.middlewareCtx,
1065
1352
  // Client approval decisions, for harness interactive-approval resolution.
1066
- approvals: this.initialApprovals,
1353
+ approvals: adapterApprovals,
1067
1354
  ...(combinedSchema ? { outputSchema: combinedSchema } : {}),
1068
1355
  })) {
1069
1356
  if (this.isCancelled()) {
@@ -1138,6 +1425,10 @@ class TextEngine<
1138
1425
  ) {
1139
1426
  continue
1140
1427
  }
1428
+ if (this.shouldDeferToolCallRunFinished(outputChunk)) {
1429
+ this.deferredToolCallRunFinishedChunks.push(outputChunk)
1430
+ continue
1431
+ }
1141
1432
  this.logger.output(`type=${outputChunk.type}`, { chunk: outputChunk })
1142
1433
  yield outputChunk
1143
1434
  this.middlewareCtx.chunkIndex++
@@ -1302,6 +1593,10 @@ class TextEngine<
1302
1593
 
1303
1594
  const finishEvent = this.createSyntheticFinishedEvent()
1304
1595
 
1596
+ // Count is deduped so wait→resume after a live turn does not double-count.
1597
+ // Per-turn execution caps are app middleware via onBeforeToolCall skip.
1598
+ this.recordToolCalls(pendingToolCalls)
1599
+
1305
1600
  // Handle undiscovered lazy tool calls with self-correcting error messages
1306
1601
  const undiscoveredLazyResults: Array<ToolResult> = []
1307
1602
  const executablePendingCalls = pendingToolCalls.filter((tc) => {
@@ -1321,16 +1616,28 @@ class TextEngine<
1321
1616
  return true
1322
1617
  })
1323
1618
 
1324
- if (undiscoveredLazyResults.length > 0) {
1325
- for (const chunk of this.buildToolResultChunks(
1326
- undiscoveredLazyResults,
1327
- finishEvent,
1328
- )) {
1329
- yield* this.pipeThroughMiddleware(chunk)
1330
- }
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]
1623
+
1624
+ // Build args lookup so buildToolResultChunks can emit TOOL_CALL_START +
1625
+ // TOOL_CALL_ARGS before TOOL_CALL_END during continuation re-executions.
1626
+ const argsMap = new Map<string, string>()
1627
+ for (const tc of pendingToolCalls) {
1628
+ argsMap.set(tc.id, tc.function.arguments)
1331
1629
  }
1332
1630
 
1333
1631
  if (executablePendingCalls.length === 0) {
1632
+ if (deferredErrorResults.length > 0) {
1633
+ for (const chunk of this.buildToolResultChunks(
1634
+ deferredErrorResults,
1635
+ finishEvent,
1636
+ argsMap,
1637
+ )) {
1638
+ yield* this.pipeThroughMiddleware(chunk)
1639
+ }
1640
+ }
1334
1641
  return 'continue'
1335
1642
  }
1336
1643
 
@@ -1373,6 +1680,10 @@ class TextEngine<
1373
1680
  },
1374
1681
  this.middlewareCtx.context,
1375
1682
  this.toolAbortSignal,
1683
+ {
1684
+ deniedToolResults: this.resumeDeniedToolResults,
1685
+ cancelledToolCallIds: this.resumeCancelledToolCallIds,
1686
+ },
1376
1687
  )
1377
1688
 
1378
1689
  // Consume the async generator, yielding custom events and collecting the return value
@@ -1384,58 +1695,41 @@ class TextEngine<
1384
1695
  return 'stop'
1385
1696
  }
1386
1697
 
1698
+ const allResults = [...executionResult.results, ...deferredErrorResults]
1699
+
1387
1700
  // Notify middleware of tool phase completion (devtools emits aggregate events here)
1388
1701
  await this.middlewareRunner.runOnToolPhaseComplete(this.middlewareCtx, {
1389
1702
  toolCalls: pendingToolCalls,
1390
- results: executionResult.results,
1703
+ results: allResults,
1391
1704
  needsApproval: executionResult.needsApproval,
1392
1705
  needsClientExecution: executionResult.needsClientExecution,
1393
1706
  })
1394
1707
 
1395
- // Build args lookup so buildToolResultChunks can emit TOOL_CALL_START +
1396
- // TOOL_CALL_ARGS before TOOL_CALL_END during continuation re-executions.
1397
- const argsMap = new Map<string, string>()
1398
- for (const tc of pendingToolCalls) {
1399
- argsMap.set(tc.id, tc.function.arguments)
1400
- }
1401
-
1402
1708
  if (
1403
1709
  executionResult.needsApproval.length > 0 ||
1404
1710
  executionResult.needsClientExecution.length > 0
1405
1711
  ) {
1406
- if (executionResult.results.length > 0) {
1712
+ this.discardDeferredToolCallRunFinishedChunks()
1713
+
1714
+ if (allResults.length > 0) {
1407
1715
  for (const chunk of this.buildToolResultChunks(
1408
- executionResult.results,
1716
+ allResults,
1409
1717
  finishEvent,
1410
- argsMap,
1411
1718
  )) {
1412
1719
  yield* this.pipeThroughMiddleware(chunk)
1413
1720
  }
1414
1721
  }
1415
1722
 
1416
- for (const chunk of this.buildApprovalChunks(
1417
- executionResult.needsApproval,
1723
+ const emitted = yield* this.emitActionableInterruptBoundary(
1418
1724
  finishEvent,
1419
- )) {
1420
- yield* this.pipeThroughMiddleware(chunk)
1421
- }
1422
-
1423
- for (const chunk of this.buildClientToolChunks(
1725
+ executionResult.needsApproval,
1424
1726
  executionResult.needsClientExecution,
1425
- finishEvent,
1426
- )) {
1427
- yield* this.pipeThroughMiddleware(chunk)
1428
- }
1429
-
1430
- this.setToolPhase('wait')
1431
- return 'wait'
1727
+ )
1728
+ this.setToolPhase(emitted ? 'wait' : 'stop')
1729
+ return emitted ? 'wait' : 'stop'
1432
1730
  }
1433
1731
 
1434
- const toolResultChunks = this.buildToolResultChunks(
1435
- executionResult.results,
1436
- finishEvent,
1437
- argsMap,
1438
- )
1732
+ const toolResultChunks = this.buildToolResultChunks(allResults, finishEvent)
1439
1733
 
1440
1734
  for (const chunk of toolResultChunks) {
1441
1735
  yield* this.pipeThroughMiddleware(chunk)
@@ -1446,6 +1740,8 @@ class TextEngine<
1446
1740
 
1447
1741
  private async *processToolCalls(): AsyncGenerator<StreamChunk, void, void> {
1448
1742
  if (!this.shouldExecuteToolPhase()) {
1743
+ // Text-only turn — clear per-turn count so strategies see 0 tools.
1744
+ this.lastTurnToolCallCount = 0
1449
1745
  this.setToolPhase('stop')
1450
1746
  return
1451
1747
  }
@@ -1454,10 +1750,15 @@ class TextEngine<
1454
1750
  const finishEvent = this.finishedEvent
1455
1751
 
1456
1752
  if (!finishEvent || toolCalls.length === 0) {
1753
+ this.lastTurnToolCallCount = 0
1457
1754
  this.setToolPhase('stop')
1458
1755
  return
1459
1756
  }
1460
1757
 
1758
+ // Count every model-emitted tool call. Per-turn execution caps are app
1759
+ // middleware via onBeforeToolCall skip.
1760
+ this.recordToolCalls(toolCalls)
1761
+
1461
1762
  this.addAssistantToolCallMessage(toolCalls)
1462
1763
 
1463
1764
  // Handle undiscovered lazy tool calls with self-correcting error messages
@@ -1479,17 +1780,22 @@ class TextEngine<
1479
1780
  return true
1480
1781
  })
1481
1782
 
1482
- if (undiscoveredLazyResults.length > 0 && this.finishedEvent) {
1483
- for (const chunk of this.buildToolResultChunks(
1484
- undiscoveredLazyResults,
1485
- this.finishedEvent,
1486
- )) {
1487
- yield* this.pipeThroughMiddleware(chunk)
1488
- }
1489
- }
1783
+ // Non-executed outcomes (undiscovered lazy). Per-turn skips come from
1784
+ // middleware and appear in execution results.
1785
+ const deferredErrorResults = [...undiscoveredLazyResults]
1490
1786
 
1491
1787
  if (executableToolCalls.length === 0) {
1492
- // All tool calls were undiscovered lazy tools — errors emitted, continue loop
1788
+ yield* this.flushDeferredToolCallRunFinishedChunks()
1789
+ // All tool calls were undiscovered lazy tools — errors emitted, continue
1790
+ // loop (strategy / onShouldContinue may stop).
1791
+ if (deferredErrorResults.length > 0) {
1792
+ for (const chunk of this.buildToolResultChunks(
1793
+ deferredErrorResults,
1794
+ finishEvent,
1795
+ )) {
1796
+ yield* this.pipeThroughMiddleware(chunk)
1797
+ }
1798
+ }
1493
1799
  this.toolCallManager.clear()
1494
1800
  this.setToolPhase('continue')
1495
1801
  return
@@ -1535,6 +1841,10 @@ class TextEngine<
1535
1841
  },
1536
1842
  this.middlewareCtx.context,
1537
1843
  this.toolAbortSignal,
1844
+ {
1845
+ deniedToolResults: this.resumeDeniedToolResults,
1846
+ cancelledToolCallIds: this.resumeCancelledToolCallIds,
1847
+ },
1538
1848
  )
1539
1849
 
1540
1850
  // Consume the async generator, yielding custom events and collecting the return value
@@ -1548,10 +1858,13 @@ class TextEngine<
1548
1858
  return
1549
1859
  }
1550
1860
 
1861
+ // Executed results first, then deferred errors (fan-out skips / undiscovered)
1862
+ const allResults = [...executionResult.results, ...deferredErrorResults]
1863
+
1551
1864
  // Notify middleware of tool phase completion (devtools emits aggregate events here)
1552
1865
  await this.middlewareRunner.runOnToolPhaseComplete(this.middlewareCtx, {
1553
1866
  toolCalls,
1554
- results: executionResult.results,
1867
+ results: allResults,
1555
1868
  needsApproval: executionResult.needsApproval,
1556
1869
  needsClientExecution: executionResult.needsClientExecution,
1557
1870
  })
@@ -1560,37 +1873,27 @@ class TextEngine<
1560
1873
  executionResult.needsApproval.length > 0 ||
1561
1874
  executionResult.needsClientExecution.length > 0
1562
1875
  ) {
1563
- if (executionResult.results.length > 0) {
1876
+ if (allResults.length > 0) {
1564
1877
  for (const chunk of this.buildToolResultChunks(
1565
- executionResult.results,
1878
+ allResults,
1566
1879
  finishEvent,
1567
1880
  )) {
1568
1881
  yield* this.pipeThroughMiddleware(chunk)
1569
1882
  }
1570
1883
  }
1571
1884
 
1572
- for (const chunk of this.buildApprovalChunks(
1573
- executionResult.needsApproval,
1885
+ const emitted = yield* this.emitActionableInterruptBoundary(
1574
1886
  finishEvent,
1575
- )) {
1576
- yield* this.pipeThroughMiddleware(chunk)
1577
- }
1578
-
1579
- for (const chunk of this.buildClientToolChunks(
1887
+ executionResult.needsApproval,
1580
1888
  executionResult.needsClientExecution,
1581
- finishEvent,
1582
- )) {
1583
- yield* this.pipeThroughMiddleware(chunk)
1584
- }
1585
-
1586
- this.setToolPhase('wait')
1889
+ )
1890
+ this.setToolPhase(emitted ? 'wait' : 'stop')
1587
1891
  return
1588
1892
  }
1589
1893
 
1590
- const toolResultChunks = this.buildToolResultChunks(
1591
- executionResult.results,
1592
- finishEvent,
1593
- )
1894
+ yield* this.flushDeferredToolCallRunFinishedChunks()
1895
+
1896
+ const toolResultChunks = this.buildToolResultChunks(allResults, finishEvent)
1594
1897
 
1595
1898
  for (const chunk of toolResultChunks) {
1596
1899
  yield* this.pipeThroughMiddleware(chunk)
@@ -1612,6 +1915,28 @@ class TextEngine<
1612
1915
  this.setToolPhase('continue')
1613
1916
  }
1614
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
+
1615
1940
  private shouldExecuteToolPhase(): boolean {
1616
1941
  return (
1617
1942
  this.finishedEvent?.finishReason === 'tool_calls' &&
@@ -1634,6 +1959,7 @@ class TextEngine<
1634
1959
  }),
1635
1960
  },
1636
1961
  ]
1962
+ this.middlewareCtx.messages = this.messages
1637
1963
  }
1638
1964
 
1639
1965
  /**
@@ -1644,10 +1970,10 @@ class TextEngine<
1644
1970
  private extractClientStateFromOriginalMessages(
1645
1971
  originalMessages: Array<any>,
1646
1972
  ): {
1647
- approvals: Map<string, boolean>
1973
+ approvals: Map<string, ToolApprovalResolution>
1648
1974
  clientToolResults: Map<string, any>
1649
1975
  } {
1650
- const approvals = new Map<string, boolean>()
1976
+ const approvals = new Map<string, ToolApprovalResolution>()
1651
1977
  const clientToolResults = new Map<string, any>()
1652
1978
 
1653
1979
  for (const message of originalMessages) {
@@ -1676,12 +2002,18 @@ class TextEngine<
1676
2002
  }
1677
2003
 
1678
2004
  private collectClientState(): {
1679
- approvals: Map<string, boolean>
2005
+ approvals: Map<string, ToolApprovalResolution>
1680
2006
  clientToolResults: Map<string, any>
1681
2007
  } {
1682
2008
  // Start with the initial client state extracted from original messages
1683
2009
  const approvals = new Map(this.initialApprovals)
1684
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
+ }
1685
2017
 
1686
2018
  // Also check current messages for any additional tool results (from server tools)
1687
2019
  for (const message of this.messages) {
@@ -1720,54 +2052,301 @@ class TextEngine<
1720
2052
  return { approvals, clientToolResults }
1721
2053
  }
1722
2054
 
1723
- private buildApprovalChunks(
2055
+ private buildActionableInterrupts(
1724
2056
  approvals: Array<ApprovalRequest>,
1725
- finishEvent: RunFinishedEvent,
1726
- ): Array<StreamChunk> {
1727
- const chunks: Array<StreamChunk> = []
2057
+ clientRequests: Array<ClientToolRequest>,
2058
+ ): Array<Interrupt> {
2059
+ const interrupts: Array<Interrupt> = []
1728
2060
 
1729
2061
  for (const approval of approvals) {
1730
- chunks.push({
1731
- type: 'CUSTOM',
1732
- timestamp: Date.now(),
1733
- model: finishEvent.model,
1734
- name: 'approval-requested',
1735
- value: {
1736
- 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',
1737
2080
  toolName: approval.toolName,
1738
2081
  input: approval.input,
1739
- approval: {
1740
- id: approval.approvalId,
1741
- 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,
1742
2092
  },
1743
2093
  },
1744
- } as StreamChunk)
2094
+ })
1745
2095
  }
1746
2096
 
1747
- 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
1748
2128
  }
1749
2129
 
1750
- private buildClientToolChunks(
2130
+ private buildInterruptFinishedChunk(
2131
+ finishEvent: RunFinishedEvent,
2132
+ approvals: Array<ApprovalRequest>,
1751
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(
1752
2278
  finishEvent: RunFinishedEvent,
1753
- ): Array<StreamChunk> {
1754
- 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
+ }
1755
2295
 
1756
- for (const clientTool of clientRequests) {
1757
- chunks.push({
1758
- type: 'CUSTOM',
2296
+ yield* this.pipeThroughMiddleware(this.buildMessagesSnapshotChunk())
2297
+ if (this.params.state !== undefined) {
2298
+ yield* this.pipeThroughMiddleware({
2299
+ type: EventType.STATE_SNAPSHOT,
1759
2300
  timestamp: Date.now(),
1760
- model: finishEvent.model,
1761
- name: 'tool-input-available',
1762
- value: {
1763
- toolCallId: clientTool.toolCallId,
1764
- toolName: clientTool.toolName,
1765
- input: clientTool.input,
1766
- },
1767
- } as StreamChunk)
2301
+ model: this.params.model,
2302
+ snapshot: this.params.state,
2303
+ })
1768
2304
  }
2305
+ for (const output of terminalOutputs) {
2306
+ yield this.publicInterruptTerminal(output)
2307
+ this.middlewareCtx.chunkIndex++
2308
+ }
2309
+ return true
2310
+ }
1769
2311
 
1770
- 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
+ }
1771
2350
  }
1772
2351
 
1773
2352
  private buildToolResultChunks(
@@ -1791,6 +2370,7 @@ class TextEngine<
1791
2370
  // argsMap is set only on continuation re-executions, where the adapter
1792
2371
  // never streamed these calls. Otherwise it already emitted END, so a
1793
2372
  // second one here would be an orphan that fails verifyEvents (#519).
2373
+ // When we do emit END, attach parsed `input`/`output` for TypedStreamChunk.
1794
2374
  if (argsMap) {
1795
2375
  chunks.push({
1796
2376
  type: 'TOOL_CALL_START',
@@ -1799,7 +2379,7 @@ class TextEngine<
1799
2379
  toolCallId: result.toolCallId,
1800
2380
  toolCallName: result.toolName,
1801
2381
  toolName: result.toolName,
1802
- } as StreamChunk)
2382
+ })
1803
2383
 
1804
2384
  const args = argsMap.get(result.toolCallId) ?? '{}'
1805
2385
  chunks.push({
@@ -1819,8 +2399,10 @@ class TextEngine<
1819
2399
  toolCallName: result.toolName,
1820
2400
  toolName: result.toolName,
1821
2401
  result: wireContent,
2402
+ ...(result.input !== undefined && { input: result.input }),
2403
+ ...(result.output !== undefined && { output: result.output }),
1822
2404
  ...(result.state !== undefined && { state: result.state }),
1823
- } as StreamChunk)
2405
+ })
1824
2406
  }
1825
2407
 
1826
2408
  // AG-UI spec TOOL_CALL_RESULT event (content is string-only per spec)
@@ -1868,6 +2450,7 @@ class TextEngine<
1868
2450
  } else {
1869
2451
  this.messages = [...this.messages, newToolMessage]
1870
2452
  }
2453
+ this.middlewareCtx.messages = this.messages
1871
2454
  }
1872
2455
 
1873
2456
  return chunks
@@ -1922,6 +2505,54 @@ class TextEngine<
1922
2505
  return pending
1923
2506
  }
1924
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
+
1925
2556
  private createSyntheticFinishedEvent(): RunFinishedEvent {
1926
2557
  return {
1927
2558
  type: 'RUN_FINISHED',
@@ -1933,20 +2564,55 @@ class TextEngine<
1933
2564
  } as RunFinishedEvent
1934
2565
  }
1935
2566
 
1936
- private shouldContinue(): boolean {
2567
+ private async shouldContinue(): Promise<boolean> {
2568
+ // Always enter the tool-execution half-cycle after a model turn.
1937
2569
  if (this.cyclePhase === 'executeToolCalls') {
1938
2570
  return true
1939
2571
  }
1940
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
+
1941
2590
  return (
1942
- this.loopStrategy({
1943
- iterationCount: this.iterationCount,
1944
- messages: this.messages,
1945
- finishReason: this.lastFinishReason,
1946
- }) && this.toolPhase === 'continue'
2591
+ strategyContinues && middlewareContinues && this.toolPhase === 'continue'
1947
2592
  )
1948
2593
  }
1949
2594
 
2595
+ /**
2596
+ * Record tool calls (deduped by id) toward `toolCallCount` /
2597
+ * `lastTurnToolCallCount` for strategies and middleware `onShouldContinue`.
2598
+ *
2599
+ * Used for both live model turns and pending/resume batches. IDs already
2600
+ * counted in this run (e.g. wait→resume after a live turn) are not
2601
+ * re-added to `toolCallCount`. Per-turn execution caps are app middleware
2602
+ * (`onBeforeToolCall` skip), not engine policy.
2603
+ */
2604
+ private recordToolCalls(toolCalls: Array<ToolCall>): void {
2605
+ this.lastTurnToolCallCount = toolCalls.length
2606
+ let newlyCounted = 0
2607
+ for (const tc of toolCalls) {
2608
+ if (!this.countedToolCallIds.has(tc.id)) {
2609
+ this.countedToolCallIds.add(tc.id)
2610
+ newlyCounted++
2611
+ }
2612
+ }
2613
+ this.toolCallCount += newlyCounted
2614
+ }
2615
+
1950
2616
  private isAborted(): boolean {
1951
2617
  return !!this.effectiveSignal?.aborted
1952
2618
  }
@@ -1959,6 +2625,100 @@ class TextEngine<
1959
2625
  return this.isAborted() || this.isMiddlewareAborted()
1960
2626
  }
1961
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
+
1962
2722
  /**
1963
2723
  * Run the final structured-output adapter call through the middleware
1964
2724
  * pipeline. Yields chunks to the caller only when
@@ -2514,12 +3274,181 @@ class TextEngine<
2514
3274
  messages: this.messages,
2515
3275
  systemPrompts: [...this.systemPrompts],
2516
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
+ },
2517
3284
  metadata: this.params.metadata,
2518
3285
  modelOptions: this.params.modelOptions,
2519
3286
  }
2520
3287
  }
2521
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
+
2522
3450
  private applyMiddlewareConfig(config: ChatMiddlewareConfig): void {
3451
+ this.applyResumeToolState(config.resumeToolState)
2523
3452
  this.messages = config.messages
2524
3453
  this.systemPrompts = config.systemPrompts
2525
3454
  this.tools = config.tools
@@ -2610,7 +3539,7 @@ class TextEngine<
2610
3539
  model: this.params.model,
2611
3540
  name: eventName,
2612
3541
  value,
2613
- } as CustomEvent
3542
+ }
2614
3543
  }
2615
3544
 
2616
3545
  private createId(prefix: string): string {
@@ -2637,7 +3566,7 @@ class TextEngine<
2637
3566
  * import { openaiText } from '@tanstack/ai-openai'
2638
3567
  *
2639
3568
  * for await (const chunk of chat({
2640
- * adapter: openaiText('gpt-4o'),
3569
+ * adapter: openaiText('gpt-5.5'),
2641
3570
  * messages: [{ role: 'user', content: 'What is the weather?' }],
2642
3571
  * tools: [weatherTool]
2643
3572
  * })) {
@@ -2650,7 +3579,7 @@ class TextEngine<
2650
3579
  * @example One-shot text (streaming without tools)
2651
3580
  * ```ts
2652
3581
  * for await (const chunk of chat({
2653
- * adapter: openaiText('gpt-4o'),
3582
+ * adapter: openaiText('gpt-5.5'),
2654
3583
  * messages: [{ role: 'user', content: 'Hello!' }]
2655
3584
  * })) {
2656
3585
  * console.log(chunk)
@@ -2660,7 +3589,7 @@ class TextEngine<
2660
3589
  * @example Non-streaming text (stream: false)
2661
3590
  * ```ts
2662
3591
  * const text = await chat({
2663
- * adapter: openaiText('gpt-4o'),
3592
+ * adapter: openaiText('gpt-5.5'),
2664
3593
  * messages: [{ role: 'user', content: 'Hello!' }],
2665
3594
  * stream: false
2666
3595
  * })
@@ -2672,7 +3601,7 @@ class TextEngine<
2672
3601
  * import { z } from 'zod'
2673
3602
  *
2674
3603
  * const result = await chat({
2675
- * adapter: openaiText('gpt-4o'),
3604
+ * adapter: openaiText('gpt-5.5'),
2676
3605
  * messages: [{ role: 'user', content: 'Research and summarize the topic' }],
2677
3606
  * tools: [researchTool, analyzeTool],
2678
3607
  * outputSchema: z.object({
@@ -2712,7 +3641,7 @@ export function chat<
2712
3641
  TTools,
2713
3642
  TMiddleware
2714
3643
  >,
2715
- ): TextActivityResult<TSchema, TStream> {
3644
+ ): TextActivityResult<TSchema, TStream, TTools> {
2716
3645
  validateCapabilities(options.middleware ?? [], options.adapter)
2717
3646
 
2718
3647
  const { outputSchema, stream } = options
@@ -2725,7 +3654,7 @@ export function chat<
2725
3654
  ...options,
2726
3655
  outputSchema,
2727
3656
  stream,
2728
- }) as TextActivityResult<TSchema, TStream>
3657
+ }) as TextActivityResult<TSchema, TStream, TTools>
2729
3658
  }
2730
3659
 
2731
3660
  // If outputSchema is provided, run agentic structured output (Promise<T>)
@@ -2733,7 +3662,7 @@ export function chat<
2733
3662
  return runAgenticStructuredOutput({
2734
3663
  ...options,
2735
3664
  outputSchema,
2736
- }) as TextActivityResult<TSchema, TStream>
3665
+ }) as TextActivityResult<TSchema, TStream, TTools>
2737
3666
  }
2738
3667
 
2739
3668
  // If stream is explicitly false, run non-streaming text
@@ -2742,7 +3671,7 @@ export function chat<
2742
3671
  ...options,
2743
3672
  outputSchema: undefined,
2744
3673
  stream,
2745
- }) as TextActivityResult<TSchema, TStream>
3674
+ }) as TextActivityResult<TSchema, TStream, TTools>
2746
3675
  }
2747
3676
 
2748
3677
  // Otherwise, run streaming text (default)
@@ -2750,14 +3679,70 @@ export function chat<
2750
3679
  ...options,
2751
3680
  outputSchema: undefined,
2752
3681
  stream,
2753
- }) 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
+ }
3696
+ }
3697
+
3698
+ /**
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).
3705
+ */
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
+ })
2754
3722
  }
2755
3723
 
2756
3724
  /**
2757
- * Run streaming text (agentic or one-shot depending on tools)
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.
2758
3733
  */
2759
- async function* runStreamingText<TContext = unknown>(
3734
+ function runStreamingText<TContext = unknown>(
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>(
2760
3744
  options: TextActivityOptions<AnyTextAdapter, undefined, true, TContext>,
3745
+ engineRef: DeliveryEngineRef,
2761
3746
  ): AsyncIterable<StreamChunk> {
2762
3747
  const { adapter, middleware, context, debug, mcp, ...textOptions } = options
2763
3748
  const model = adapter.model
@@ -2782,6 +3767,7 @@ async function* runStreamingText<TContext = unknown>(
2782
3767
  },
2783
3768
  logger,
2784
3769
  )
3770
+ engineRef.current = engine
2785
3771
 
2786
3772
  try {
2787
3773
  for await (const chunk of engine.run()) {
@@ -2801,7 +3787,7 @@ function runNonStreamingText<TContext = unknown>(
2801
3787
  ): Promise<string> {
2802
3788
  // Run the streaming text and collect all text using streamToText.
2803
3789
  const stream = runStreamingText(
2804
- // 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.
2805
3791
  options as unknown as TextActivityOptions<
2806
3792
  AnyTextAdapter,
2807
3793
  undefined,
@@ -3125,25 +4111,36 @@ function runStreamingStructuredOutput<
3125
4111
  undoNullWidening(data, nullWideningMap)
3126
4112
 
3127
4113
  // The implementation generator yields the broader internal type
3128
- // (`StreamChunk | StructuredOutputCompleteEvent<T>`) so agent-loop
3129
- // CustomEvents can flow through; the public-facing type narrows to
3130
- // `Exclude<StreamChunk, CustomEvent> | StructuredOutputCompleteEvent<T>`
3131
- // which lets consumers narrow `chunk.value` cleanly. The widen→narrow
3132
- // is contained here so consumers see only the strict type.
3133
- 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(
3134
4128
  options,
3135
4129
  jsonSchema,
3136
4130
  normalize,
3137
- ) as StructuredOutputStream<InferSchemaType<TSchema>>
4131
+ engineRef,
4132
+ )
4133
+ publishDeliverySeams(stream, engineRef)
4134
+ return stream as StructuredOutputStream<InferSchemaType<TSchema>>
3138
4135
  }
3139
4136
 
3140
4137
  /**
3141
4138
  * Internal generator return type — broader than the public
3142
- * `StructuredOutputStream<T>`. The public type pins three tagged `CUSTOM`
3143
- * events (`structured-output.complete`, `approval-requested`,
3144
- * `tool-input-available`) so consumers can narrow `chunk.value` cleanly by
3145
- * literal `name`. At runtime, tools can also emit arbitrary user-defined
3146
- * `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
3147
4144
  * through this generator with `name: string` and are widened out at the
3148
4145
  * public boundary because keeping them would collapse the typed narrow back
3149
4146
  * to `any`. The cast inside `runStreamingStructuredOutput` is where that
@@ -3160,6 +4157,7 @@ async function* runStreamingStructuredOutputImpl<
3160
4157
  options: TextActivityOptions<AnyTextAdapter, TSchema, true, TContext>,
3161
4158
  jsonSchema: NonNullable<ReturnType<typeof convertSchemaToJsonSchema>>,
3162
4159
  normalize: (data: unknown) => unknown,
4160
+ engineRef: DeliveryEngineRef,
3163
4161
  ): StructuredOutputStreamInternal<InferSchemaType<TSchema>> {
3164
4162
  const {
3165
4163
  adapter,
@@ -3210,6 +4208,7 @@ async function* runStreamingStructuredOutputImpl<
3210
4208
  },
3211
4209
  logger,
3212
4210
  )
4211
+ engineRef.current = engine
3213
4212
 
3214
4213
  try {
3215
4214
  for await (const chunk of engine.run()) {