@pikku/core 0.12.63 → 0.12.64

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 (198) hide show
  1. package/CHANGELOG.md +147 -0
  2. package/dist/dev/hot-reload.js +13 -55
  3. package/dist/dev/module-runner.d.ts +14 -0
  4. package/dist/dev/module-runner.js +59 -0
  5. package/dist/dev/reload-meta.d.ts +13 -0
  6. package/dist/dev/reload-meta.js +22 -0
  7. package/dist/errors/errors.d.ts +12 -0
  8. package/dist/errors/errors.js +19 -0
  9. package/dist/function/function-runner.d.ts +5 -5
  10. package/dist/function/function-runner.js +39 -111
  11. package/dist/function/functions.types.d.ts +17 -7
  12. package/dist/index.d.ts +5 -1
  13. package/dist/index.js +3 -1
  14. package/dist/middleware/auth-apikey.d.ts +1 -0
  15. package/dist/middleware/auth-bearer.d.ts +1 -0
  16. package/dist/middleware/auth-cookie.d.ts +1 -0
  17. package/dist/middleware/cors.d.ts +1 -0
  18. package/dist/middleware/index.d.ts +1 -1
  19. package/dist/middleware/index.js +1 -1
  20. package/dist/middleware/remote-auth.d.ts +1 -0
  21. package/dist/middleware/telemetry.d.ts +2 -0
  22. package/dist/middleware/timeout.d.ts +1 -0
  23. package/dist/permissions.d.ts +32 -25
  24. package/dist/permissions.js +104 -185
  25. package/dist/pikku-state.js +0 -2
  26. package/dist/scopes.d.ts +17 -0
  27. package/dist/scopes.js +59 -0
  28. package/dist/services/ai-embedding-service.d.ts +31 -0
  29. package/dist/services/ai-embedding-service.js +1 -0
  30. package/dist/services/credential-wire-service.d.ts +3 -1
  31. package/dist/services/credential-wire-service.js +10 -4
  32. package/dist/services/index.d.ts +4 -0
  33. package/dist/services/index.js +2 -0
  34. package/dist/services/meta-service.d.ts +15 -2
  35. package/dist/services/meta-service.js +51 -1
  36. package/dist/services/queue-webhook-service.d.ts +55 -0
  37. package/dist/services/queue-webhook-service.js +136 -0
  38. package/dist/services/scope-service.d.ts +67 -0
  39. package/dist/services/scope-service.js +1 -0
  40. package/dist/services/typed-secret-service.d.ts +8 -0
  41. package/dist/services/typed-secret-service.js +39 -4
  42. package/dist/services/webhook-service.d.ts +140 -0
  43. package/dist/services/webhook-service.js +44 -0
  44. package/dist/services/workflow-service.d.ts +7 -0
  45. package/dist/types/core.types.d.ts +34 -6
  46. package/dist/types/state.types.d.ts +20 -2
  47. package/dist/utils/hmac.d.ts +16 -0
  48. package/dist/utils/hmac.js +26 -0
  49. package/dist/utils/safe-fetch.d.ts +51 -0
  50. package/dist/utils/safe-fetch.js +192 -0
  51. package/dist/wirings/ai-agent/ai-agent-agui.d.ts +84 -0
  52. package/dist/wirings/ai-agent/ai-agent-agui.js +305 -0
  53. package/dist/wirings/ai-agent/ai-agent-prepare.d.ts +41 -1
  54. package/dist/wirings/ai-agent/ai-agent-prepare.js +146 -6
  55. package/dist/wirings/ai-agent/ai-agent-runner.js +6 -1
  56. package/dist/wirings/ai-agent/ai-agent-stream.js +61 -10
  57. package/dist/wirings/ai-agent/ai-agent.types.d.ts +27 -1
  58. package/dist/wirings/ai-agent/index.d.ts +1 -0
  59. package/dist/wirings/ai-agent/index.js +1 -0
  60. package/dist/wirings/ai-agent/voice-input.d.ts +1 -0
  61. package/dist/wirings/ai-agent/voice-input.js +2 -38
  62. package/dist/wirings/ai-agent/voice-output.d.ts +1 -0
  63. package/dist/wirings/channel/channel-common.js +0 -1
  64. package/dist/wirings/channel/channel-handler.js +1 -4
  65. package/dist/wirings/channel/channel-middleware-runner.d.ts +12 -0
  66. package/dist/wirings/channel/channel-middleware-runner.js +34 -19
  67. package/dist/wirings/channel/channel.types.d.ts +2 -6
  68. package/dist/wirings/cli/cli-runner.js +0 -2
  69. package/dist/wirings/cli/cli.types.d.ts +1 -2
  70. package/dist/wirings/http/http-routes.js +0 -3
  71. package/dist/wirings/http/http-runner.d.ts +9 -29
  72. package/dist/wirings/http/http-runner.js +9 -34
  73. package/dist/wirings/http/http.types.d.ts +1 -9
  74. package/dist/wirings/mcp/mcp-runner.js +0 -2
  75. package/dist/wirings/mcp/mcp.types.d.ts +5 -11
  76. package/dist/wirings/oauth2/index.d.ts +0 -3
  77. package/dist/wirings/oauth2/index.js +1 -2
  78. package/dist/wirings/rpc/addon-runner.d.ts +28 -0
  79. package/dist/wirings/rpc/addon-runner.js +173 -0
  80. package/dist/wirings/rpc/index.d.ts +5 -1
  81. package/dist/wirings/rpc/index.js +3 -1
  82. package/dist/wirings/rpc/remote-addon-auth.d.ts +26 -0
  83. package/dist/wirings/rpc/remote-addon-auth.js +43 -0
  84. package/dist/wirings/rpc/rpc-runner.d.ts +19 -0
  85. package/dist/wirings/rpc/rpc-runner.js +109 -3
  86. package/dist/wirings/rpc/rpc-types.d.ts +15 -1
  87. package/dist/wirings/rpc/wire-addon.js +9 -0
  88. package/dist/wirings/rpc/wire-remote-addon.d.ts +47 -0
  89. package/dist/wirings/rpc/wire-remote-addon.js +19 -0
  90. package/dist/wirings/scope/index.d.ts +3 -0
  91. package/dist/wirings/scope/index.js +2 -0
  92. package/dist/wirings/scope/scope.types.d.ts +39 -0
  93. package/dist/wirings/scope/scope.types.js +1 -0
  94. package/dist/wirings/scope/validate-scope-definitions.d.ts +16 -0
  95. package/dist/wirings/scope/validate-scope-definitions.js +76 -0
  96. package/dist/wirings/scope/wire-scope.d.ts +33 -0
  97. package/dist/wirings/scope/wire-scope.js +32 -0
  98. package/dist/wirings/workflow/dsl/index.d.ts +1 -1
  99. package/dist/wirings/workflow/dsl/workflow-dsl.types.d.ts +53 -1
  100. package/dist/wirings/workflow/graph/graph-node.d.ts +2 -0
  101. package/dist/wirings/workflow/graph/graph-runner.js +3 -0
  102. package/dist/wirings/workflow/graph/wire-workflow-graph.d.ts +6 -0
  103. package/dist/wirings/workflow/graph/wire-workflow-graph.js +1 -0
  104. package/dist/wirings/workflow/graph/workflow-graph.types.d.ts +5 -0
  105. package/dist/wirings/workflow/index.d.ts +2 -2
  106. package/dist/wirings/workflow/index.js +1 -1
  107. package/dist/wirings/workflow/pikku-workflow-service.d.ts +51 -1
  108. package/dist/wirings/workflow/pikku-workflow-service.js +171 -0
  109. package/dist/wirings/workflow/workflow.types.d.ts +1 -1
  110. package/package.json +5 -1
  111. package/run-tests.sh +1 -0
  112. package/src/dev/hot-reload.ts +13 -68
  113. package/src/dev/module-runner.test.ts +169 -0
  114. package/src/dev/module-runner.ts +103 -0
  115. package/src/dev/reload-meta.test.ts +31 -2
  116. package/src/dev/reload-meta.ts +26 -0
  117. package/src/errors/errors.ts +24 -0
  118. package/src/function/function-runner.test.ts +276 -142
  119. package/src/function/function-runner.ts +52 -145
  120. package/src/function/functions.types.ts +28 -38
  121. package/src/index.ts +8 -6
  122. package/src/middleware/index.ts +1 -5
  123. package/src/permissions.test.ts +153 -372
  124. package/src/permissions.ts +139 -259
  125. package/src/pikku-state.ts +0 -2
  126. package/src/scopes.test.ts +167 -0
  127. package/src/scopes.ts +69 -0
  128. package/src/services/ai-embedding-service.ts +31 -0
  129. package/src/services/credential-wire-service.ts +10 -4
  130. package/src/services/index.ts +20 -0
  131. package/src/services/meta-service.ts +68 -3
  132. package/src/services/queue-webhook-service.test.ts +408 -0
  133. package/src/services/queue-webhook-service.ts +182 -0
  134. package/src/services/scope-service.ts +75 -0
  135. package/src/services/typed-secret-service.test.ts +35 -0
  136. package/src/services/typed-secret-service.ts +39 -4
  137. package/src/services/webhook-service.ts +180 -0
  138. package/src/services/workflow-service.ts +7 -0
  139. package/src/types/core.types.ts +42 -14
  140. package/src/types/state.types.ts +23 -3
  141. package/src/utils/hmac.ts +27 -0
  142. package/src/utils/safe-fetch.test.ts +373 -0
  143. package/src/utils/safe-fetch.ts +213 -0
  144. package/src/wirings/ai-agent/ai-agent-agui.test.ts +1127 -0
  145. package/src/wirings/ai-agent/ai-agent-agui.ts +386 -0
  146. package/src/wirings/ai-agent/ai-agent-prepare.test.ts +308 -0
  147. package/src/wirings/ai-agent/ai-agent-prepare.ts +192 -8
  148. package/src/wirings/ai-agent/ai-agent-runner.test.ts +48 -1
  149. package/src/wirings/ai-agent/ai-agent-runner.ts +21 -0
  150. package/src/wirings/ai-agent/ai-agent-stream.test.ts +198 -10
  151. package/src/wirings/ai-agent/ai-agent-stream.ts +84 -10
  152. package/src/wirings/ai-agent/ai-agent.types.ts +28 -0
  153. package/src/wirings/ai-agent/index.ts +1 -0
  154. package/src/wirings/ai-agent/voice-input.ts +6 -35
  155. package/src/wirings/channel/channel-common.ts +0 -1
  156. package/src/wirings/channel/channel-handler.ts +1 -9
  157. package/src/wirings/channel/channel-middleware-runner.test.ts +28 -2
  158. package/src/wirings/channel/channel-middleware-runner.ts +39 -27
  159. package/src/wirings/channel/channel.types.ts +0 -6
  160. package/src/wirings/cli/cli-runner.ts +0 -2
  161. package/src/wirings/cli/cli.types.ts +0 -2
  162. package/src/wirings/http/http-routes.ts +0 -3
  163. package/src/wirings/http/http-runner.test.ts +1 -11
  164. package/src/wirings/http/http-runner.ts +16 -39
  165. package/src/wirings/http/http.types.ts +1 -10
  166. package/src/wirings/mcp/mcp-runner.ts +0 -2
  167. package/src/wirings/mcp/mcp.types.ts +3 -11
  168. package/src/wirings/oauth2/index.ts +0 -3
  169. package/src/wirings/rpc/addon-runner.ts +233 -0
  170. package/src/wirings/rpc/index.ts +17 -1
  171. package/src/wirings/rpc/remote-addon-auth.ts +69 -0
  172. package/src/wirings/rpc/rpc-runner.test.ts +319 -16
  173. package/src/wirings/rpc/rpc-runner.ts +155 -3
  174. package/src/wirings/rpc/rpc-types.ts +16 -5
  175. package/src/wirings/rpc/wire-addon.test.ts +6 -3
  176. package/src/wirings/rpc/wire-addon.ts +9 -0
  177. package/src/wirings/rpc/wire-remote-addon.ts +57 -0
  178. package/src/wirings/scope/index.ts +14 -0
  179. package/src/wirings/scope/scope.test.ts +135 -0
  180. package/src/wirings/scope/scope.types.ts +44 -0
  181. package/src/wirings/scope/validate-scope-definitions.ts +110 -0
  182. package/src/wirings/scope/wire-scope.ts +34 -0
  183. package/src/wirings/workflow/dsl/index.ts +4 -0
  184. package/src/wirings/workflow/dsl/workflow-dsl.types.ts +61 -0
  185. package/src/wirings/workflow/graph/graph-node.ts +2 -0
  186. package/src/wirings/workflow/graph/graph-runner.test.ts +58 -0
  187. package/src/wirings/workflow/graph/graph-runner.ts +3 -0
  188. package/src/wirings/workflow/graph/wire-workflow-graph.ts +7 -0
  189. package/src/wirings/workflow/graph/workflow-graph.types.ts +5 -0
  190. package/src/wirings/workflow/index.ts +5 -0
  191. package/src/wirings/workflow/pikku-workflow-service.test.ts +341 -0
  192. package/src/wirings/workflow/pikku-workflow-service.ts +236 -0
  193. package/src/wirings/workflow/workflow-step-session.test.ts +21 -5
  194. package/src/wirings/workflow/workflow.types.ts +4 -0
  195. package/tsconfig.tsbuildinfo +1 -1
  196. package/src/wirings/oauth2/oauth2-client.test.ts +0 -930
  197. package/src/wirings/oauth2/oauth2-client.ts +0 -331
  198. package/src/wirings/oauth2/oauth2-routes.ts +0 -234
@@ -0,0 +1,1127 @@
1
+ import { describe, it, mock } from 'node:test'
2
+ import assert from 'node:assert/strict'
3
+ import { EventSchemas } from '@ag-ui/core'
4
+ import { wrapChannelWithAGUI } from './ai-agent-agui.js'
5
+ import type { AIStreamChannel, AIStreamEvent } from './ai-agent.types.js'
6
+
7
+ function makeChannel(): { channel: AIStreamChannel; events: unknown[] } {
8
+ const events: unknown[] = []
9
+ const channel: AIStreamChannel = {
10
+ channelId: 'test-id',
11
+ openingData: null,
12
+ state: 'open',
13
+ setState: () => {},
14
+ getState: () => undefined as any,
15
+ clearState: () => {},
16
+ sendBinary: () => {},
17
+ close: () => {},
18
+ send: (e) => events.push(e),
19
+ }
20
+ return { channel, events }
21
+ }
22
+
23
+ function types(events: unknown[]): string[] {
24
+ return (events as any[]).map((e) => e.type)
25
+ }
26
+
27
+ function find<T = any>(
28
+ events: unknown[],
29
+ type: string,
30
+ name?: string
31
+ ): T | undefined {
32
+ return (events as any[]).find(
33
+ (e) => e.type === type && (name === undefined || e.name === name)
34
+ ) as T
35
+ }
36
+
37
+ describe('wrapChannelWithAGUI — text streaming', () => {
38
+ it('sends TEXT_MESSAGE_START only once for consecutive text-deltas', () => {
39
+ const { channel, events } = makeChannel()
40
+ const wrapped = wrapChannelWithAGUI(channel)
41
+
42
+ wrapped.send({ type: 'text-delta', text: 'a' } as AIStreamEvent)
43
+ wrapped.send({ type: 'text-delta', text: 'b' } as AIStreamEvent)
44
+ wrapped.send({ type: 'text-delta', text: 'c' } as AIStreamEvent)
45
+
46
+ assert.equal(
47
+ events.filter((e: any) => e.type === 'TEXT_MESSAGE_START').length,
48
+ 1
49
+ )
50
+ assert.equal(
51
+ events.filter((e: any) => e.type === 'TEXT_MESSAGE_CONTENT').length,
52
+ 3
53
+ )
54
+ })
55
+
56
+ it('uses the same messageId for START, all CONTENTs, and END', () => {
57
+ const { channel, events } = makeChannel()
58
+ const wrapped = wrapChannelWithAGUI(channel)
59
+
60
+ wrapped.send({ type: 'text-delta', text: 'hello' } as AIStreamEvent)
61
+ wrapped.send({ type: 'text-delta', text: ' world' } as AIStreamEvent)
62
+ wrapped.send({ type: 'done' } as AIStreamEvent)
63
+
64
+ const start = find(events, 'TEXT_MESSAGE_START')
65
+ const end = find(events, 'TEXT_MESSAGE_END')
66
+ const contents = (events as any[]).filter(
67
+ (e) => e.type === 'TEXT_MESSAGE_CONTENT'
68
+ )
69
+
70
+ assert.ok(start)
71
+ assert.ok(end)
72
+ assert.equal(end.messageId, start.messageId)
73
+ for (const c of contents) {
74
+ assert.equal(c.messageId, start.messageId)
75
+ }
76
+ })
77
+
78
+ it('closes open text message when done arrives', () => {
79
+ const { channel, events } = makeChannel()
80
+ const wrapped = wrapChannelWithAGUI(channel)
81
+
82
+ wrapped.send({ type: 'text-delta', text: 'hi' } as AIStreamEvent)
83
+ wrapped.send({ type: 'done' } as AIStreamEvent)
84
+
85
+ assert.ok(find(events, 'TEXT_MESSAGE_END'))
86
+ })
87
+
88
+ it('closes open text message when error arrives', () => {
89
+ const { channel, events } = makeChannel()
90
+ const wrapped = wrapChannelWithAGUI(channel)
91
+
92
+ wrapped.send({ type: 'text-delta', text: 'partial' } as AIStreamEvent)
93
+ wrapped.send({ type: 'error', message: 'oops' } as AIStreamEvent)
94
+
95
+ const t = types(events)
96
+ assert.ok(t.indexOf('TEXT_MESSAGE_END') < t.indexOf('RUN_ERROR'))
97
+ })
98
+
99
+ it('does not emit TEXT_MESSAGE_END when no text was sent', () => {
100
+ const { channel, events } = makeChannel()
101
+ const wrapped = wrapChannelWithAGUI(channel)
102
+
103
+ wrapped.send({ type: 'done' } as AIStreamEvent)
104
+
105
+ assert.equal(
106
+ events.filter((e: any) => e.type === 'TEXT_MESSAGE_END').length,
107
+ 0
108
+ )
109
+ })
110
+ })
111
+
112
+ describe('wrapChannelWithAGUI — reasoning streaming', () => {
113
+ it('sends THINKING_TEXT_MESSAGE_START only once for consecutive reasoning-deltas', () => {
114
+ const { channel, events } = makeChannel()
115
+ const wrapped = wrapChannelWithAGUI(channel)
116
+
117
+ wrapped.send({ type: 'reasoning-delta', text: 'step 1' } as AIStreamEvent)
118
+ wrapped.send({ type: 'reasoning-delta', text: 'step 2' } as AIStreamEvent)
119
+
120
+ assert.equal(
121
+ events.filter((e: any) => e.type === 'THINKING_TEXT_MESSAGE_START')
122
+ .length,
123
+ 1
124
+ )
125
+ assert.equal(
126
+ events.filter((e: any) => e.type === 'THINKING_TEXT_MESSAGE_CONTENT')
127
+ .length,
128
+ 2
129
+ )
130
+ })
131
+
132
+ it('closes thinking message and opens text message when switching', () => {
133
+ const { channel, events } = makeChannel()
134
+ const wrapped = wrapChannelWithAGUI(channel)
135
+
136
+ wrapped.send({ type: 'reasoning-delta', text: 'thinking' } as AIStreamEvent)
137
+ wrapped.send({ type: 'text-delta', text: 'answer' } as AIStreamEvent)
138
+
139
+ const t = types(events)
140
+ assert.ok(t.includes('THINKING_TEXT_MESSAGE_END'))
141
+ assert.ok(
142
+ t.indexOf('THINKING_TEXT_MESSAGE_END') < t.indexOf('TEXT_MESSAGE_START')
143
+ )
144
+ })
145
+
146
+ it('closes thinking message before tool-call', () => {
147
+ const { channel, events } = makeChannel()
148
+ const wrapped = wrapChannelWithAGUI(channel)
149
+
150
+ wrapped.send({
151
+ type: 'reasoning-delta',
152
+ text: 'reasoning',
153
+ } as AIStreamEvent)
154
+ wrapped.send({
155
+ type: 'tool-call',
156
+ toolCallId: 'tc1',
157
+ toolName: 'fn',
158
+ args: {},
159
+ } as AIStreamEvent)
160
+
161
+ const t = types(events)
162
+ assert.ok(
163
+ t.indexOf('THINKING_TEXT_MESSAGE_END') < t.indexOf('TOOL_CALL_START')
164
+ )
165
+ })
166
+
167
+ it('uses consistent messageId across THINKING START, CONTENT, END', () => {
168
+ const { channel, events } = makeChannel()
169
+ const wrapped = wrapChannelWithAGUI(channel)
170
+
171
+ wrapped.send({ type: 'reasoning-delta', text: 'reason' } as AIStreamEvent)
172
+ wrapped.send({ type: 'done' } as AIStreamEvent)
173
+
174
+ const start = find(events, 'THINKING_TEXT_MESSAGE_START')
175
+ const end = find(events, 'THINKING_TEXT_MESSAGE_END')
176
+ const content = find(events, 'THINKING_TEXT_MESSAGE_CONTENT')
177
+
178
+ assert.ok(start && end && content)
179
+ assert.equal(content.messageId, start.messageId)
180
+ assert.equal(end.messageId, start.messageId)
181
+ })
182
+ })
183
+
184
+ describe('wrapChannelWithAGUI — tool calls', () => {
185
+ it('closes text message before TOOL_CALL_START', () => {
186
+ const { channel, events } = makeChannel()
187
+ const wrapped = wrapChannelWithAGUI(channel)
188
+
189
+ wrapped.send({ type: 'text-delta', text: 'preamble' } as AIStreamEvent)
190
+ wrapped.send({
191
+ type: 'tool-call',
192
+ toolCallId: 'tc1',
193
+ toolName: 'myTool',
194
+ args: { x: 1 },
195
+ } as AIStreamEvent)
196
+
197
+ const t = types(events)
198
+ assert.ok(t.indexOf('TEXT_MESSAGE_END') < t.indexOf('TOOL_CALL_START'))
199
+ })
200
+
201
+ it('emits TOOL_CALL_START, TOOL_CALL_ARGS, TOOL_CALL_END in sequence', () => {
202
+ const { channel, events } = makeChannel()
203
+ const wrapped = wrapChannelWithAGUI(channel)
204
+
205
+ wrapped.send({
206
+ type: 'tool-call',
207
+ toolCallId: 'tc1',
208
+ toolName: 'search',
209
+ args: { q: 'hi' },
210
+ } as AIStreamEvent)
211
+
212
+ const t = types(events)
213
+ const startIdx = t.indexOf('TOOL_CALL_START')
214
+ const argsIdx = t.indexOf('TOOL_CALL_ARGS')
215
+ const endIdx = t.indexOf('TOOL_CALL_END')
216
+ assert.ok(startIdx !== -1 && argsIdx !== -1 && endIdx !== -1)
217
+ assert.equal(argsIdx, startIdx + 1)
218
+ assert.equal(endIdx, argsIdx + 1)
219
+ })
220
+
221
+ it('propagates toolCallId and toolCallName to START, ARGS and END', () => {
222
+ const { channel, events } = makeChannel()
223
+ const wrapped = wrapChannelWithAGUI(channel)
224
+
225
+ wrapped.send({
226
+ type: 'tool-call',
227
+ toolCallId: 'abc',
228
+ toolName: 'myFunc',
229
+ args: { n: 42 },
230
+ } as AIStreamEvent)
231
+
232
+ const start = find(events, 'TOOL_CALL_START')
233
+ const args = find(events, 'TOOL_CALL_ARGS')
234
+ const end = find(events, 'TOOL_CALL_END')
235
+ assert.equal(start.toolCallId, 'abc')
236
+ assert.equal(start.toolCallName, 'myFunc')
237
+ assert.equal(args.toolCallId, 'abc')
238
+ assert.equal(args.delta, '{"n":42}')
239
+ assert.equal(end.toolCallId, 'abc')
240
+ })
241
+
242
+ it('handles multiple tool calls in sequence', () => {
243
+ const { channel, events } = makeChannel()
244
+ const wrapped = wrapChannelWithAGUI(channel)
245
+
246
+ wrapped.send({
247
+ type: 'tool-call',
248
+ toolCallId: 'tc1',
249
+ toolName: 'fn1',
250
+ args: {},
251
+ } as AIStreamEvent)
252
+ wrapped.send({
253
+ type: 'tool-call',
254
+ toolCallId: 'tc2',
255
+ toolName: 'fn2',
256
+ args: {},
257
+ } as AIStreamEvent)
258
+
259
+ const starts = (events as any[]).filter((e) => e.type === 'TOOL_CALL_START')
260
+ const ends = (events as any[]).filter((e) => e.type === 'TOOL_CALL_END')
261
+ assert.equal(starts.length, 2)
262
+ assert.equal(ends.length, 2)
263
+ assert.equal(starts[0].toolCallId, 'tc1')
264
+ assert.equal(starts[1].toolCallId, 'tc2')
265
+ })
266
+
267
+ it('converts object tool-result to JSON string in content', () => {
268
+ const { channel, events } = makeChannel()
269
+ const wrapped = wrapChannelWithAGUI(channel)
270
+
271
+ wrapped.send({
272
+ type: 'tool-result',
273
+ toolCallId: 'tc1',
274
+ toolName: 'fn',
275
+ result: { ok: true },
276
+ } as AIStreamEvent)
277
+
278
+ const r = find(events, 'TOOL_CALL_RESULT')
279
+ assert.equal(r.content, '{"ok":true}')
280
+ assert.equal(r.role, 'tool')
281
+ })
282
+
283
+ it('passes string tool-result through unchanged', () => {
284
+ const { channel, events } = makeChannel()
285
+ const wrapped = wrapChannelWithAGUI(channel)
286
+
287
+ wrapped.send({
288
+ type: 'tool-result',
289
+ toolCallId: 'tc1',
290
+ toolName: 'fn',
291
+ result: 'plain text',
292
+ } as AIStreamEvent)
293
+
294
+ const r = find(events, 'TOOL_CALL_RESULT')
295
+ assert.equal(r.content, 'plain text')
296
+ })
297
+ })
298
+
299
+ describe('wrapChannelWithAGUI — run lifecycle', () => {
300
+ it('emits RUN_FINISHED with full usage fields', () => {
301
+ const { channel, events } = makeChannel()
302
+ const wrapped = wrapChannelWithAGUI(channel)
303
+
304
+ wrapped.send({
305
+ type: 'usage',
306
+ tokens: { input: 100, output: 50 },
307
+ model: 'claude-3-5',
308
+ } as AIStreamEvent)
309
+ wrapped.send({ type: 'done' } as AIStreamEvent)
310
+
311
+ const f = find(events, 'RUN_FINISHED')
312
+ assert.ok(f)
313
+ assert.equal(f.model, 'claude-3-5')
314
+ assert.equal(f.usage.promptTokens, 100)
315
+ assert.equal(f.usage.completionTokens, 50)
316
+ assert.equal(f.usage.totalTokens, 150)
317
+ })
318
+
319
+ it('emits RUN_FINISHED exactly once when both usage and done arrive', () => {
320
+ const { channel, events } = makeChannel()
321
+ const wrapped = wrapChannelWithAGUI(channel)
322
+
323
+ wrapped.send({
324
+ type: 'usage',
325
+ tokens: { input: 1, output: 1 },
326
+ model: 'm',
327
+ } as AIStreamEvent)
328
+ wrapped.send({ type: 'done' } as AIStreamEvent)
329
+
330
+ assert.equal(events.filter((e: any) => e.type === 'RUN_FINISHED').length, 1)
331
+ })
332
+
333
+ it('emits RUN_FINISHED on done when there was no usage event', () => {
334
+ const { channel, events } = makeChannel()
335
+ const wrapped = wrapChannelWithAGUI(channel)
336
+
337
+ wrapped.send({ type: 'done' } as AIStreamEvent)
338
+
339
+ assert.ok(find(events, 'RUN_FINISHED'))
340
+ })
341
+
342
+ it('usage event closes any open text message before RUN_FINISHED', () => {
343
+ const { channel, events } = makeChannel()
344
+ const wrapped = wrapChannelWithAGUI(channel)
345
+
346
+ wrapped.send({ type: 'text-delta', text: 'hi' } as AIStreamEvent)
347
+ wrapped.send({
348
+ type: 'usage',
349
+ tokens: { input: 1, output: 1 },
350
+ model: 'm',
351
+ } as AIStreamEvent)
352
+ wrapped.send({ type: 'done' } as AIStreamEvent)
353
+
354
+ const t = types(events)
355
+ assert.ok(t.indexOf('TEXT_MESSAGE_END') < t.indexOf('RUN_FINISHED'))
356
+ })
357
+
358
+ it('emits RUN_ERROR and closes open text on error', () => {
359
+ const { channel, events } = makeChannel()
360
+ const wrapped = wrapChannelWithAGUI(channel)
361
+
362
+ wrapped.send({ type: 'text-delta', text: 'partial' } as AIStreamEvent)
363
+ wrapped.send({ type: 'error', message: 'network timeout' } as AIStreamEvent)
364
+
365
+ const err = find(events, 'RUN_ERROR')
366
+ assert.ok(err)
367
+ assert.equal(err.message, 'network timeout')
368
+ const t = types(events)
369
+ assert.ok(t.indexOf('TEXT_MESSAGE_END') < t.indexOf('RUN_ERROR'))
370
+ })
371
+
372
+ it('emits RUN_ERROR and closes open thinking message on error', () => {
373
+ const { channel, events } = makeChannel()
374
+ const wrapped = wrapChannelWithAGUI(channel)
375
+
376
+ wrapped.send({ type: 'reasoning-delta', text: 'thinking' } as AIStreamEvent)
377
+ wrapped.send({ type: 'error', message: 'fail' } as AIStreamEvent)
378
+
379
+ const t = types(events)
380
+ assert.ok(t.indexOf('THINKING_TEXT_MESSAGE_END') < t.indexOf('RUN_ERROR'))
381
+ })
382
+ })
383
+
384
+ describe('wrapChannelWithAGUI — step events', () => {
385
+ it('emits STEP_STARTED for step-start with the agent in the step name', () => {
386
+ const { channel, events } = makeChannel()
387
+ const wrapped = wrapChannelWithAGUI(channel)
388
+
389
+ wrapped.send({
390
+ type: 'step-start',
391
+ stepNumber: 1,
392
+ agent: 'researcher',
393
+ } as AIStreamEvent)
394
+
395
+ const s = find(events, 'STEP_STARTED')
396
+ assert.ok(s)
397
+ assert.ok(
398
+ typeof s.stepName === 'string' && s.stepName.includes('researcher')
399
+ )
400
+ })
401
+
402
+ it('emits STEP_STARTED with a defined stepName when agent is absent', () => {
403
+ const { channel, events } = makeChannel()
404
+ const wrapped = wrapChannelWithAGUI(channel)
405
+
406
+ wrapped.send({ type: 'step-start', stepNumber: 1 } as AIStreamEvent)
407
+
408
+ const s = find(events, 'STEP_STARTED')
409
+ assert.ok(s)
410
+ assert.ok(typeof s.stepName === 'string' && s.stepName.length > 0)
411
+ })
412
+ })
413
+
414
+ describe('wrapChannelWithAGUI — Pikku CUSTOM events', () => {
415
+ it('emits CUSTOM pikku:approval-request with all fields', () => {
416
+ const { channel, events } = makeChannel()
417
+ const wrapped = wrapChannelWithAGUI(channel)
418
+
419
+ wrapped.send({
420
+ type: 'approval-request',
421
+ toolCallId: 'tc1',
422
+ toolName: 'deleteUser',
423
+ args: { userId: 99 },
424
+ reason: 'irreversible action',
425
+ agent: 'admin-agent',
426
+ session: 'sess-1',
427
+ } as AIStreamEvent)
428
+
429
+ const c = find(events, 'CUSTOM', 'pikku:approval-request')
430
+ assert.ok(c)
431
+ assert.equal(c.value.toolCallId, 'tc1')
432
+ assert.equal(c.value.toolName, 'deleteUser')
433
+ assert.deepEqual(c.value.args, { userId: 99 })
434
+ assert.equal(c.value.reason, 'irreversible action')
435
+ assert.equal(c.value.agent, 'admin-agent')
436
+ assert.equal(c.value.session, 'sess-1')
437
+ })
438
+
439
+ it('closes open text message before emitting approval-request', () => {
440
+ const { channel, events } = makeChannel()
441
+ const wrapped = wrapChannelWithAGUI(channel)
442
+
443
+ wrapped.send({ type: 'text-delta', text: 'about to ask' } as AIStreamEvent)
444
+ wrapped.send({
445
+ type: 'approval-request',
446
+ toolCallId: 'tc1',
447
+ toolName: 'fn',
448
+ args: {},
449
+ } as AIStreamEvent)
450
+
451
+ const t = types(events)
452
+ assert.ok(t.indexOf('TEXT_MESSAGE_END') < t.indexOf('CUSTOM'))
453
+ })
454
+
455
+ it('emits CUSTOM pikku:credential-request with all fields', () => {
456
+ const { channel, events } = makeChannel()
457
+ const wrapped = wrapChannelWithAGUI(channel)
458
+
459
+ wrapped.send({
460
+ type: 'credential-request',
461
+ toolCallId: 'tc2',
462
+ toolName: 'githubSearch',
463
+ args: { repo: 'pikku' },
464
+ credentialName: 'github-token',
465
+ credentialType: 'oauth2',
466
+ connectUrl: 'https://github.com/oauth',
467
+ runId: 'run-abc',
468
+ agent: 'dev-agent',
469
+ } as AIStreamEvent)
470
+
471
+ const c = find(events, 'CUSTOM', 'pikku:credential-request')
472
+ assert.ok(c)
473
+ assert.equal(c.value.credentialName, 'github-token')
474
+ assert.equal(c.value.credentialType, 'oauth2')
475
+ assert.equal(c.value.connectUrl, 'https://github.com/oauth')
476
+ assert.equal(c.value.runId, 'run-abc')
477
+ assert.equal(c.value.toolCallId, 'tc2')
478
+ })
479
+
480
+ it('emits CUSTOM pikku:generative-ui', () => {
481
+ const { channel, events } = makeChannel()
482
+ const wrapped = wrapChannelWithAGUI(channel)
483
+
484
+ wrapped.send({
485
+ type: 'generative-ui',
486
+ spec: { component: 'Chart', props: { data: [1, 2] } },
487
+ } as AIStreamEvent)
488
+
489
+ const c = find(events, 'CUSTOM', 'pikku:generative-ui')
490
+ assert.ok(c)
491
+ assert.deepEqual(c.value.spec, {
492
+ component: 'Chart',
493
+ props: { data: [1, 2] },
494
+ })
495
+ })
496
+
497
+ it('emits CUSTOM pikku:data', () => {
498
+ const { channel, events } = makeChannel()
499
+ const wrapped = wrapChannelWithAGUI(channel)
500
+
501
+ wrapped.send({
502
+ type: 'data',
503
+ name: 'search-results',
504
+ data: [{ title: 'foo' }],
505
+ } as AIStreamEvent)
506
+
507
+ const c = find(events, 'CUSTOM', 'pikku:data')
508
+ assert.ok(c)
509
+ assert.equal(c.value.name, 'search-results')
510
+ assert.deepEqual(c.value.data, [{ title: 'foo' }])
511
+ })
512
+
513
+ it('emits CUSTOM pikku:workflow-created', () => {
514
+ const { channel, events } = makeChannel()
515
+ const wrapped = wrapChannelWithAGUI(channel)
516
+
517
+ wrapped.send({
518
+ type: 'workflow-created',
519
+ workflowName: 'myFlow',
520
+ graph: { nodes: [] },
521
+ } as AIStreamEvent)
522
+
523
+ const c = find(events, 'CUSTOM', 'pikku:workflow-created')
524
+ assert.ok(c)
525
+ assert.equal(c.value.workflowName, 'myFlow')
526
+ assert.deepEqual(c.value.graph, { nodes: [] })
527
+ })
528
+
529
+ it('emits CUSTOM pikku:agent-call', () => {
530
+ const { channel, events } = makeChannel()
531
+ const wrapped = wrapChannelWithAGUI(channel)
532
+
533
+ wrapped.send({
534
+ type: 'agent-call',
535
+ agentName: 'sub-agent',
536
+ session: 'sess-2',
537
+ input: { query: 'hello' },
538
+ } as AIStreamEvent)
539
+
540
+ const c = find(events, 'CUSTOM', 'pikku:agent-call')
541
+ assert.ok(c)
542
+ assert.equal(c.value.agentName, 'sub-agent')
543
+ assert.equal(c.value.session, 'sess-2')
544
+ assert.deepEqual(c.value.input, { query: 'hello' })
545
+ })
546
+
547
+ it('emits CUSTOM pikku:agent-result', () => {
548
+ const { channel, events } = makeChannel()
549
+ const wrapped = wrapChannelWithAGUI(channel)
550
+
551
+ wrapped.send({
552
+ type: 'agent-result',
553
+ agentName: 'sub-agent',
554
+ session: 'sess-2',
555
+ result: 'done',
556
+ } as AIStreamEvent)
557
+
558
+ const c = find(events, 'CUSTOM', 'pikku:agent-result')
559
+ assert.ok(c)
560
+ assert.equal(c.value.agentName, 'sub-agent')
561
+ assert.equal(c.value.result, 'done')
562
+ })
563
+
564
+ it('emits CUSTOM pikku:suspended', () => {
565
+ const { channel, events } = makeChannel()
566
+ const wrapped = wrapChannelWithAGUI(channel)
567
+
568
+ wrapped.send({
569
+ type: 'suspended',
570
+ reason: 'rpc-missing',
571
+ missingRpcs: ['stripe.charge'],
572
+ } as AIStreamEvent)
573
+
574
+ const c = find(events, 'CUSTOM', 'pikku:suspended')
575
+ assert.ok(c)
576
+ assert.equal(c.value.reason, 'rpc-missing')
577
+ assert.deepEqual(c.value.missingRpcs, ['stripe.charge'])
578
+ })
579
+ })
580
+
581
+ describe('wrapChannelWithAGUI — silently dropped events', () => {
582
+ it('drops audio-delta without emitting anything', () => {
583
+ const { channel, events } = makeChannel()
584
+ const wrapped = wrapChannelWithAGUI(channel)
585
+
586
+ wrapped.send({
587
+ type: 'audio-delta',
588
+ data: 'base64abc',
589
+ format: 'mp3',
590
+ } as AIStreamEvent)
591
+
592
+ assert.equal(events.length, 0)
593
+ })
594
+
595
+ it('drops audio-done without emitting anything', () => {
596
+ const { channel, events } = makeChannel()
597
+ const wrapped = wrapChannelWithAGUI(channel)
598
+
599
+ wrapped.send({ type: 'audio-done' } as AIStreamEvent)
600
+
601
+ assert.equal(events.length, 0)
602
+ })
603
+ })
604
+
605
+ describe('wrapChannelWithAGUI — channel proxy', () => {
606
+ it('exposes the inner channelId', () => {
607
+ const { channel } = makeChannel()
608
+ const wrapped = wrapChannelWithAGUI(channel)
609
+ assert.equal(wrapped.channelId, 'test-id')
610
+ })
611
+
612
+ it('delegates setState / getState / clearState to inner', () => {
613
+ let stored: unknown
614
+ const { channel } = makeChannel()
615
+ channel.setState = (v) => {
616
+ stored = v
617
+ }
618
+ channel.getState = () => stored as any
619
+ channel.clearState = () => {
620
+ stored = undefined
621
+ }
622
+
623
+ const wrapped = wrapChannelWithAGUI(channel)
624
+ wrapped.setState('my-state')
625
+ assert.equal(wrapped.getState(), 'my-state')
626
+ wrapped.clearState()
627
+ assert.equal(wrapped.getState(), undefined)
628
+ })
629
+
630
+ it('delegates sendBinary to inner', () => {
631
+ const binaries: unknown[] = []
632
+ const { channel } = makeChannel()
633
+ channel.sendBinary = (d) => binaries.push(d)
634
+
635
+ const wrapped = wrapChannelWithAGUI(channel)
636
+ wrapped.sendBinary(new Uint8Array([1, 2, 3]))
637
+ assert.equal(binaries.length, 1)
638
+ })
639
+
640
+ it('delegates close to inner', () => {
641
+ let closed = false
642
+ const { channel } = makeChannel()
643
+ channel.close = () => {
644
+ closed = true
645
+ }
646
+
647
+ const wrapped = wrapChannelWithAGUI(channel)
648
+ wrapped.close()
649
+ assert.ok(closed)
650
+ })
651
+
652
+ it('reflects inner state property', () => {
653
+ const { channel } = makeChannel()
654
+ const wrapped = wrapChannelWithAGUI(channel)
655
+ assert.equal(wrapped.state, 'open')
656
+ channel.state = 'closed'
657
+ assert.equal(wrapped.state, 'closed')
658
+ })
659
+ })
660
+
661
+ /**
662
+ * Replicates the ordering rules enforced by @ag-ui/client's verifyEvents so
663
+ * the bridge's output is checked against the same contract the browser
664
+ * applies. Throws on the first violation.
665
+ */
666
+ function assertClientAcceptsStream(events: any[]): void {
667
+ let started = false
668
+ let finished = false
669
+ let errored = false
670
+ const activeTexts = new Set<string>()
671
+ const activeTools = new Set<string>()
672
+ const activeSteps = new Set<string>()
673
+ let thinkingStep = false
674
+ let thinkingMessage = false
675
+
676
+ for (const e of events) {
677
+ const parsed = EventSchemas.safeParse(e)
678
+ assert.ok(
679
+ parsed.success,
680
+ `event failed @ag-ui/core schema: ${JSON.stringify(e)} — ${JSON.stringify(parsed.success ? [] : parsed.error.issues)}`
681
+ )
682
+ assert.ok(!errored, `event '${e.type}' sent after RUN_ERROR`)
683
+ assert.ok(
684
+ !finished || e.type === 'RUN_ERROR' || e.type === 'RUN_STARTED',
685
+ `event '${e.type}' sent after RUN_FINISHED`
686
+ )
687
+ if (!started) {
688
+ started = true
689
+ assert.ok(
690
+ e.type === 'RUN_STARTED' || e.type === 'RUN_ERROR',
691
+ `first event must be RUN_STARTED, got '${e.type}'`
692
+ )
693
+ }
694
+ switch (e.type) {
695
+ case 'TEXT_MESSAGE_START':
696
+ assert.ok(!activeTexts.has(e.messageId), 'text already active')
697
+ activeTexts.add(e.messageId)
698
+ break
699
+ case 'TEXT_MESSAGE_CONTENT':
700
+ assert.ok(activeTexts.has(e.messageId), 'text content without start')
701
+ break
702
+ case 'TEXT_MESSAGE_END':
703
+ assert.ok(activeTexts.delete(e.messageId), 'text end without start')
704
+ break
705
+ case 'TOOL_CALL_START':
706
+ assert.ok(!activeTools.has(e.toolCallId), 'tool already active')
707
+ activeTools.add(e.toolCallId)
708
+ break
709
+ case 'TOOL_CALL_ARGS':
710
+ assert.ok(activeTools.has(e.toolCallId), 'tool args without start')
711
+ break
712
+ case 'TOOL_CALL_END':
713
+ assert.ok(activeTools.delete(e.toolCallId), 'tool end without start')
714
+ break
715
+ case 'STEP_STARTED':
716
+ assert.ok(
717
+ !activeSteps.has(e.stepName),
718
+ `step '${e.stepName}' already active`
719
+ )
720
+ activeSteps.add(e.stepName)
721
+ break
722
+ case 'STEP_FINISHED':
723
+ assert.ok(
724
+ activeSteps.delete(e.stepName),
725
+ `step '${e.stepName}' not started`
726
+ )
727
+ break
728
+ case 'THINKING_START':
729
+ assert.ok(!thinkingStep, 'thinking step already active')
730
+ thinkingStep = true
731
+ break
732
+ case 'THINKING_END':
733
+ assert.ok(thinkingStep, 'thinking end without start')
734
+ thinkingStep = false
735
+ break
736
+ case 'THINKING_TEXT_MESSAGE_START':
737
+ assert.ok(thinkingStep, 'thinking message outside THINKING_START')
738
+ assert.ok(!thinkingMessage, 'thinking message already active')
739
+ thinkingMessage = true
740
+ break
741
+ case 'THINKING_TEXT_MESSAGE_CONTENT':
742
+ assert.ok(thinkingMessage, 'thinking content without start')
743
+ break
744
+ case 'THINKING_TEXT_MESSAGE_END':
745
+ assert.ok(thinkingMessage, 'thinking message end without start')
746
+ thinkingMessage = false
747
+ break
748
+ case 'RUN_FINISHED':
749
+ assert.equal(activeSteps.size, 0, 'RUN_FINISHED with active steps')
750
+ assert.equal(activeTexts.size, 0, 'RUN_FINISHED with active text')
751
+ assert.equal(activeTools.size, 0, 'RUN_FINISHED with active tools')
752
+ finished = true
753
+ break
754
+ case 'RUN_ERROR':
755
+ errored = true
756
+ break
757
+ }
758
+ }
759
+ }
760
+
761
+ describe('wrapChannelWithAGUI — AG-UI protocol conformance', () => {
762
+ it('emits RUN_STARTED with threadId and runId as the first event', () => {
763
+ const { channel, events } = makeChannel()
764
+ const wrapped = wrapChannelWithAGUI(channel, {
765
+ threadId: 'thread-1',
766
+ runId: 'run-1',
767
+ })
768
+
769
+ wrapped.send({ type: 'text-delta', text: 'hi' } as AIStreamEvent)
770
+ wrapped.send({ type: 'done' } as AIStreamEvent)
771
+
772
+ const first = events[0] as any
773
+ assert.equal(first.type, 'RUN_STARTED')
774
+ assert.equal(first.threadId, 'thread-1')
775
+ assert.equal(first.runId, 'run-1')
776
+ })
777
+
778
+ it('carries the same threadId and runId on RUN_FINISHED', () => {
779
+ const { channel, events } = makeChannel()
780
+ const wrapped = wrapChannelWithAGUI(channel, {
781
+ threadId: 'thread-1',
782
+ runId: 'run-1',
783
+ })
784
+
785
+ wrapped.send({ type: 'done' } as AIStreamEvent)
786
+
787
+ const f = find(events, 'RUN_FINISHED')
788
+ assert.equal(f.threadId, 'thread-1')
789
+ assert.equal(f.runId, 'run-1')
790
+ })
791
+
792
+ it('does not emit RUN_FINISHED on mid-run usage; accumulates into final RUN_FINISHED', () => {
793
+ const { channel, events } = makeChannel()
794
+ const wrapped = wrapChannelWithAGUI(channel)
795
+
796
+ wrapped.send({ type: 'step-start', stepNumber: 0 } as AIStreamEvent)
797
+ wrapped.send({
798
+ type: 'tool-call',
799
+ toolCallId: 'tc1',
800
+ toolName: 'listTodos',
801
+ args: {},
802
+ } as AIStreamEvent)
803
+ wrapped.send({
804
+ type: 'tool-result',
805
+ toolCallId: 'tc1',
806
+ toolName: 'listTodos',
807
+ result: ['a'],
808
+ } as AIStreamEvent)
809
+ wrapped.send({
810
+ type: 'usage',
811
+ tokens: { input: 10, output: 5 },
812
+ model: 'gpt-4o',
813
+ } as AIStreamEvent)
814
+ wrapped.send({ type: 'step-start', stepNumber: 1 } as AIStreamEvent)
815
+ wrapped.send({
816
+ type: 'text-delta',
817
+ text: 'You have 1 todo',
818
+ } as AIStreamEvent)
819
+ wrapped.send({
820
+ type: 'usage',
821
+ tokens: { input: 20, output: 8 },
822
+ model: 'gpt-4o',
823
+ } as AIStreamEvent)
824
+ wrapped.send({ type: 'done' } as AIStreamEvent)
825
+
826
+ const finishes = (events as any[]).filter((e) => e.type === 'RUN_FINISHED')
827
+ assert.equal(finishes.length, 1)
828
+ assert.equal((events as any[]).at(-1).type, 'RUN_FINISHED')
829
+ assert.equal(finishes[0].usage.promptTokens, 30)
830
+ assert.equal(finishes[0].usage.completionTokens, 13)
831
+ assert.equal(finishes[0].usage.totalTokens, 43)
832
+ const t = types(events)
833
+ assert.ok(
834
+ t.lastIndexOf('TEXT_MESSAGE_CONTENT') < t.indexOf('RUN_FINISHED'),
835
+ 'text must arrive before RUN_FINISHED'
836
+ )
837
+ assertClientAcceptsStream(events as any[])
838
+ })
839
+
840
+ it('swallows events after done so nothing follows RUN_FINISHED', () => {
841
+ const { channel, events } = makeChannel()
842
+ const wrapped = wrapChannelWithAGUI(channel)
843
+
844
+ wrapped.send({ type: 'done' } as AIStreamEvent)
845
+ wrapped.send({
846
+ type: 'usage',
847
+ tokens: { input: 1, output: 1 },
848
+ model: 'm',
849
+ } as AIStreamEvent)
850
+ wrapped.send({ type: 'text-delta', text: 'late' } as AIStreamEvent)
851
+ wrapped.send({ type: 'done' } as AIStreamEvent)
852
+
853
+ assert.equal((events as any[]).at(-1).type, 'RUN_FINISHED')
854
+ assert.equal(events.filter((e: any) => e.type === 'RUN_FINISHED').length, 1)
855
+ })
856
+
857
+ it('gives TOOL_CALL_RESULT a messageId', () => {
858
+ const { channel, events } = makeChannel()
859
+ const wrapped = wrapChannelWithAGUI(channel)
860
+
861
+ wrapped.send({
862
+ type: 'tool-result',
863
+ toolCallId: 'tc1',
864
+ toolName: 'fn',
865
+ result: 'ok',
866
+ } as AIStreamEvent)
867
+
868
+ const r = find(events, 'TOOL_CALL_RESULT')
869
+ assert.ok(r.messageId, 'TOOL_CALL_RESULT requires a messageId')
870
+ })
871
+
872
+ it('streams tool args via TOOL_CALL_ARGS between START and END', () => {
873
+ const { channel, events } = makeChannel()
874
+ const wrapped = wrapChannelWithAGUI(channel)
875
+
876
+ wrapped.send({
877
+ type: 'tool-call',
878
+ toolCallId: 'tc1',
879
+ toolName: 'addTodo',
880
+ args: { text: 'buy milk' },
881
+ } as AIStreamEvent)
882
+
883
+ const t = types(events)
884
+ const argsIdx = t.indexOf('TOOL_CALL_ARGS')
885
+ assert.ok(argsIdx !== -1, 'TOOL_CALL_ARGS must be emitted')
886
+ assert.ok(t.indexOf('TOOL_CALL_START') < argsIdx)
887
+ assert.ok(argsIdx < t.indexOf('TOOL_CALL_END'))
888
+ const args = find(events, 'TOOL_CALL_ARGS')
889
+ assert.equal(args.delta, '{"text":"buy milk"}')
890
+ })
891
+
892
+ it('wraps thinking messages in THINKING_START / THINKING_END', () => {
893
+ const { channel, events } = makeChannel()
894
+ const wrapped = wrapChannelWithAGUI(channel)
895
+
896
+ wrapped.send({ type: 'reasoning-delta', text: 'hmm' } as AIStreamEvent)
897
+ wrapped.send({ type: 'text-delta', text: 'answer' } as AIStreamEvent)
898
+ wrapped.send({ type: 'done' } as AIStreamEvent)
899
+
900
+ const t = types(events)
901
+ assert.ok(
902
+ t.indexOf('THINKING_START') < t.indexOf('THINKING_TEXT_MESSAGE_START')
903
+ )
904
+ assert.ok(
905
+ t.indexOf('THINKING_TEXT_MESSAGE_END') < t.indexOf('THINKING_END')
906
+ )
907
+ assertClientAcceptsStream(events as any[])
908
+ })
909
+
910
+ it('closes the open step before RUN_FINISHED and keeps step names unique', () => {
911
+ const { channel, events } = makeChannel()
912
+ const wrapped = wrapChannelWithAGUI(channel)
913
+
914
+ wrapped.send({ type: 'step-start', stepNumber: 0 } as AIStreamEvent)
915
+ wrapped.send({
916
+ type: 'step-start',
917
+ stepNumber: 0,
918
+ agent: 'todoAgent',
919
+ } as AIStreamEvent)
920
+ wrapped.send({ type: 'step-start', stepNumber: 1 } as AIStreamEvent)
921
+ wrapped.send({ type: 'done' } as AIStreamEvent)
922
+
923
+ const startNames = (events as any[])
924
+ .filter((e) => e.type === 'STEP_STARTED')
925
+ .map((e) => e.stepName)
926
+ assert.equal(new Set(startNames).size, startNames.length)
927
+ assertClientAcceptsStream(events as any[])
928
+ })
929
+
930
+ it('accepts the full multi-agent delegate wire sequence', () => {
931
+ const { channel, events } = makeChannel()
932
+ const wrapped = wrapChannelWithAGUI(channel, {
933
+ threadId: 't1',
934
+ runId: 'r1',
935
+ })
936
+
937
+ const send = (e: any) => wrapped.send(e as AIStreamEvent)
938
+ send({ type: 'step-start', stepNumber: 0 })
939
+ send({
940
+ type: 'tool-call',
941
+ toolCallId: 'p1',
942
+ toolName: 'todoAgent',
943
+ args: { message: 'list' },
944
+ })
945
+ send({
946
+ type: 'agent-call',
947
+ agentName: 'todoAgent',
948
+ session: 's',
949
+ input: 'list',
950
+ })
951
+ send({ type: 'step-start', stepNumber: 0, agent: 'todoAgent' })
952
+ send({
953
+ type: 'tool-call',
954
+ toolCallId: 'c1',
955
+ toolName: 'todos__listTodos',
956
+ args: {},
957
+ })
958
+ send({
959
+ type: 'tool-result',
960
+ toolCallId: 'c1',
961
+ toolName: 'todos__listTodos',
962
+ result: ['x'],
963
+ })
964
+ send({
965
+ type: 'usage',
966
+ tokens: { input: 5, output: 2 },
967
+ model: 'gpt-4o-mini',
968
+ })
969
+ send({ type: 'step-start', stepNumber: 1, agent: 'todoAgent' })
970
+ for (const chunk of ['You ', 'have ', 'one ', 'todo']) {
971
+ send({ type: 'text-delta', text: chunk })
972
+ }
973
+ send({
974
+ type: 'usage',
975
+ tokens: { input: 7, output: 9 },
976
+ model: 'gpt-4o-mini',
977
+ })
978
+ send({
979
+ type: 'agent-result',
980
+ agentName: 'todoAgent',
981
+ session: 's',
982
+ result: 'You have one todo',
983
+ })
984
+ send({
985
+ type: 'tool-result',
986
+ toolCallId: 'p1',
987
+ toolName: 'todoAgent',
988
+ result: 'You have one todo',
989
+ })
990
+ send({
991
+ type: 'usage',
992
+ tokens: { input: 11, output: 3 },
993
+ model: 'gpt-4o-mini',
994
+ })
995
+ send({ type: 'step-start', stepNumber: 1 })
996
+ send({
997
+ type: 'usage',
998
+ tokens: { input: 4, output: 1 },
999
+ model: 'gpt-4o-mini',
1000
+ })
1001
+ send({ type: 'done' })
1002
+
1003
+ assertClientAcceptsStream(events as any[])
1004
+ assert.equal((events as any[]).at(-1).type, 'RUN_FINISHED')
1005
+ assert.ok(find(events, 'CUSTOM', 'pikku:agent-call'))
1006
+ assert.ok(find(events, 'CUSTOM', 'pikku:agent-result'))
1007
+ assert.equal(
1008
+ (events as any[]).filter((e) => e.type === 'TEXT_MESSAGE_CONTENT').length,
1009
+ 4
1010
+ )
1011
+ })
1012
+
1013
+ it('approval flow ends with CUSTOM approval-request then RUN_FINISHED', () => {
1014
+ const { channel, events } = makeChannel()
1015
+ const wrapped = wrapChannelWithAGUI(channel)
1016
+
1017
+ wrapped.send({ type: 'step-start', stepNumber: 0 } as AIStreamEvent)
1018
+ wrapped.send({
1019
+ type: 'tool-call',
1020
+ toolCallId: 'tc1',
1021
+ toolName: 'addTodo',
1022
+ args: { text: 'x' },
1023
+ } as AIStreamEvent)
1024
+ wrapped.send({
1025
+ type: 'approval-request',
1026
+ toolCallId: 'tc1',
1027
+ toolName: 'addTodo',
1028
+ args: { text: 'x' },
1029
+ reason: 'Add a todo',
1030
+ runId: 'run-9',
1031
+ } as AIStreamEvent)
1032
+ wrapped.send({ type: 'done' } as AIStreamEvent)
1033
+
1034
+ assertClientAcceptsStream(events as any[])
1035
+ const c = find(events, 'CUSTOM', 'pikku:approval-request')
1036
+ assert.equal(c.value.runId, 'run-9')
1037
+ const t = types(events)
1038
+ assert.ok(t.indexOf('CUSTOM') < t.indexOf('RUN_FINISHED'))
1039
+ })
1040
+ })
1041
+
1042
+ describe('wrapChannelWithAGUI — realistic full turn', () => {
1043
+ it('handles think → text → tool-call → tool-result → usage → done sequence', () => {
1044
+ const { channel, events } = makeChannel()
1045
+ const wrapped = wrapChannelWithAGUI(channel)
1046
+
1047
+ wrapped.send({
1048
+ type: 'reasoning-delta',
1049
+ text: 'let me search',
1050
+ } as AIStreamEvent)
1051
+ wrapped.send({
1052
+ type: 'text-delta',
1053
+ text: 'I will look that up.',
1054
+ } as AIStreamEvent)
1055
+ wrapped.send({
1056
+ type: 'tool-call',
1057
+ toolCallId: 'tc1',
1058
+ toolName: 'search',
1059
+ args: { q: 'pikku' },
1060
+ } as AIStreamEvent)
1061
+ wrapped.send({
1062
+ type: 'tool-result',
1063
+ toolCallId: 'tc1',
1064
+ toolName: 'search',
1065
+ result: 'found it',
1066
+ } as AIStreamEvent)
1067
+ wrapped.send({
1068
+ type: 'text-delta',
1069
+ text: 'Here is what I found.',
1070
+ } as AIStreamEvent)
1071
+ wrapped.send({
1072
+ type: 'usage',
1073
+ tokens: { input: 200, output: 80 },
1074
+ model: 'gpt-4o',
1075
+ } as AIStreamEvent)
1076
+ wrapped.send({ type: 'done' } as AIStreamEvent)
1077
+
1078
+ const t = types(events)
1079
+ assert.ok(t.includes('THINKING_TEXT_MESSAGE_START'))
1080
+ assert.ok(t.includes('THINKING_TEXT_MESSAGE_END'))
1081
+ assert.ok(t.includes('TEXT_MESSAGE_START'))
1082
+ assert.ok(t.includes('TEXT_MESSAGE_END'))
1083
+ assert.ok(t.includes('TOOL_CALL_START'))
1084
+ assert.ok(t.includes('TOOL_CALL_END'))
1085
+ assert.ok(t.includes('TOOL_CALL_RESULT'))
1086
+ assert.ok(t.includes('RUN_FINISHED'))
1087
+ assert.equal(t.filter((x) => x === 'RUN_FINISHED').length, 1)
1088
+ assert.equal(t.filter((x) => x === 'TEXT_MESSAGE_START').length, 2)
1089
+ })
1090
+ })
1091
+
1092
+ describe('wrapChannelWithAGUI — late-bound runId', () => {
1093
+ it('resolves getRunId at the first event, not at wrap time', () => {
1094
+ const { channel, events } = makeChannel()
1095
+ let runId: string | undefined
1096
+ const wrapped = wrapChannelWithAGUI(channel, {
1097
+ threadId: 't1',
1098
+ getRunId: () => runId,
1099
+ })
1100
+
1101
+ runId = 'run-from-state-service'
1102
+ wrapped.send({ type: 'text-delta', text: 'hi' } as AIStreamEvent)
1103
+ wrapped.send({ type: 'done' } as AIStreamEvent)
1104
+
1105
+ const started = find(events, 'RUN_STARTED')
1106
+ const finished = find(events, 'RUN_FINISHED')
1107
+ assert.equal(started.runId, 'run-from-state-service')
1108
+ assert.equal(finished.runId, 'run-from-state-service')
1109
+ })
1110
+
1111
+ it('prefers an explicit runId over getRunId and falls back to a UUID', () => {
1112
+ const explicit = makeChannel()
1113
+ wrapChannelWithAGUI(explicit.channel, {
1114
+ runId: 'explicit-run',
1115
+ getRunId: () => 'ignored',
1116
+ }).send({ type: 'done' } as AIStreamEvent)
1117
+ assert.equal(find(explicit.events, 'RUN_STARTED').runId, 'explicit-run')
1118
+
1119
+ const fallback = makeChannel()
1120
+ wrapChannelWithAGUI(fallback.channel, {
1121
+ getRunId: () => undefined,
1122
+ }).send({ type: 'done' } as AIStreamEvent)
1123
+ const started = find(fallback.events, 'RUN_STARTED')
1124
+ assert.ok(started.runId)
1125
+ assert.equal(started.runId, find(fallback.events, 'RUN_FINISHED').runId)
1126
+ })
1127
+ })