@pikku/core 0.12.4 → 0.12.5

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 (229) hide show
  1. package/CHANGELOG.md +11 -1
  2. package/dist/dev/hot-reload.d.ts +10 -0
  3. package/dist/dev/hot-reload.js +158 -0
  4. package/dist/middleware-runner.d.ts +1 -0
  5. package/dist/middleware-runner.js +5 -0
  6. package/dist/permissions.d.ts +1 -0
  7. package/dist/permissions.js +5 -0
  8. package/dist/services/in-memory-workflow-service.js +7 -11
  9. package/dist/wirings/ai-agent/ai-agent-prepare.js +16 -1
  10. package/dist/wirings/channel/channel-middleware-runner.d.ts +1 -0
  11. package/dist/wirings/channel/channel-middleware-runner.js +5 -0
  12. package/dist/wirings/workflow/pikku-workflow-service.js +7 -8
  13. package/package.json +3 -2
  14. package/src/crypto-utils.test.ts +214 -0
  15. package/src/crypto-utils.ts +213 -0
  16. package/src/dev/hot-reload.test.ts +484 -0
  17. package/src/dev/hot-reload.ts +212 -0
  18. package/src/errors/error-handler.ts +62 -0
  19. package/src/errors/error.test.ts +195 -0
  20. package/src/errors/errors.ts +374 -0
  21. package/src/errors/index.ts +2 -0
  22. package/src/factory-functions.test.ts +109 -0
  23. package/src/function/function-runner.test.ts +536 -0
  24. package/src/function/function-runner.ts +365 -0
  25. package/src/function/functions.types.ts +304 -0
  26. package/src/function/index.ts +5 -0
  27. package/src/handle-error.test.ts +424 -0
  28. package/src/handle-error.ts +69 -0
  29. package/src/index.ts +118 -0
  30. package/src/internal.ts +6 -0
  31. package/src/middleware/auth-apikey.test.ts +363 -0
  32. package/src/middleware/auth-apikey.ts +47 -0
  33. package/src/middleware/auth-bearer.test.ts +450 -0
  34. package/src/middleware/auth-bearer.ts +72 -0
  35. package/src/middleware/auth-cookie.test.ts +528 -0
  36. package/src/middleware/auth-cookie.ts +80 -0
  37. package/src/middleware/cors.test.ts +424 -0
  38. package/src/middleware/cors.ts +104 -0
  39. package/src/middleware/index.ts +5 -0
  40. package/src/middleware/remote-auth.test.ts +488 -0
  41. package/src/middleware/remote-auth.ts +68 -0
  42. package/src/middleware/timeout.ts +15 -0
  43. package/src/middleware-runner.test.ts +418 -0
  44. package/src/middleware-runner.ts +240 -0
  45. package/src/permissions.test.ts +434 -0
  46. package/src/permissions.ts +327 -0
  47. package/src/pikku-request.ts +23 -0
  48. package/src/pikku-response.ts +5 -0
  49. package/src/pikku-state.test.ts +224 -0
  50. package/src/pikku-state.ts +216 -0
  51. package/src/run-tests-script.test.ts +49 -0
  52. package/src/schema.test.ts +249 -0
  53. package/src/schema.ts +151 -0
  54. package/src/services/ai-agent-runner-service.ts +41 -0
  55. package/src/services/ai-run-state-service.ts +20 -0
  56. package/src/services/ai-storage-service.ts +39 -0
  57. package/src/services/content-service.ts +76 -0
  58. package/src/services/deployment-service.ts +22 -0
  59. package/src/services/gateway-service.ts +20 -0
  60. package/src/services/gopass-secrets.ts +98 -0
  61. package/src/services/in-memory-ai-run-state-service.ts +73 -0
  62. package/src/services/in-memory-trigger-service.ts +64 -0
  63. package/src/services/in-memory-workflow-service.test.ts +351 -0
  64. package/src/services/in-memory-workflow-service.ts +502 -0
  65. package/src/services/index.ts +56 -0
  66. package/src/services/jwt-service.ts +30 -0
  67. package/src/services/local-content.ts +120 -0
  68. package/src/services/local-gateway-service.ts +62 -0
  69. package/src/services/local-secrets.test.ts +80 -0
  70. package/src/services/local-secrets.ts +94 -0
  71. package/src/services/local-variables.test.ts +61 -0
  72. package/src/services/local-variables.ts +39 -0
  73. package/src/services/logger-console.test.ts +118 -0
  74. package/src/services/logger-console.ts +98 -0
  75. package/src/services/logger.ts +57 -0
  76. package/src/services/scheduler-service.ts +86 -0
  77. package/src/services/schema-service.ts +33 -0
  78. package/src/services/scoped-secret-service.test.ts +74 -0
  79. package/src/services/scoped-secret-service.ts +41 -0
  80. package/src/services/secret-service.ts +38 -0
  81. package/src/services/trigger-service.ts +17 -0
  82. package/src/services/typed-secret-service.test.ts +93 -0
  83. package/src/services/typed-secret-service.ts +70 -0
  84. package/src/services/typed-variables-service.test.ts +73 -0
  85. package/src/services/typed-variables-service.ts +81 -0
  86. package/src/services/user-session-service.test.ts +113 -0
  87. package/src/services/user-session-service.ts +86 -0
  88. package/src/services/variables-service.ts +11 -0
  89. package/src/services/workflow-service.ts +95 -0
  90. package/src/testing/index.ts +2 -0
  91. package/src/testing/service-tests.ts +873 -0
  92. package/src/time-utils.test.ts +56 -0
  93. package/src/time-utils.ts +107 -0
  94. package/src/types/core.types.ts +478 -0
  95. package/src/types/state.types.ts +188 -0
  96. package/src/utils/hash.test.ts +68 -0
  97. package/src/utils/hash.ts +26 -0
  98. package/src/utils.test.ts +350 -0
  99. package/src/utils.ts +129 -0
  100. package/src/version.test.ts +80 -0
  101. package/src/version.ts +25 -0
  102. package/src/wirings/ai-agent/agent-dynamic-workflow.ts +469 -0
  103. package/src/wirings/ai-agent/ai-agent-helpers.test.ts +152 -0
  104. package/src/wirings/ai-agent/ai-agent-helpers.ts +76 -0
  105. package/src/wirings/ai-agent/ai-agent-memory.ts +310 -0
  106. package/src/wirings/ai-agent/ai-agent-model-config.test.ts +115 -0
  107. package/src/wirings/ai-agent/ai-agent-model-config.ts +43 -0
  108. package/src/wirings/ai-agent/ai-agent-prepare.ts +659 -0
  109. package/src/wirings/ai-agent/ai-agent-registry.test.ts +37 -0
  110. package/src/wirings/ai-agent/ai-agent-registry.ts +82 -0
  111. package/src/wirings/ai-agent/ai-agent-runner.test.ts +319 -0
  112. package/src/wirings/ai-agent/ai-agent-runner.ts +773 -0
  113. package/src/wirings/ai-agent/ai-agent-stream.test.ts +859 -0
  114. package/src/wirings/ai-agent/ai-agent-stream.ts +1153 -0
  115. package/src/wirings/ai-agent/ai-agent.types.ts +382 -0
  116. package/src/wirings/ai-agent/index.ts +37 -0
  117. package/src/wirings/channel/channel-common.ts +90 -0
  118. package/src/wirings/channel/channel-handler.ts +250 -0
  119. package/src/wirings/channel/channel-middleware-runner.ts +126 -0
  120. package/src/wirings/channel/channel-runner.ts +166 -0
  121. package/src/wirings/channel/channel-store.ts +29 -0
  122. package/src/wirings/channel/channel.types.ts +172 -0
  123. package/src/wirings/channel/define-channel-routes.ts +25 -0
  124. package/src/wirings/channel/eventhub-service.ts +34 -0
  125. package/src/wirings/channel/eventhub-store.ts +13 -0
  126. package/src/wirings/channel/index.ts +24 -0
  127. package/src/wirings/channel/local/index.ts +3 -0
  128. package/src/wirings/channel/local/local-channel-handler.ts +81 -0
  129. package/src/wirings/channel/local/local-channel-runner.test.ts +215 -0
  130. package/src/wirings/channel/local/local-channel-runner.ts +210 -0
  131. package/src/wirings/channel/local/local-eventhub-service.test.ts +95 -0
  132. package/src/wirings/channel/local/local-eventhub-service.ts +101 -0
  133. package/src/wirings/channel/log-channels.ts +20 -0
  134. package/src/wirings/channel/pikku-abstract-channel-handler.test.ts +54 -0
  135. package/src/wirings/channel/pikku-abstract-channel-handler.ts +45 -0
  136. package/src/wirings/channel/serverless/index.ts +5 -0
  137. package/src/wirings/channel/serverless/serverless-channel-runner.ts +289 -0
  138. package/src/wirings/cli/channel/cli-channel-runner.ts +136 -0
  139. package/src/wirings/cli/channel/index.ts +1 -0
  140. package/src/wirings/cli/cli-runner.test.ts +403 -0
  141. package/src/wirings/cli/cli-runner.ts +529 -0
  142. package/src/wirings/cli/cli.types.ts +358 -0
  143. package/src/wirings/cli/command-parser.test.ts +445 -0
  144. package/src/wirings/cli/command-parser.ts +504 -0
  145. package/src/wirings/cli/define-cli-commands.ts +24 -0
  146. package/src/wirings/cli/index.ts +19 -0
  147. package/src/wirings/gateway/gateway-runner.test.ts +474 -0
  148. package/src/wirings/gateway/gateway-runner.ts +411 -0
  149. package/src/wirings/gateway/gateway.types.ts +149 -0
  150. package/src/wirings/gateway/index.ts +13 -0
  151. package/src/wirings/http/http-routes.test.ts +322 -0
  152. package/src/wirings/http/http-routes.ts +197 -0
  153. package/src/wirings/http/http-runner.test.ts +144 -0
  154. package/src/wirings/http/http-runner.ts +588 -0
  155. package/src/wirings/http/http.types.ts +369 -0
  156. package/src/wirings/http/index.ts +28 -0
  157. package/src/wirings/http/log-http-routes.ts +22 -0
  158. package/src/wirings/http/pikku-fetch-http-request.test.ts +237 -0
  159. package/src/wirings/http/pikku-fetch-http-request.ts +170 -0
  160. package/src/wirings/http/pikku-fetch-http-response.test.ts +82 -0
  161. package/src/wirings/http/pikku-fetch-http-response.ts +147 -0
  162. package/src/wirings/http/routers/http-router.ts +13 -0
  163. package/src/wirings/http/routers/path-to-regex.test.ts +319 -0
  164. package/src/wirings/http/routers/path-to-regex.ts +126 -0
  165. package/src/wirings/http/web-request.test.ts +236 -0
  166. package/src/wirings/http/web-request.ts +104 -0
  167. package/src/wirings/mcp/index.ts +27 -0
  168. package/src/wirings/mcp/mcp-endpoint-registry.test.ts +389 -0
  169. package/src/wirings/mcp/mcp-endpoint-registry.ts +159 -0
  170. package/src/wirings/mcp/mcp-runner.ts +323 -0
  171. package/src/wirings/mcp/mcp.types.ts +216 -0
  172. package/src/wirings/node/index.ts +2 -0
  173. package/src/wirings/node/node.types.ts +23 -0
  174. package/src/wirings/oauth2/index.ts +3 -0
  175. package/src/wirings/oauth2/oauth2-client.test.ts +929 -0
  176. package/src/wirings/oauth2/oauth2-client.ts +335 -0
  177. package/src/wirings/oauth2/oauth2.types.ts +69 -0
  178. package/src/wirings/oauth2/wire-oauth2-credential.ts +23 -0
  179. package/src/wirings/queue/index.ts +31 -0
  180. package/src/wirings/queue/queue-runner.test.ts +710 -0
  181. package/src/wirings/queue/queue-runner.ts +192 -0
  182. package/src/wirings/queue/queue.types.ts +189 -0
  183. package/src/wirings/queue/register-queue-helper.ts +60 -0
  184. package/src/wirings/queue/validate-worker-config.test.ts +108 -0
  185. package/src/wirings/queue/validate-worker-config.ts +116 -0
  186. package/src/wirings/rpc/index.ts +4 -0
  187. package/src/wirings/rpc/rpc-runner.ts +460 -0
  188. package/src/wirings/rpc/rpc-types.ts +56 -0
  189. package/src/wirings/rpc/wire-addon.ts +21 -0
  190. package/src/wirings/scheduler/index.ts +11 -0
  191. package/src/wirings/scheduler/log-schedulers.ts +20 -0
  192. package/src/wirings/scheduler/scheduler-runner.test.ts +660 -0
  193. package/src/wirings/scheduler/scheduler-runner.ts +133 -0
  194. package/src/wirings/scheduler/scheduler.types.ts +53 -0
  195. package/src/wirings/secret/index.ts +9 -0
  196. package/src/wirings/secret/secret.types.ts +32 -0
  197. package/src/wirings/secret/validate-secret-definitions.test.ts +140 -0
  198. package/src/wirings/secret/validate-secret-definitions.ts +82 -0
  199. package/src/wirings/trigger/index.ts +10 -0
  200. package/src/wirings/trigger/pikku-trigger-service.ts +112 -0
  201. package/src/wirings/trigger/trigger-runner.test.ts +79 -0
  202. package/src/wirings/trigger/trigger-runner.ts +135 -0
  203. package/src/wirings/trigger/trigger.types.ts +178 -0
  204. package/src/wirings/variable/index.ts +8 -0
  205. package/src/wirings/variable/validate-variable-definitions.test.ts +91 -0
  206. package/src/wirings/variable/validate-variable-definitions.ts +69 -0
  207. package/src/wirings/variable/variable.types.ts +22 -0
  208. package/src/wirings/workflow/dsl/index.ts +31 -0
  209. package/src/wirings/workflow/dsl/workflow-dsl.types.ts +320 -0
  210. package/src/wirings/workflow/dsl/workflow-runner.ts +28 -0
  211. package/src/wirings/workflow/graph/graph-node.ts +222 -0
  212. package/src/wirings/workflow/graph/graph-runner.test.ts +487 -0
  213. package/src/wirings/workflow/graph/graph-runner.ts +816 -0
  214. package/src/wirings/workflow/graph/graph-validation.test.ts +170 -0
  215. package/src/wirings/workflow/graph/graph-validation.ts +237 -0
  216. package/src/wirings/workflow/graph/index.ts +19 -0
  217. package/src/wirings/workflow/graph/template.test.ts +49 -0
  218. package/src/wirings/workflow/graph/template.ts +42 -0
  219. package/src/wirings/workflow/graph/wire-workflow-graph.ts +29 -0
  220. package/src/wirings/workflow/graph/workflow-graph.types.ts +104 -0
  221. package/src/wirings/workflow/index.ts +82 -0
  222. package/src/wirings/workflow/pikku-workflow-service.test.ts +200 -0
  223. package/src/wirings/workflow/pikku-workflow-service.ts +1179 -0
  224. package/src/wirings/workflow/workflow-helpers.test.ts +129 -0
  225. package/src/wirings/workflow/workflow-helpers.ts +79 -0
  226. package/src/wirings/workflow/workflow.types.ts +274 -0
  227. package/tsconfig.json +14 -0
  228. package/tsconfig.tsbuildinfo +1 -0
  229. package/lcov.info +0 -18891
@@ -0,0 +1,859 @@
1
+ import { beforeEach, describe, test } from 'node:test'
2
+ import assert from 'node:assert/strict'
3
+
4
+ import { resetPikkuState, pikkuState } from '../../pikku-state.js'
5
+ import { streamAIAgent } from './ai-agent-stream.js'
6
+ import { ToolApprovalRequired } from './ai-agent-prepare.js'
7
+ import type {
8
+ CoreAIAgent,
9
+ AIStreamEvent,
10
+ PikkuAIMiddlewareHooks,
11
+ } from './ai-agent.types.js'
12
+ import type { AIAgentStepResult } from '../../services/ai-agent-runner-service.js'
13
+
14
+ beforeEach(() => {
15
+ resetPikkuState()
16
+ })
17
+
18
+ const addTestAgent = (agentName: string) => {
19
+ const agent: CoreAIAgent = {
20
+ name: agentName,
21
+ description: 'test agent',
22
+ instructions: 'be helpful',
23
+ model: 'test-model',
24
+ }
25
+
26
+ pikkuState(null, 'agent', 'agentsMeta')[agentName] = {
27
+ ...agent,
28
+ inputSchema: null,
29
+ outputSchema: null,
30
+ workingMemorySchema: null,
31
+ }
32
+ pikkuState(null, 'agent', 'agents').set(agentName, agent)
33
+ pikkuState(null, 'models', 'config', {
34
+ models: { 'test-model': 'test/test-model' },
35
+ })
36
+ }
37
+
38
+ const makeStepResult = (
39
+ overrides?: Partial<AIAgentStepResult>
40
+ ): AIAgentStepResult => ({
41
+ text: '',
42
+ toolCalls: [],
43
+ toolResults: [],
44
+ usage: { inputTokens: 0, outputTokens: 0 },
45
+ finishReason: 'stop',
46
+ ...overrides,
47
+ })
48
+
49
+ describe('streamAIAgent', () => {
50
+ test('marks run as failed and emits error/done when stream runner throws', async () => {
51
+ addTestAgent('failing-stream-agent')
52
+
53
+ const updates: Array<{ runId: string; patch: unknown }> = []
54
+ const events: AIStreamEvent[] = []
55
+
56
+ const mockServices = {
57
+ logger: {
58
+ info: () => {},
59
+ warn: () => {},
60
+ error: () => {},
61
+ debug: () => {},
62
+ },
63
+ aiAgentRunner: {
64
+ stream: async () => {
65
+ throw new Error('stream failed')
66
+ },
67
+ },
68
+ aiRunState: {
69
+ createRun: async () => 'run-1',
70
+ updateRun: async (runId: string, patch: unknown) => {
71
+ updates.push({ runId, patch })
72
+ },
73
+ },
74
+ } as any
75
+
76
+ pikkuState(null, 'package', 'singletonServices', mockServices)
77
+
78
+ await streamAIAgent(
79
+ 'failing-stream-agent',
80
+ {
81
+ message: 'hello',
82
+ threadId: 'thread-1',
83
+ resourceId: 'resource-1',
84
+ },
85
+ {
86
+ channelId: 'channel-1',
87
+ openingData: undefined,
88
+ state: 'open',
89
+ send: (event: AIStreamEvent) => {
90
+ events.push(event)
91
+ },
92
+ close: () => {},
93
+ },
94
+ {}
95
+ )
96
+
97
+ assert.deepEqual(updates, [
98
+ {
99
+ runId: 'run-1',
100
+ patch: { status: 'failed', errorMessage: 'stream failed' },
101
+ },
102
+ ])
103
+ assert.deepEqual(
104
+ events.map((event) => event.type),
105
+ ['step-start', 'error', 'done']
106
+ )
107
+ })
108
+
109
+ test('completes normally when stream returns no tool calls', async () => {
110
+ addTestAgent('simple-stream-agent')
111
+
112
+ const updates: Array<{ runId: string; patch: unknown }> = []
113
+ const events: AIStreamEvent[] = []
114
+
115
+ const mockServices = {
116
+ logger: {
117
+ info: () => {},
118
+ warn: () => {},
119
+ error: () => {},
120
+ debug: () => {},
121
+ },
122
+ aiAgentRunner: {
123
+ stream: async (): Promise<AIAgentStepResult> => {
124
+ return makeStepResult({ text: 'Hello world' })
125
+ },
126
+ },
127
+ aiRunState: {
128
+ createRun: async () => 'run-simple',
129
+ updateRun: async (runId: string, patch: unknown) => {
130
+ updates.push({ runId, patch })
131
+ },
132
+ },
133
+ } as any
134
+
135
+ pikkuState(null, 'package', 'singletonServices', mockServices)
136
+
137
+ await streamAIAgent(
138
+ 'simple-stream-agent',
139
+ {
140
+ message: 'hello',
141
+ threadId: 'thread-simple',
142
+ resourceId: 'resource-simple',
143
+ },
144
+ {
145
+ channelId: 'channel-simple',
146
+ openingData: undefined,
147
+ state: 'open',
148
+ send: (event: AIStreamEvent) => {
149
+ events.push(event)
150
+ },
151
+ close: () => {},
152
+ },
153
+ {}
154
+ )
155
+
156
+ assert.deepEqual(updates, [
157
+ { runId: 'run-simple', patch: { status: 'completed' } },
158
+ ])
159
+ assert.ok(events.some((e) => e.type === 'done'))
160
+ })
161
+
162
+ test('suspends run when tool needs approval (needsApproval flag)', async () => {
163
+ addTestAgent('approval-stream-agent')
164
+
165
+ const updates: Array<{ runId: string; patch: unknown }> = []
166
+ const events: AIStreamEvent[] = []
167
+
168
+ const mockServices = {
169
+ logger: {
170
+ info: () => {},
171
+ warn: () => {},
172
+ error: () => {},
173
+ debug: () => {},
174
+ },
175
+ aiAgentRunner: {
176
+ stream: async (): Promise<AIAgentStepResult> => {
177
+ return makeStepResult({
178
+ toolCalls: [
179
+ {
180
+ toolCallId: 'tool-call-1',
181
+ toolName: 'tool-x',
182
+ args: { id: 1 },
183
+ },
184
+ ],
185
+ })
186
+ },
187
+ },
188
+ aiRunState: {
189
+ createRun: async () => 'run-2',
190
+ updateRun: async (runId: string, patch: unknown) => {
191
+ updates.push({ runId, patch })
192
+ },
193
+ },
194
+ } as any
195
+
196
+ pikkuState(null, 'package', 'singletonServices', mockServices)
197
+
198
+ const agentName = 'approval-stream-agent'
199
+ const agent = pikkuState(null, 'agent', 'agents').get(agentName)!
200
+ agent.tools = ['tool-x']
201
+ pikkuState(null, 'agent', 'agents').set(agentName, agent)
202
+ pikkuState(null, 'agent', 'agentsMeta')[agentName].tools = ['tool-x']
203
+ pikkuState(null, 'rpc', 'meta')['tool-x'] = 'tool-x'
204
+ pikkuState(null, 'function', 'meta')['tool-x'] = {
205
+ description: 'test tool',
206
+ approvalRequired: true,
207
+ }
208
+ pikkuState(null, 'misc', 'schemas').set('ToolXInput', {
209
+ type: 'object',
210
+ properties: { id: { type: 'number' } },
211
+ })
212
+
213
+ await streamAIAgent(
214
+ agentName,
215
+ {
216
+ message: 'hello',
217
+ threadId: 'thread-2',
218
+ resourceId: 'resource-2',
219
+ },
220
+ {
221
+ channelId: 'channel-2',
222
+ openingData: undefined,
223
+ state: 'open',
224
+ send: (event: AIStreamEvent) => {
225
+ events.push(event)
226
+ },
227
+ close: () => {},
228
+ },
229
+ {},
230
+ undefined,
231
+ { requiresToolApproval: 'all' }
232
+ )
233
+
234
+ assert.deepEqual(updates, [
235
+ {
236
+ runId: 'run-2',
237
+ patch: {
238
+ status: 'suspended',
239
+ suspendReason: 'approval',
240
+ pendingApprovals: [
241
+ {
242
+ type: 'tool-call',
243
+ toolCallId: 'tool-call-1',
244
+ toolName: 'tool-x',
245
+ args: { id: 1 },
246
+ },
247
+ ],
248
+ },
249
+ },
250
+ ])
251
+
252
+ const approvalEvent = events.find(
253
+ (e) => e.type === 'approval-request'
254
+ ) as any
255
+ assert.ok(approvalEvent)
256
+ assert.equal(approvalEvent.toolCallId, 'tool-call-1')
257
+ assert.equal(approvalEvent.toolName, 'tool-x')
258
+ assert.deepEqual(approvalEvent.args, { id: 1 })
259
+
260
+ assert.deepEqual(
261
+ events
262
+ .filter((e) => e.type === 'approval-request' || e.type === 'done')
263
+ .map((e) => e.type),
264
+ ['approval-request', 'done']
265
+ )
266
+ })
267
+
268
+ test('suspends run when multiple tool calls need approval (explicit policy)', async () => {
269
+ addTestAgent('multi-approval-agent')
270
+
271
+ const updates: Array<{ runId: string; patch: unknown }> = []
272
+ const events: AIStreamEvent[] = []
273
+
274
+ const mockServices = {
275
+ logger: {
276
+ info: () => {},
277
+ warn: () => {},
278
+ error: () => {},
279
+ debug: () => {},
280
+ },
281
+ aiAgentRunner: {
282
+ stream: async (): Promise<AIAgentStepResult> => {
283
+ // Simulate LLM returning 3 addTodo calls in one step
284
+ return makeStepResult({
285
+ toolCalls: [
286
+ {
287
+ toolCallId: 'tc-1',
288
+ toolName: 'todos__addTodo',
289
+ args: { title: 'todo 1' },
290
+ },
291
+ {
292
+ toolCallId: 'tc-2',
293
+ toolName: 'todos__addTodo',
294
+ args: { title: 'todo 2' },
295
+ },
296
+ {
297
+ toolCallId: 'tc-3',
298
+ toolName: 'todos__addTodo',
299
+ args: { title: 'todo 3' },
300
+ },
301
+ ],
302
+ // No toolResults since tools have no execute (needsApproval)
303
+ })
304
+ },
305
+ },
306
+ aiRunState: {
307
+ createRun: async () => 'run-multi-approval',
308
+ updateRun: async (runId: string, patch: unknown) => {
309
+ updates.push({ runId, patch })
310
+ },
311
+ },
312
+ } as any
313
+
314
+ pikkuState(null, 'package', 'singletonServices', mockServices)
315
+
316
+ const agentName = 'multi-approval-agent'
317
+ pikkuState(null, 'agent', 'agentsMeta')[agentName].tools = [
318
+ 'todos__addTodo',
319
+ ]
320
+ pikkuState(null, 'rpc', 'meta')['todos__addTodo'] = 'addTodo'
321
+ pikkuState(null, 'function', 'meta')['addTodo'] = {
322
+ description: 'Add a todo',
323
+ approvalRequired: true,
324
+ inputSchemaName: 'AddTodoInput',
325
+ }
326
+ pikkuState(null, 'misc', 'schemas').set('AddTodoInput', {
327
+ type: 'object',
328
+ properties: { title: { type: 'string' } },
329
+ })
330
+
331
+ await streamAIAgent(
332
+ agentName,
333
+ {
334
+ message: 'create 3 todos',
335
+ threadId: 'thread-multi-approval',
336
+ resourceId: 'resource-multi-approval',
337
+ },
338
+ {
339
+ channelId: 'channel-multi-approval',
340
+ openingData: undefined,
341
+ state: 'open',
342
+ send: (event: AIStreamEvent) => {
343
+ events.push(event)
344
+ },
345
+ close: () => {},
346
+ },
347
+ {}
348
+ // No options → defaults to requiresToolApproval: 'explicit'
349
+ )
350
+
351
+ // Should suspend on first tool call
352
+ const approvalEvent = events.find(
353
+ (e) => e.type === 'approval-request'
354
+ ) as any
355
+ assert.ok(approvalEvent, 'Should have an approval-request event')
356
+ assert.equal(approvalEvent.toolCallId, 'tc-1')
357
+ assert.equal(approvalEvent.toolName, 'todos__addTodo')
358
+
359
+ // Run should be suspended
360
+ assert.ok(
361
+ updates.some((u) => (u.patch as any).status === 'suspended'),
362
+ 'Run should be suspended for approval'
363
+ )
364
+
365
+ assert.ok(events.some((e) => e.type === 'done'))
366
+ })
367
+
368
+ test('suspends run for addon tools using namespaced resolution (explicit policy)', async () => {
369
+ addTestAgent('addon-approval-agent')
370
+
371
+ const updates: Array<{ runId: string; patch: unknown }> = []
372
+ const events: AIStreamEvent[] = []
373
+
374
+ const mockServices = {
375
+ logger: {
376
+ info: () => {},
377
+ warn: () => {},
378
+ error: () => {},
379
+ debug: () => {},
380
+ },
381
+ aiAgentRunner: {
382
+ stream: async (params: any): Promise<AIAgentStepResult> => {
383
+ // Verify the tool was built with needsApproval
384
+ const addTodoTool = params.tools.find(
385
+ (t: any) => t.name === 'todos__addTodo'
386
+ )
387
+ assert.ok(addTodoTool, 'Tool todos__addTodo should exist')
388
+ assert.equal(
389
+ addTodoTool.needsApproval,
390
+ true,
391
+ 'Tool should have needsApproval=true'
392
+ )
393
+
394
+ // Simulate LLM calling the tool
395
+ return makeStepResult({
396
+ toolCalls: [
397
+ {
398
+ toolCallId: 'tc-1',
399
+ toolName: 'todos__addTodo',
400
+ args: { title: 'new todo' },
401
+ },
402
+ ],
403
+ })
404
+ },
405
+ },
406
+ aiRunState: {
407
+ createRun: async () => 'run-addon-approval',
408
+ updateRun: async (runId: string, patch: unknown) => {
409
+ updates.push({ runId, patch })
410
+ },
411
+ },
412
+ } as any
413
+
414
+ pikkuState(null, 'package', 'singletonServices', mockServices)
415
+
416
+ // Register addon package
417
+ pikkuState(null, 'addons', 'packages').set('todos', {
418
+ package: '@test/addon-todos',
419
+ })
420
+
421
+ // Set up addon package's function metadata
422
+ pikkuState('@test/addon-todos', 'function', 'meta')['addTodo'] = {
423
+ description: 'Add a todo',
424
+ approvalRequired: true,
425
+ inputSchemaName: 'AddTodoInput',
426
+ }
427
+ pikkuState('@test/addon-todos', 'misc', 'schemas').set('AddTodoInput', {
428
+ type: 'object',
429
+ properties: { title: { type: 'string' } },
430
+ })
431
+
432
+ // Set up agent with namespaced tool
433
+ const agentName = 'addon-approval-agent'
434
+ pikkuState(null, 'agent', 'agentsMeta')[agentName].tools = ['todos:addTodo']
435
+
436
+ await streamAIAgent(
437
+ agentName,
438
+ {
439
+ message: 'add a todo',
440
+ threadId: 'thread-addon-approval',
441
+ resourceId: 'resource-addon-approval',
442
+ },
443
+ {
444
+ channelId: 'channel-addon-approval',
445
+ openingData: undefined,
446
+ state: 'open',
447
+ send: (event: AIStreamEvent) => {
448
+ events.push(event)
449
+ },
450
+ close: () => {},
451
+ },
452
+ {}
453
+ )
454
+
455
+ // Should suspend on the tool call
456
+ const approvalEvent = events.find(
457
+ (e) => e.type === 'approval-request'
458
+ ) as any
459
+ assert.ok(approvalEvent, 'Should have an approval-request event')
460
+ assert.equal(approvalEvent.toolCallId, 'tc-1')
461
+ assert.equal(approvalEvent.toolName, 'todos__addTodo')
462
+
463
+ // Run should be suspended
464
+ assert.ok(
465
+ updates.some((u) => (u.patch as any).status === 'suspended'),
466
+ 'Run should be suspended for approval'
467
+ )
468
+ })
469
+
470
+ test('suspends with agent-call type when sub-agent returns approval sentinel', async () => {
471
+ addTestAgent('parent-agent')
472
+
473
+ const updates: Array<{ runId: string; patch: unknown }> = []
474
+ const events: AIStreamEvent[] = []
475
+
476
+ const mockServices = {
477
+ logger: {
478
+ info: () => {},
479
+ warn: () => {},
480
+ error: () => {},
481
+ debug: () => {},
482
+ },
483
+ aiAgentRunner: {
484
+ stream: async (): Promise<AIAgentStepResult> => {
485
+ return makeStepResult({
486
+ toolCalls: [
487
+ {
488
+ toolCallId: 'tool-call-2',
489
+ toolName: 'sub-agent',
490
+ args: { message: 'hi' },
491
+ },
492
+ ],
493
+ toolResults: [
494
+ {
495
+ toolCallId: 'tool-call-2',
496
+ toolName: 'sub-agent',
497
+ result: {
498
+ __approvalRequired: true,
499
+ toolName: 'sub-agent',
500
+ args: { message: 'hi' },
501
+ displayToolName: 'deleteTodo',
502
+ displayArgs: { todoId: '2' },
503
+ agentRunId: 'sub-run-1',
504
+ },
505
+ },
506
+ ],
507
+ })
508
+ },
509
+ },
510
+ aiRunState: {
511
+ createRun: async () => 'run-3',
512
+ updateRun: async (runId: string, patch: unknown) => {
513
+ updates.push({ runId, patch })
514
+ },
515
+ },
516
+ } as any
517
+
518
+ pikkuState(null, 'package', 'singletonServices', mockServices)
519
+
520
+ await streamAIAgent(
521
+ 'parent-agent',
522
+ {
523
+ message: 'hello',
524
+ threadId: 'thread-3',
525
+ resourceId: 'resource-3',
526
+ },
527
+ {
528
+ channelId: 'channel-3',
529
+ openingData: undefined,
530
+ state: 'open',
531
+ send: (event: AIStreamEvent) => {
532
+ events.push(event)
533
+ },
534
+ close: () => {},
535
+ },
536
+ {}
537
+ )
538
+
539
+ assert.deepEqual(updates, [
540
+ {
541
+ runId: 'run-3',
542
+ patch: {
543
+ status: 'suspended',
544
+ suspendReason: 'approval',
545
+ pendingApprovals: [
546
+ {
547
+ type: 'agent-call',
548
+ toolCallId: 'tool-call-2',
549
+ agentName: 'sub-agent',
550
+ agentRunId: 'sub-run-1',
551
+ displayToolName: 'deleteTodo',
552
+ displayArgs: { todoId: '2' },
553
+ },
554
+ ],
555
+ },
556
+ },
557
+ ])
558
+
559
+ const approvalEvent = events.find(
560
+ (e) => e.type === 'approval-request'
561
+ ) as any
562
+ assert.ok(approvalEvent)
563
+ assert.equal(approvalEvent.toolCallId, 'tool-call-2')
564
+ assert.equal(approvalEvent.toolName, 'deleteTodo')
565
+ assert.deepEqual(approvalEvent.args, { todoId: '2' })
566
+ })
567
+
568
+ test('loops through multiple steps when tools are called', async () => {
569
+ addTestAgent('multi-step-agent')
570
+
571
+ const updates: Array<{ runId: string; patch: unknown }> = []
572
+ const events: AIStreamEvent[] = []
573
+ let callCount = 0
574
+
575
+ const mockServices = {
576
+ logger: {
577
+ info: () => {},
578
+ warn: () => {},
579
+ error: () => {},
580
+ debug: () => {},
581
+ },
582
+ aiAgentRunner: {
583
+ stream: async (): Promise<AIAgentStepResult> => {
584
+ callCount++
585
+ if (callCount === 1) {
586
+ return makeStepResult({
587
+ text: 'Calling tool...',
588
+ toolCalls: [
589
+ { toolCallId: 'tc-1', toolName: 'myTool', args: { q: 'test' } },
590
+ ],
591
+ toolResults: [
592
+ {
593
+ toolCallId: 'tc-1',
594
+ toolName: 'myTool',
595
+ result: 'tool result',
596
+ },
597
+ ],
598
+ finishReason: 'tool-calls',
599
+ })
600
+ }
601
+ return makeStepResult({ text: 'Final answer', finishReason: 'stop' })
602
+ },
603
+ },
604
+ aiRunState: {
605
+ createRun: async () => 'run-multi',
606
+ updateRun: async (runId: string, patch: unknown) => {
607
+ updates.push({ runId, patch })
608
+ },
609
+ },
610
+ } as any
611
+
612
+ pikkuState(null, 'package', 'singletonServices', mockServices)
613
+
614
+ await streamAIAgent(
615
+ 'multi-step-agent',
616
+ {
617
+ message: 'hello',
618
+ threadId: 'thread-multi',
619
+ resourceId: 'resource-multi',
620
+ },
621
+ {
622
+ channelId: 'channel-multi',
623
+ openingData: undefined,
624
+ state: 'open',
625
+ send: (event: AIStreamEvent) => {
626
+ events.push(event)
627
+ },
628
+ close: () => {},
629
+ },
630
+ {}
631
+ )
632
+
633
+ assert.equal(callCount, 2)
634
+ assert.deepEqual(updates, [
635
+ { runId: 'run-multi', patch: { status: 'completed' } },
636
+ ])
637
+ assert.ok(events.some((e) => e.type === 'done'))
638
+ })
639
+
640
+ test('afterStep hook is called for each stream step', async () => {
641
+ addTestAgent('afterstep-stream-agent')
642
+
643
+ const afterStepCalls: unknown[] = []
644
+ const middleware: PikkuAIMiddlewareHooks = {
645
+ afterStep: async (_services, ctx) => {
646
+ afterStepCalls.push(ctx)
647
+ },
648
+ }
649
+ const agent = pikkuState(null, 'agent', 'agents').get(
650
+ 'afterstep-stream-agent'
651
+ )!
652
+ agent.aiMiddleware = [middleware] as any
653
+ pikkuState(null, 'agent', 'agents').set('afterstep-stream-agent', agent)
654
+
655
+ let callCount = 0
656
+ const events: AIStreamEvent[] = []
657
+
658
+ const mockServices = {
659
+ logger: {
660
+ info: () => {},
661
+ warn: () => {},
662
+ error: () => {},
663
+ debug: () => {},
664
+ },
665
+ aiAgentRunner: {
666
+ stream: async (): Promise<AIAgentStepResult> => {
667
+ callCount++
668
+ if (callCount === 1) {
669
+ return makeStepResult({
670
+ text: 'step1',
671
+ toolCalls: [
672
+ { toolCallId: 'tc-1', toolName: 'myTool', args: { q: 'x' } },
673
+ ],
674
+ toolResults: [
675
+ { toolCallId: 'tc-1', toolName: 'myTool', result: 'ok' },
676
+ ],
677
+ usage: { inputTokens: 10, outputTokens: 5 },
678
+ finishReason: 'tool-calls',
679
+ })
680
+ }
681
+ return makeStepResult({
682
+ text: 'final',
683
+ finishReason: 'stop',
684
+ })
685
+ },
686
+ },
687
+ aiRunState: {
688
+ createRun: async () => 'run-as',
689
+ updateRun: async () => {},
690
+ },
691
+ } as any
692
+
693
+ pikkuState(null, 'package', 'singletonServices', mockServices)
694
+
695
+ await streamAIAgent(
696
+ 'afterstep-stream-agent',
697
+ { message: 'hi', threadId: 't-as', resourceId: 'r-as' },
698
+ {
699
+ channelId: 'c-as',
700
+ openingData: undefined,
701
+ state: 'open',
702
+ send: (event: AIStreamEvent) => events.push(event),
703
+ close: () => {},
704
+ },
705
+ {}
706
+ )
707
+
708
+ assert.equal(afterStepCalls.length, 2)
709
+ assert.equal((afterStepCalls[0] as any).stepNumber, 0)
710
+ assert.equal((afterStepCalls[0] as any).finishReason, 'tool-calls')
711
+ assert.equal((afterStepCalls[1] as any).stepNumber, 1)
712
+ assert.equal((afterStepCalls[1] as any).text, 'final')
713
+ })
714
+
715
+ test('onError hook is called when stream throws', async () => {
716
+ addTestAgent('onerror-stream-agent')
717
+
718
+ const onErrorCalls: unknown[] = []
719
+ const middleware: PikkuAIMiddlewareHooks = {
720
+ onError: async (_services, ctx) => {
721
+ onErrorCalls.push(ctx)
722
+ },
723
+ }
724
+ const agent = pikkuState(null, 'agent', 'agents').get(
725
+ 'onerror-stream-agent'
726
+ )!
727
+ agent.aiMiddleware = [middleware] as any
728
+ pikkuState(null, 'agent', 'agents').set('onerror-stream-agent', agent)
729
+
730
+ const events: AIStreamEvent[] = []
731
+ const mockServices = {
732
+ logger: {
733
+ info: () => {},
734
+ warn: () => {},
735
+ error: () => {},
736
+ debug: () => {},
737
+ },
738
+ aiAgentRunner: {
739
+ stream: async () => {
740
+ throw new Error('stream boom')
741
+ },
742
+ },
743
+ aiRunState: {
744
+ createRun: async () => 'run-oe',
745
+ updateRun: async () => {},
746
+ },
747
+ } as any
748
+
749
+ pikkuState(null, 'package', 'singletonServices', mockServices)
750
+
751
+ await streamAIAgent(
752
+ 'onerror-stream-agent',
753
+ { message: 'hi', threadId: 't-oe', resourceId: 'r-oe' },
754
+ {
755
+ channelId: 'c-oe',
756
+ openingData: undefined,
757
+ state: 'open',
758
+ send: (event: AIStreamEvent) => events.push(event),
759
+ close: () => {},
760
+ },
761
+ {}
762
+ )
763
+
764
+ assert.equal(onErrorCalls.length, 1)
765
+ assert.equal((onErrorCalls[0] as any).error.message, 'stream boom')
766
+ assert.ok(events.some((e) => e.type === 'error'))
767
+ assert.ok(events.some((e) => e.type === 'done'))
768
+ })
769
+
770
+ test('beforeToolCall and afterToolCall hooks fire for tool executions', async () => {
771
+ addTestAgent('toolhooks-stream-agent')
772
+
773
+ const hookCalls: { hook: string; ctx: any }[] = []
774
+ const middleware: PikkuAIMiddlewareHooks = {
775
+ beforeToolCall: async (_services, ctx) => {
776
+ hookCalls.push({ hook: 'before', ctx })
777
+ return { args: { ...ctx.args, injected: true } }
778
+ },
779
+ afterToolCall: async (_services, ctx) => {
780
+ hookCalls.push({ hook: 'after', ctx })
781
+ return { result: `modified:${ctx.result}` }
782
+ },
783
+ }
784
+ const agentName = 'toolhooks-stream-agent'
785
+ const agent = pikkuState(null, 'agent', 'agents').get(agentName)!
786
+ agent.aiMiddleware = [middleware] as any
787
+ agent.tools = ['myTool']
788
+ pikkuState(null, 'agent', 'agents').set(agentName, agent)
789
+ pikkuState(null, 'agent', 'agentsMeta')[agentName].tools = ['myTool']
790
+ pikkuState(null, 'rpc', 'meta')['myTool'] = 'myTool'
791
+ pikkuState(null, 'function', 'meta')['myTool'] = {
792
+ description: 'test tool',
793
+ }
794
+
795
+ const events: AIStreamEvent[] = []
796
+ let callCount = 0
797
+
798
+ const mockServices = {
799
+ logger: {
800
+ info: () => {},
801
+ warn: () => {},
802
+ error: () => {},
803
+ debug: () => {},
804
+ },
805
+ aiAgentRunner: {
806
+ stream: async (
807
+ params: any,
808
+ _channel: any
809
+ ): Promise<AIAgentStepResult> => {
810
+ callCount++
811
+ if (callCount === 1) {
812
+ const tool = params.tools.find((t: any) => t.name === 'myTool')
813
+ if (tool) {
814
+ try {
815
+ await tool.execute({ q: 'test' })
816
+ } catch {
817
+ // runPikkuFunc not fully mocked; hooks still fire
818
+ }
819
+ }
820
+ return makeStepResult({
821
+ text: 'done',
822
+ finishReason: 'stop',
823
+ })
824
+ }
825
+ return makeStepResult({ text: 'done', finishReason: 'stop' })
826
+ },
827
+ },
828
+ aiRunState: {
829
+ createRun: async () => 'run-th',
830
+ updateRun: async () => {},
831
+ },
832
+ } as any
833
+
834
+ pikkuState(null, 'package', 'singletonServices', mockServices)
835
+ pikkuState(null, 'package', 'createWireServices', () => ({}))
836
+
837
+ await streamAIAgent(
838
+ agentName,
839
+ { message: 'hi', threadId: 't-th', resourceId: 'r-th' },
840
+ {
841
+ channelId: 'c-th',
842
+ openingData: undefined,
843
+ state: 'open',
844
+ send: (event: AIStreamEvent) => events.push(event),
845
+ close: () => {},
846
+ },
847
+ {}
848
+ )
849
+
850
+ assert.equal(hookCalls.length, 2)
851
+ assert.equal(hookCalls[0].hook, 'before')
852
+ assert.equal(hookCalls[0].ctx.toolName, 'myTool')
853
+ assert.deepEqual(hookCalls[0].ctx.args, { q: 'test' })
854
+ assert.equal(hookCalls[1].hook, 'after')
855
+ assert.equal(hookCalls[1].ctx.toolName, 'myTool')
856
+ assert.deepEqual(hookCalls[1].ctx.args, { q: 'test', injected: true })
857
+ assert.ok(hookCalls[1].ctx.durationMs >= 0)
858
+ })
859
+ })