@amodalai/runtime 0.1.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 (277) hide show
  1. package/LICENSE +21 -0
  2. package/dist/.last_build +0 -0
  3. package/dist/src/agent/agent-runner.d.ts +13 -0
  4. package/dist/src/agent/agent-runner.js +827 -0
  5. package/dist/src/agent/agent-runner.js.map +1 -0
  6. package/dist/src/agent/agent-runner.test.d.ts +6 -0
  7. package/dist/src/agent/agent-runner.test.js +552 -0
  8. package/dist/src/agent/agent-runner.test.js.map +1 -0
  9. package/dist/src/agent/agent-types.d.ts +57 -0
  10. package/dist/src/agent/agent-types.js +17 -0
  11. package/dist/src/agent/agent-types.js.map +1 -0
  12. package/dist/src/agent/agent-types.test.d.ts +6 -0
  13. package/dist/src/agent/agent-types.test.js +44 -0
  14. package/dist/src/agent/agent-types.test.js.map +1 -0
  15. package/dist/src/agent/automation-bridge.d.ts +24 -0
  16. package/dist/src/agent/automation-bridge.js +24 -0
  17. package/dist/src/agent/automation-bridge.js.map +1 -0
  18. package/dist/src/agent/automation-bridge.test.d.ts +6 -0
  19. package/dist/src/agent/automation-bridge.test.js +67 -0
  20. package/dist/src/agent/automation-bridge.test.js.map +1 -0
  21. package/dist/src/agent/config-watcher.d.ts +20 -0
  22. package/dist/src/agent/config-watcher.js +68 -0
  23. package/dist/src/agent/config-watcher.js.map +1 -0
  24. package/dist/src/agent/config-watcher.test.d.ts +6 -0
  25. package/dist/src/agent/config-watcher.test.js +83 -0
  26. package/dist/src/agent/config-watcher.test.js.map +1 -0
  27. package/dist/src/agent/custom-tools-e2e.test.d.ts +6 -0
  28. package/dist/src/agent/custom-tools-e2e.test.js +566 -0
  29. package/dist/src/agent/custom-tools-e2e.test.js.map +1 -0
  30. package/dist/src/agent/local-server.d.ts +15 -0
  31. package/dist/src/agent/local-server.js +158 -0
  32. package/dist/src/agent/local-server.js.map +1 -0
  33. package/dist/src/agent/local-server.test.d.ts +6 -0
  34. package/dist/src/agent/local-server.test.js +126 -0
  35. package/dist/src/agent/local-server.test.js.map +1 -0
  36. package/dist/src/agent/proactive/delivery.d.ts +21 -0
  37. package/dist/src/agent/proactive/delivery.js +68 -0
  38. package/dist/src/agent/proactive/delivery.js.map +1 -0
  39. package/dist/src/agent/proactive/delivery.test.d.ts +6 -0
  40. package/dist/src/agent/proactive/delivery.test.js +65 -0
  41. package/dist/src/agent/proactive/delivery.test.js.map +1 -0
  42. package/dist/src/agent/proactive/proactive-runner.d.ts +76 -0
  43. package/dist/src/agent/proactive/proactive-runner.js +201 -0
  44. package/dist/src/agent/proactive/proactive-runner.js.map +1 -0
  45. package/dist/src/agent/proactive/proactive-runner.test.d.ts +6 -0
  46. package/dist/src/agent/proactive/proactive-runner.test.js +265 -0
  47. package/dist/src/agent/proactive/proactive-runner.test.js.map +1 -0
  48. package/dist/src/agent/request-helper.d.ts +16 -0
  49. package/dist/src/agent/request-helper.js +87 -0
  50. package/dist/src/agent/request-helper.js.map +1 -0
  51. package/dist/src/agent/routes/automations.d.ts +19 -0
  52. package/dist/src/agent/routes/automations.js +58 -0
  53. package/dist/src/agent/routes/automations.js.map +1 -0
  54. package/dist/src/agent/routes/automations.test.d.ts +6 -0
  55. package/dist/src/agent/routes/automations.test.js +117 -0
  56. package/dist/src/agent/routes/automations.test.js.map +1 -0
  57. package/dist/src/agent/routes/chat.d.ts +35 -0
  58. package/dist/src/agent/routes/chat.js +88 -0
  59. package/dist/src/agent/routes/chat.js.map +1 -0
  60. package/dist/src/agent/routes/chat.test.d.ts +6 -0
  61. package/dist/src/agent/routes/chat.test.js +115 -0
  62. package/dist/src/agent/routes/chat.test.js.map +1 -0
  63. package/dist/src/agent/routes/inspect.d.ts +12 -0
  64. package/dist/src/agent/routes/inspect.js +40 -0
  65. package/dist/src/agent/routes/inspect.js.map +1 -0
  66. package/dist/src/agent/routes/inspect.test.d.ts +6 -0
  67. package/dist/src/agent/routes/inspect.test.js +80 -0
  68. package/dist/src/agent/routes/inspect.test.js.map +1 -0
  69. package/dist/src/agent/routes/stores.d.ts +20 -0
  70. package/dist/src/agent/routes/stores.js +137 -0
  71. package/dist/src/agent/routes/stores.js.map +1 -0
  72. package/dist/src/agent/routes/stores.test.d.ts +6 -0
  73. package/dist/src/agent/routes/stores.test.js +191 -0
  74. package/dist/src/agent/routes/stores.test.js.map +1 -0
  75. package/dist/src/agent/routes/task.d.ts +11 -0
  76. package/dist/src/agent/routes/task.js +116 -0
  77. package/dist/src/agent/routes/task.js.map +1 -0
  78. package/dist/src/agent/routes/task.test.d.ts +6 -0
  79. package/dist/src/agent/routes/task.test.js +91 -0
  80. package/dist/src/agent/routes/task.test.js.map +1 -0
  81. package/dist/src/agent/routes/webhooks.d.ts +17 -0
  82. package/dist/src/agent/routes/webhooks.js +53 -0
  83. package/dist/src/agent/routes/webhooks.js.map +1 -0
  84. package/dist/src/agent/routes/webhooks.test.d.ts +6 -0
  85. package/dist/src/agent/routes/webhooks.test.js +100 -0
  86. package/dist/src/agent/routes/webhooks.test.js.map +1 -0
  87. package/dist/src/agent/session-manager.d.ts +72 -0
  88. package/dist/src/agent/session-manager.js +214 -0
  89. package/dist/src/agent/session-manager.js.map +1 -0
  90. package/dist/src/agent/session-manager.test.d.ts +6 -0
  91. package/dist/src/agent/session-manager.test.js +145 -0
  92. package/dist/src/agent/session-manager.test.js.map +1 -0
  93. package/dist/src/agent/shell-executor-local.d.ts +16 -0
  94. package/dist/src/agent/shell-executor-local.js +51 -0
  95. package/dist/src/agent/shell-executor-local.js.map +1 -0
  96. package/dist/src/agent/shell-executor-local.test.d.ts +6 -0
  97. package/dist/src/agent/shell-executor-local.test.js +46 -0
  98. package/dist/src/agent/shell-executor-local.test.js.map +1 -0
  99. package/dist/src/agent/snapshot-server.d.ts +38 -0
  100. package/dist/src/agent/snapshot-server.js +114 -0
  101. package/dist/src/agent/snapshot-server.js.map +1 -0
  102. package/dist/src/agent/stores-e2e.test.d.ts +6 -0
  103. package/dist/src/agent/stores-e2e.test.js +433 -0
  104. package/dist/src/agent/stores-e2e.test.js.map +1 -0
  105. package/dist/src/agent/tool-context-builder.d.ts +11 -0
  106. package/dist/src/agent/tool-context-builder.js +84 -0
  107. package/dist/src/agent/tool-context-builder.js.map +1 -0
  108. package/dist/src/agent/tool-context-builder.test.d.ts +6 -0
  109. package/dist/src/agent/tool-context-builder.test.js +152 -0
  110. package/dist/src/agent/tool-context-builder.test.js.map +1 -0
  111. package/dist/src/agent/tool-executor-local.d.ts +15 -0
  112. package/dist/src/agent/tool-executor-local.js +74 -0
  113. package/dist/src/agent/tool-executor-local.js.map +1 -0
  114. package/dist/src/agent/tool-executor-local.test.d.ts +6 -0
  115. package/dist/src/agent/tool-executor-local.test.js +116 -0
  116. package/dist/src/agent/tool-executor-local.test.js.map +1 -0
  117. package/dist/src/agent/tool-harness-template.d.ts +23 -0
  118. package/dist/src/agent/tool-harness-template.js +94 -0
  119. package/dist/src/agent/tool-harness-template.js.map +1 -0
  120. package/dist/src/agent/user-context-fetcher.d.ts +25 -0
  121. package/dist/src/agent/user-context-fetcher.js +79 -0
  122. package/dist/src/agent/user-context-fetcher.js.map +1 -0
  123. package/dist/src/agent/user-context-fetcher.test.d.ts +6 -0
  124. package/dist/src/agent/user-context-fetcher.test.js +121 -0
  125. package/dist/src/agent/user-context-fetcher.test.js.map +1 -0
  126. package/dist/src/audit/audit-client.d.ts +46 -0
  127. package/dist/src/audit/audit-client.js +83 -0
  128. package/dist/src/audit/audit-client.js.map +1 -0
  129. package/dist/src/cron/heartbeat-runner.d.ts +24 -0
  130. package/dist/src/cron/heartbeat-runner.js +87 -0
  131. package/dist/src/cron/heartbeat-runner.js.map +1 -0
  132. package/dist/src/cron/heartbeat-runner.test.d.ts +6 -0
  133. package/dist/src/cron/heartbeat-runner.test.js +120 -0
  134. package/dist/src/cron/heartbeat-runner.test.js.map +1 -0
  135. package/dist/src/cron/heartbeat-scheduler.d.ts +26 -0
  136. package/dist/src/cron/heartbeat-scheduler.js +54 -0
  137. package/dist/src/cron/heartbeat-scheduler.js.map +1 -0
  138. package/dist/src/cron/heartbeat-scheduler.test.d.ts +6 -0
  139. package/dist/src/cron/heartbeat-scheduler.test.js +61 -0
  140. package/dist/src/cron/heartbeat-scheduler.test.js.map +1 -0
  141. package/dist/src/index.d.ts +24 -0
  142. package/dist/src/index.js +118 -0
  143. package/dist/src/index.js.map +1 -0
  144. package/dist/src/middleware/auth.d.ts +40 -0
  145. package/dist/src/middleware/auth.js +135 -0
  146. package/dist/src/middleware/auth.js.map +1 -0
  147. package/dist/src/middleware/auth.test.d.ts +6 -0
  148. package/dist/src/middleware/auth.test.js +268 -0
  149. package/dist/src/middleware/auth.test.js.map +1 -0
  150. package/dist/src/middleware/error-handler.d.ts +20 -0
  151. package/dist/src/middleware/error-handler.js +48 -0
  152. package/dist/src/middleware/error-handler.js.map +1 -0
  153. package/dist/src/middleware/error-handler.test.d.ts +6 -0
  154. package/dist/src/middleware/error-handler.test.js +68 -0
  155. package/dist/src/middleware/error-handler.test.js.map +1 -0
  156. package/dist/src/middleware/request-validation.d.ts +13 -0
  157. package/dist/src/middleware/request-validation.js +26 -0
  158. package/dist/src/middleware/request-validation.js.map +1 -0
  159. package/dist/src/middleware/request-validation.test.d.ts +6 -0
  160. package/dist/src/middleware/request-validation.test.js +57 -0
  161. package/dist/src/middleware/request-validation.test.js.map +1 -0
  162. package/dist/src/output/email-output.d.ts +10 -0
  163. package/dist/src/output/email-output.js +12 -0
  164. package/dist/src/output/email-output.js.map +1 -0
  165. package/dist/src/output/output-router.d.ts +12 -0
  166. package/dist/src/output/output-router.js +36 -0
  167. package/dist/src/output/output-router.js.map +1 -0
  168. package/dist/src/output/output-router.test.d.ts +6 -0
  169. package/dist/src/output/output-router.test.js +132 -0
  170. package/dist/src/output/output-router.test.js.map +1 -0
  171. package/dist/src/output/slack-output.d.ts +10 -0
  172. package/dist/src/output/slack-output.js +54 -0
  173. package/dist/src/output/slack-output.js.map +1 -0
  174. package/dist/src/output/webhook-output.d.ts +10 -0
  175. package/dist/src/output/webhook-output.js +25 -0
  176. package/dist/src/output/webhook-output.js.map +1 -0
  177. package/dist/src/routes/ai-stream.d.ts +159 -0
  178. package/dist/src/routes/ai-stream.js +309 -0
  179. package/dist/src/routes/ai-stream.js.map +1 -0
  180. package/dist/src/routes/ai-stream.test.d.ts +6 -0
  181. package/dist/src/routes/ai-stream.test.js +586 -0
  182. package/dist/src/routes/ai-stream.test.js.map +1 -0
  183. package/dist/src/routes/ask-user-response.d.ts +30 -0
  184. package/dist/src/routes/ask-user-response.js +61 -0
  185. package/dist/src/routes/ask-user-response.js.map +1 -0
  186. package/dist/src/routes/ask-user-response.test.d.ts +6 -0
  187. package/dist/src/routes/ask-user-response.test.js +88 -0
  188. package/dist/src/routes/ask-user-response.test.js.map +1 -0
  189. package/dist/src/routes/chat-stream.d.ts +14 -0
  190. package/dist/src/routes/chat-stream.js +84 -0
  191. package/dist/src/routes/chat-stream.js.map +1 -0
  192. package/dist/src/routes/chat-stream.test.d.ts +6 -0
  193. package/dist/src/routes/chat-stream.test.js +155 -0
  194. package/dist/src/routes/chat-stream.test.js.map +1 -0
  195. package/dist/src/routes/chat.d.ts +13 -0
  196. package/dist/src/routes/chat.js +55 -0
  197. package/dist/src/routes/chat.js.map +1 -0
  198. package/dist/src/routes/chat.test.d.ts +6 -0
  199. package/dist/src/routes/chat.test.js +99 -0
  200. package/dist/src/routes/chat.test.js.map +1 -0
  201. package/dist/src/routes/health.d.ts +13 -0
  202. package/dist/src/routes/health.js +23 -0
  203. package/dist/src/routes/health.js.map +1 -0
  204. package/dist/src/routes/health.test.d.ts +6 -0
  205. package/dist/src/routes/health.test.js +45 -0
  206. package/dist/src/routes/health.test.js.map +1 -0
  207. package/dist/src/routes/sessions.d.ts +14 -0
  208. package/dist/src/routes/sessions.js +82 -0
  209. package/dist/src/routes/sessions.js.map +1 -0
  210. package/dist/src/routes/webhooks.d.ts +13 -0
  211. package/dist/src/routes/webhooks.js +43 -0
  212. package/dist/src/routes/webhooks.js.map +1 -0
  213. package/dist/src/routes/webhooks.test.d.ts +6 -0
  214. package/dist/src/routes/webhooks.test.js +80 -0
  215. package/dist/src/routes/webhooks.test.js.map +1 -0
  216. package/dist/src/routes/widget-actions.d.ts +49 -0
  217. package/dist/src/routes/widget-actions.js +78 -0
  218. package/dist/src/routes/widget-actions.js.map +1 -0
  219. package/dist/src/server.d.ts +31 -0
  220. package/dist/src/server.js +129 -0
  221. package/dist/src/server.js.map +1 -0
  222. package/dist/src/server.test.d.ts +6 -0
  223. package/dist/src/server.test.js +153 -0
  224. package/dist/src/server.test.js.map +1 -0
  225. package/dist/src/session/history-converter.d.ts +21 -0
  226. package/dist/src/session/history-converter.js +59 -0
  227. package/dist/src/session/history-converter.js.map +1 -0
  228. package/dist/src/session/history-converter.test.d.ts +6 -0
  229. package/dist/src/session/history-converter.test.js +130 -0
  230. package/dist/src/session/history-converter.test.js.map +1 -0
  231. package/dist/src/session/session-manager.d.ts +117 -0
  232. package/dist/src/session/session-manager.js +480 -0
  233. package/dist/src/session/session-manager.js.map +1 -0
  234. package/dist/src/session/session-manager.test.d.ts +6 -0
  235. package/dist/src/session/session-manager.test.js +586 -0
  236. package/dist/src/session/session-manager.test.js.map +1 -0
  237. package/dist/src/session/session-runner.d.ts +30 -0
  238. package/dist/src/session/session-runner.js +771 -0
  239. package/dist/src/session/session-runner.js.map +1 -0
  240. package/dist/src/session/session-runner.test.d.ts +6 -0
  241. package/dist/src/session/session-runner.test.js +842 -0
  242. package/dist/src/session/session-runner.test.js.map +1 -0
  243. package/dist/src/stores/index.d.ts +8 -0
  244. package/dist/src/stores/index.js +9 -0
  245. package/dist/src/stores/index.js.map +1 -0
  246. package/dist/src/stores/key-resolver.d.ts +21 -0
  247. package/dist/src/stores/key-resolver.js +30 -0
  248. package/dist/src/stores/key-resolver.js.map +1 -0
  249. package/dist/src/stores/key-resolver.test.d.ts +6 -0
  250. package/dist/src/stores/key-resolver.test.js +31 -0
  251. package/dist/src/stores/key-resolver.test.js.map +1 -0
  252. package/dist/src/stores/pglite-store-backend.d.ts +36 -0
  253. package/dist/src/stores/pglite-store-backend.js +227 -0
  254. package/dist/src/stores/pglite-store-backend.js.map +1 -0
  255. package/dist/src/stores/pglite-store-backend.test.d.ts +6 -0
  256. package/dist/src/stores/pglite-store-backend.test.js +150 -0
  257. package/dist/src/stores/pglite-store-backend.test.js.map +1 -0
  258. package/dist/src/stores/ttl-resolver.d.ts +24 -0
  259. package/dist/src/stores/ttl-resolver.js +64 -0
  260. package/dist/src/stores/ttl-resolver.js.map +1 -0
  261. package/dist/src/stores/ttl-resolver.test.d.ts +6 -0
  262. package/dist/src/stores/ttl-resolver.test.js +68 -0
  263. package/dist/src/stores/ttl-resolver.test.js.map +1 -0
  264. package/dist/src/types.d.ts +227 -0
  265. package/dist/src/types.js +50 -0
  266. package/dist/src/types.js.map +1 -0
  267. package/dist/src/types.test.d.ts +6 -0
  268. package/dist/src/types.test.js +68 -0
  269. package/dist/src/types.test.js.map +1 -0
  270. package/dist/src/utils/jwt-verify.d.ts +20 -0
  271. package/dist/src/utils/jwt-verify.js +34 -0
  272. package/dist/src/utils/jwt-verify.js.map +1 -0
  273. package/dist/src/utils/jwt-verify.test.d.ts +6 -0
  274. package/dist/src/utils/jwt-verify.test.js +156 -0
  275. package/dist/src/utils/jwt-verify.test.js.map +1 -0
  276. package/dist/tsconfig.tsbuildinfo +1 -0
  277. package/package.json +51 -0
@@ -0,0 +1,842 @@
1
+ /**
2
+ * @license
3
+ * Copyright 2025 Amodal Labs, Inc.
4
+ * SPDX-License-Identifier: MIT
5
+ */
6
+ import { describe, it, expect, vi, beforeEach } from 'vitest';
7
+ import { SSEEventType } from '../types.js';
8
+ // Use string constants to avoid importing the enum from core
9
+ const CONTENT = 'content';
10
+ const TOOL_CALL_REQUEST = 'tool_call_request';
11
+ const ERROR = 'error';
12
+ const AGENT_EXECUTION_STOPPED = 'agent_execution_stopped';
13
+ const ASK_USER = 'ask_user';
14
+ vi.mock('@amodalai/core', () => ({
15
+ GeminiEventType: {
16
+ Content: CONTENT,
17
+ ToolCallRequest: TOOL_CALL_REQUEST,
18
+ Error: ERROR,
19
+ AgentExecutionStopped: AGENT_EXECUTION_STOPPED,
20
+ },
21
+ ToolErrorType: {
22
+ STOP_EXECUTION: 'stop_execution',
23
+ },
24
+ MessageBusType: {
25
+ SUBAGENT_ACTIVITY: 'subagent-activity',
26
+ },
27
+ PRESENT_TOOL_NAME: 'present',
28
+ ACTIVATE_SKILL_TOOL_NAME: 'activate_skill',
29
+ ASK_USER_TOOL_NAME: ASK_USER,
30
+ SessionManager: vi.fn(),
31
+ }));
32
+ const { runMessage, streamMessage } = await import('./session-runner.js');
33
+ // Helper to create an async generator from an array of events
34
+ async function* makeStream(events) {
35
+ for (const event of events) {
36
+ yield event;
37
+ }
38
+ }
39
+ function createMockSession(streamEvents = []) {
40
+ const mockSchedule = vi.fn().mockResolvedValue([]);
41
+ const mockRecordCompletedToolCalls = vi.fn();
42
+ return {
43
+ session: {
44
+ id: 'sess-123',
45
+ config: {
46
+ getModel: vi.fn().mockReturnValue('test-model'),
47
+ shutdownAudit: vi.fn(),
48
+ getMessageBus: vi.fn().mockReturnValue({
49
+ subscribe: vi.fn(),
50
+ unsubscribe: vi.fn(),
51
+ }),
52
+ },
53
+ geminiClient: {
54
+ sendMessageStream: vi.fn().mockReturnValue(makeStream(streamEvents)),
55
+ getCurrentSequenceModel: vi.fn().mockReturnValue('test-model'),
56
+ getChat: vi.fn().mockReturnValue({
57
+ recordCompletedToolCalls: mockRecordCompletedToolCalls,
58
+ }),
59
+ },
60
+ scheduler: {
61
+ schedule: mockSchedule,
62
+ },
63
+ createdAt: Date.now(),
64
+ lastAccessedAt: Date.now(),
65
+ accumulatedMessages: [],
66
+ },
67
+ mockSchedule,
68
+ mockRecordCompletedToolCalls,
69
+ };
70
+ }
71
+ describe('runMessage', () => {
72
+ let controller;
73
+ beforeEach(() => {
74
+ controller = new AbortController();
75
+ });
76
+ it('returns text response from content events', async () => {
77
+ const { session } = createMockSession([
78
+ { type: CONTENT, value: 'Hello ' },
79
+ { type: CONTENT, value: 'world!' },
80
+ ]);
81
+ const result = await runMessage(session, 'hi', controller.signal);
82
+ expect(result.session_id).toBe('sess-123');
83
+ expect(result.response).toBe('Hello world!');
84
+ expect(result.tool_calls).toHaveLength(0);
85
+ });
86
+ it('handles tool calls and continues loop', async () => {
87
+ const toolCallEvent = {
88
+ type: TOOL_CALL_REQUEST,
89
+ value: {
90
+ callId: 'call-1',
91
+ name: 'get_info',
92
+ args: { id: '42' },
93
+ isClientInitiated: false,
94
+ prompt_id: 'p1',
95
+ },
96
+ };
97
+ // First call returns tool call, second returns text
98
+ let callCount = 0;
99
+ const { session, mockSchedule } = createMockSession();
100
+ session.geminiClient.sendMessageStream = vi.fn().mockImplementation(() => {
101
+ callCount++;
102
+ if (callCount === 1) {
103
+ return makeStream([toolCallEvent]);
104
+ }
105
+ return makeStream([{ type: CONTENT, value: 'Result: done' }]);
106
+ });
107
+ mockSchedule.mockResolvedValue([
108
+ {
109
+ request: { callId: 'call-1', name: 'get_info' },
110
+ response: {
111
+ responseParts: [
112
+ { functionResponse: { id: 'call-1', name: 'get_info', response: { result: 'ok' } } },
113
+ ],
114
+ error: undefined,
115
+ errorType: undefined,
116
+ },
117
+ durationMs: 50,
118
+ },
119
+ ]);
120
+ const result = await runMessage(session, 'do something', controller.signal);
121
+ expect(result.tool_calls).toHaveLength(1);
122
+ expect(result.tool_calls[0]?.tool_name).toBe('get_info');
123
+ expect(result.tool_calls[0]?.status).toBe('success');
124
+ expect(result.response).toBe('Result: done');
125
+ });
126
+ it('stops on AgentExecutionStopped event', async () => {
127
+ const { session } = createMockSession([
128
+ { type: CONTENT, value: 'partial ' },
129
+ {
130
+ type: AGENT_EXECUTION_STOPPED,
131
+ value: { reason: 'done', systemMessage: 'Agent stopped' },
132
+ },
133
+ ]);
134
+ const result = await runMessage(session, 'stop test', controller.signal);
135
+ expect(result.response).toBe('partial ');
136
+ });
137
+ it('throws on error events', async () => {
138
+ const { session } = createMockSession([
139
+ {
140
+ type: ERROR,
141
+ value: { error: { message: 'LLM error' } },
142
+ },
143
+ ]);
144
+ await expect(runMessage(session, 'error test', controller.signal)).rejects.toThrow('LLM error');
145
+ });
146
+ it('reports error tool calls', async () => {
147
+ const toolCallEvent = {
148
+ type: TOOL_CALL_REQUEST,
149
+ value: {
150
+ callId: 'call-1',
151
+ name: 'failing_tool',
152
+ args: {},
153
+ isClientInitiated: false,
154
+ prompt_id: 'p1',
155
+ },
156
+ };
157
+ let callCount = 0;
158
+ const { session, mockSchedule } = createMockSession();
159
+ session.geminiClient.sendMessageStream = vi.fn().mockImplementation(() => {
160
+ callCount++;
161
+ if (callCount === 1) {
162
+ return makeStream([toolCallEvent]);
163
+ }
164
+ return makeStream([{ type: CONTENT, value: 'after error' }]);
165
+ });
166
+ mockSchedule.mockResolvedValue([
167
+ {
168
+ request: { callId: 'call-1', name: 'failing_tool' },
169
+ response: {
170
+ responseParts: [
171
+ { functionResponse: { id: 'call-1', name: 'failing_tool', response: { error: 'fail' } } },
172
+ ],
173
+ error: new Error('tool failed'),
174
+ errorType: 'TOOL_EXECUTION_ERROR',
175
+ },
176
+ durationMs: 10,
177
+ },
178
+ ]);
179
+ const result = await runMessage(session, 'error tool', controller.signal);
180
+ expect(result.tool_calls[0]?.status).toBe('error');
181
+ expect(result.tool_calls[0]?.error).toBe('tool failed');
182
+ });
183
+ it('stops on STOP_EXECUTION tool error type', async () => {
184
+ const toolCallEvent = {
185
+ type: TOOL_CALL_REQUEST,
186
+ value: {
187
+ callId: 'call-1',
188
+ name: 'stop_tool',
189
+ args: {},
190
+ isClientInitiated: false,
191
+ prompt_id: 'p1',
192
+ },
193
+ };
194
+ const { session, mockSchedule } = createMockSession();
195
+ session.geminiClient.sendMessageStream = vi
196
+ .fn()
197
+ .mockReturnValue(makeStream([toolCallEvent]));
198
+ mockSchedule.mockResolvedValue([
199
+ {
200
+ request: { callId: 'call-1', name: 'stop_tool' },
201
+ response: {
202
+ responseParts: [],
203
+ error: new Error('stopping'),
204
+ errorType: 'stop_execution',
205
+ },
206
+ },
207
+ ]);
208
+ const result = await runMessage(session, 'stop', controller.signal);
209
+ expect(result.tool_calls).toHaveLength(1);
210
+ });
211
+ it('logs a single session_completed audit event on success', async () => {
212
+ const toolCallEvent = {
213
+ type: TOOL_CALL_REQUEST,
214
+ value: {
215
+ callId: 'call-1',
216
+ name: 'get_info',
217
+ args: { id: '42' },
218
+ isClientInitiated: false,
219
+ prompt_id: 'p1',
220
+ },
221
+ };
222
+ let callCount = 0;
223
+ const { session, mockSchedule } = createMockSession();
224
+ session.geminiClient.sendMessageStream = vi.fn().mockImplementation(() => {
225
+ callCount++;
226
+ if (callCount === 1) {
227
+ return makeStream([toolCallEvent]);
228
+ }
229
+ return makeStream([{ type: CONTENT, value: 'Done' }]);
230
+ });
231
+ mockSchedule.mockResolvedValue([
232
+ {
233
+ request: { callId: 'call-1', name: 'get_info' },
234
+ response: {
235
+ responseParts: [
236
+ { functionResponse: { id: 'call-1', name: 'get_info', response: { result: 'ok' } } },
237
+ ],
238
+ error: undefined,
239
+ errorType: undefined,
240
+ },
241
+ durationMs: 50,
242
+ },
243
+ ]);
244
+ const mockLog = vi.fn();
245
+ const audit = {
246
+ auditClient: { log: mockLog },
247
+ appId: 'app-1',
248
+ token: 'tok-1',
249
+ tenantId: 'tenant-1',
250
+ orgId: 'org-1',
251
+ };
252
+ await runMessage(session, 'do something', controller.signal, audit);
253
+ expect(mockLog).toHaveBeenCalledOnce();
254
+ const [appId, token, entry] = mockLog.mock.calls[0];
255
+ expect(appId).toBe('app-1');
256
+ expect(token).toBe('tok-1');
257
+ expect(entry['event']).toBe('session_completed');
258
+ expect(entry['resource_name']).toBe('sess-123');
259
+ const details = entry['details'];
260
+ expect(details['status']).toBe('completed');
261
+ expect(details['message']).toBe('do something');
262
+ expect(details['response']).toBe('Done');
263
+ expect(details['tenant_id']).toBe('tenant-1');
264
+ expect(details['org_id']).toBe('org-1');
265
+ expect(details['turns']).toBe(2);
266
+ const toolCalls = details['tool_calls'];
267
+ expect(toolCalls).toHaveLength(1);
268
+ expect(toolCalls[0]?.['tool_name']).toBe('get_info');
269
+ expect(toolCalls[0]?.['status']).toBe('success');
270
+ });
271
+ it('captures tool result text in audit log', async () => {
272
+ const toolCallEvent = {
273
+ type: TOOL_CALL_REQUEST,
274
+ value: {
275
+ callId: 'call-1',
276
+ name: 'shell_exec',
277
+ args: { command: 'curl http://api/devices' },
278
+ isClientInitiated: false,
279
+ prompt_id: 'p1',
280
+ },
281
+ };
282
+ let callCount = 0;
283
+ const { session, mockSchedule } = createMockSession();
284
+ session.geminiClient.sendMessageStream = vi.fn().mockImplementation(() => {
285
+ callCount++;
286
+ if (callCount === 1) {
287
+ return makeStream([toolCallEvent]);
288
+ }
289
+ return makeStream([{ type: CONTENT, value: 'Got it' }]);
290
+ });
291
+ mockSchedule.mockResolvedValue([
292
+ {
293
+ request: { callId: 'call-1', name: 'shell_exec' },
294
+ response: {
295
+ responseParts: [
296
+ { text: '{"devices": [{"id": "d1", "name": "sensor-001"}]}' },
297
+ ],
298
+ error: undefined,
299
+ errorType: undefined,
300
+ },
301
+ durationMs: 120,
302
+ },
303
+ ]);
304
+ const mockLog = vi.fn();
305
+ const audit = {
306
+ auditClient: { log: mockLog },
307
+ appId: 'app-1',
308
+ token: 'tok-1',
309
+ };
310
+ await runMessage(session, 'list devices', controller.signal, audit);
311
+ const [, , entry] = mockLog.mock.calls[0];
312
+ const details = entry['details'];
313
+ const toolCalls = details['tool_calls'];
314
+ expect(toolCalls[0]?.['result']).toBe('{"devices": [{"id": "d1", "name": "sensor-001"}]}');
315
+ });
316
+ it('captures functionResponse result from task agents in audit log', async () => {
317
+ const toolCallEvent = {
318
+ type: TOOL_CALL_REQUEST,
319
+ value: {
320
+ callId: 'call-1',
321
+ name: 'environment_scanner',
322
+ args: {},
323
+ isClientInitiated: false,
324
+ prompt_id: 'p1',
325
+ },
326
+ };
327
+ let callCount = 0;
328
+ const { session, mockSchedule } = createMockSession();
329
+ session.geminiClient.sendMessageStream = vi.fn().mockImplementation(() => {
330
+ callCount++;
331
+ if (callCount === 1) {
332
+ return makeStream([toolCallEvent]);
333
+ }
334
+ return makeStream([{ type: CONTENT, value: 'Scanned' }]);
335
+ });
336
+ mockSchedule.mockResolvedValue([
337
+ {
338
+ request: { callId: 'call-1', name: 'environment_scanner', args: {} },
339
+ response: {
340
+ responseParts: [
341
+ {
342
+ functionResponse: {
343
+ id: 'call-1',
344
+ name: 'environment_scanner',
345
+ response: { summary: 'No anomalies detected', devices_scanned: 42 },
346
+ },
347
+ },
348
+ ],
349
+ error: undefined,
350
+ errorType: undefined,
351
+ },
352
+ durationMs: 20000,
353
+ },
354
+ ]);
355
+ const mockLog = vi.fn();
356
+ const audit = {
357
+ auditClient: { log: mockLog },
358
+ appId: 'app-1',
359
+ token: 'tok-1',
360
+ };
361
+ await runMessage(session, 'scan environment', controller.signal, audit);
362
+ const [, , entry] = mockLog.mock.calls[0];
363
+ const details = entry['details'];
364
+ const toolCalls = details['tool_calls'];
365
+ expect(toolCalls[0]?.['result']).toBe('{"summary":"No anomalies detected","devices_scanned":42}');
366
+ });
367
+ it('truncates long tool result text in audit log', async () => {
368
+ const toolCallEvent = {
369
+ type: TOOL_CALL_REQUEST,
370
+ value: {
371
+ callId: 'call-1',
372
+ name: 'shell_exec',
373
+ args: { command: 'curl http://api/big-response' },
374
+ isClientInitiated: false,
375
+ prompt_id: 'p1',
376
+ },
377
+ };
378
+ let callCount = 0;
379
+ const { session, mockSchedule } = createMockSession();
380
+ session.geminiClient.sendMessageStream = vi.fn().mockImplementation(() => {
381
+ callCount++;
382
+ if (callCount === 1) {
383
+ return makeStream([toolCallEvent]);
384
+ }
385
+ return makeStream([{ type: CONTENT, value: 'Got it' }]);
386
+ });
387
+ const longText = 'x'.repeat(3000);
388
+ mockSchedule.mockResolvedValue([
389
+ {
390
+ request: { callId: 'call-1', name: 'shell_exec' },
391
+ response: {
392
+ responseParts: [{ text: longText }],
393
+ error: undefined,
394
+ errorType: undefined,
395
+ },
396
+ durationMs: 200,
397
+ },
398
+ ]);
399
+ const mockLog = vi.fn();
400
+ const audit = {
401
+ auditClient: { log: mockLog },
402
+ appId: 'app-1',
403
+ token: 'tok-1',
404
+ };
405
+ await runMessage(session, 'big query', controller.signal, audit);
406
+ const [, , entry] = mockLog.mock.calls[0];
407
+ const details = entry['details'];
408
+ const toolCalls = details['tool_calls'];
409
+ const result = toolCalls[0]?.['result'];
410
+ expect(result).toHaveLength(2000 + '...[truncated]'.length);
411
+ expect(result).toContain('...[truncated]');
412
+ });
413
+ it('logs session_completed with error status on throw', async () => {
414
+ const { session } = createMockSession([
415
+ {
416
+ type: ERROR,
417
+ value: { error: { message: 'LLM error' } },
418
+ },
419
+ ]);
420
+ const mockLog = vi.fn();
421
+ const audit = {
422
+ auditClient: { log: mockLog },
423
+ appId: 'app-1',
424
+ token: 'tok-1',
425
+ };
426
+ await expect(runMessage(session, 'fail', controller.signal, audit)).rejects.toThrow('LLM error');
427
+ expect(mockLog).toHaveBeenCalledOnce();
428
+ const [, , entry] = mockLog.mock.calls[0];
429
+ expect(entry['event']).toBe('session_completed');
430
+ const details = entry['details'];
431
+ expect(details['status']).toBe('error');
432
+ expect(details['error']).toBe('LLM error');
433
+ });
434
+ });
435
+ describe('streamMessage', () => {
436
+ let controller;
437
+ beforeEach(() => {
438
+ controller = new AbortController();
439
+ });
440
+ async function collectEvents(gen) {
441
+ const events = [];
442
+ for await (const event of gen) {
443
+ events.push(event);
444
+ }
445
+ return events;
446
+ }
447
+ it('yields init event first', async () => {
448
+ const { session } = createMockSession([
449
+ { type: CONTENT, value: 'hi' },
450
+ ]);
451
+ const events = await collectEvents(streamMessage(session, 'hello', controller.signal));
452
+ expect(events[0]?.['type']).toBe(SSEEventType.Init);
453
+ expect(events[0]?.['session_id']).toBe('sess-123');
454
+ });
455
+ it('yields text delta events', async () => {
456
+ const { session } = createMockSession([
457
+ { type: CONTENT, value: 'Hello ' },
458
+ { type: CONTENT, value: 'world!' },
459
+ ]);
460
+ const events = await collectEvents(streamMessage(session, 'hi', controller.signal));
461
+ const textEvents = events.filter((e) => e['type'] === SSEEventType.TextDelta);
462
+ expect(textEvents).toHaveLength(2);
463
+ expect(textEvents[0]?.['content']).toBe('Hello ');
464
+ expect(textEvents[1]?.['content']).toBe('world!');
465
+ });
466
+ it('yields done event at the end', async () => {
467
+ const { session } = createMockSession([
468
+ { type: CONTENT, value: 'done' },
469
+ ]);
470
+ const events = await collectEvents(streamMessage(session, 'hi', controller.signal));
471
+ const lastEvent = events[events.length - 1];
472
+ expect(lastEvent?.['type']).toBe(SSEEventType.Done);
473
+ });
474
+ it('yields tool call start and result events', async () => {
475
+ const toolCallEvent = {
476
+ type: TOOL_CALL_REQUEST,
477
+ value: {
478
+ callId: 'call-1',
479
+ name: 'get_info',
480
+ args: { q: 'test' },
481
+ isClientInitiated: false,
482
+ prompt_id: 'p1',
483
+ },
484
+ };
485
+ let callCount = 0;
486
+ const { session, mockSchedule } = createMockSession();
487
+ session.geminiClient.sendMessageStream = vi.fn().mockImplementation(() => {
488
+ callCount++;
489
+ if (callCount === 1) {
490
+ return makeStream([toolCallEvent]);
491
+ }
492
+ return makeStream([{ type: CONTENT, value: 'result' }]);
493
+ });
494
+ mockSchedule.mockResolvedValue([
495
+ {
496
+ request: { callId: 'call-1', name: 'get_info' },
497
+ response: {
498
+ responseParts: [
499
+ { functionResponse: { id: 'call-1', name: 'get_info', response: {} } },
500
+ ],
501
+ error: undefined,
502
+ errorType: undefined,
503
+ },
504
+ },
505
+ ]);
506
+ const events = await collectEvents(streamMessage(session, 'do it', controller.signal));
507
+ const toolStart = events.find((e) => e['type'] === SSEEventType.ToolCallStart);
508
+ expect(toolStart).toBeDefined();
509
+ expect(toolStart?.['tool_name']).toBe('get_info');
510
+ const toolResult = events.find((e) => e['type'] === SSEEventType.ToolCallResult);
511
+ expect(toolResult).toBeDefined();
512
+ expect(toolResult?.['status']).toBe('success');
513
+ });
514
+ it('yields error event on LLM error', async () => {
515
+ const { session } = createMockSession([
516
+ {
517
+ type: ERROR,
518
+ value: { error: { message: 'boom' } },
519
+ },
520
+ ]);
521
+ const events = await collectEvents(streamMessage(session, 'fail', controller.signal));
522
+ const errorEvent = events.find((e) => e['type'] === SSEEventType.Error);
523
+ expect(errorEvent).toBeDefined();
524
+ expect(errorEvent?.['message']).toBe('boom');
525
+ });
526
+ it('yields skill_activated event when activate_skill tool succeeds', async () => {
527
+ const toolCallEvent = {
528
+ type: TOOL_CALL_REQUEST,
529
+ value: {
530
+ callId: 'call-1',
531
+ name: 'activate_skill',
532
+ args: { name: 'triage' },
533
+ isClientInitiated: false,
534
+ prompt_id: 'p1',
535
+ },
536
+ };
537
+ let callCount = 0;
538
+ const { session, mockSchedule } = createMockSession();
539
+ session.geminiClient.sendMessageStream = vi.fn().mockImplementation(() => {
540
+ callCount++;
541
+ if (callCount === 1) {
542
+ return makeStream([toolCallEvent]);
543
+ }
544
+ return makeStream([{ type: CONTENT, value: 'skill loaded' }]);
545
+ });
546
+ mockSchedule.mockResolvedValue([
547
+ {
548
+ request: { callId: 'call-1', name: 'activate_skill', args: { name: 'triage' } },
549
+ response: {
550
+ responseParts: [
551
+ { functionResponse: { id: 'call-1', name: 'activate_skill', response: { result: 'ok' } } },
552
+ ],
553
+ error: undefined,
554
+ errorType: undefined,
555
+ },
556
+ },
557
+ ]);
558
+ const events = await collectEvents(streamMessage(session, 'triage please', controller.signal));
559
+ const skillEvent = events.find((e) => e['type'] === SSEEventType.SkillActivated);
560
+ expect(skillEvent).toBeDefined();
561
+ expect(skillEvent?.['skill_name']).toBe('triage');
562
+ });
563
+ it('logs a single session_completed audit event with tool calls and skills', async () => {
564
+ const toolCallEvent = {
565
+ type: TOOL_CALL_REQUEST,
566
+ value: {
567
+ callId: 'call-1',
568
+ name: 'activate_skill',
569
+ args: { name: 'triage' },
570
+ isClientInitiated: false,
571
+ prompt_id: 'p1',
572
+ },
573
+ };
574
+ let callCount = 0;
575
+ const { session, mockSchedule } = createMockSession();
576
+ session.geminiClient.sendMessageStream = vi.fn().mockImplementation(() => {
577
+ callCount++;
578
+ if (callCount === 1) {
579
+ return makeStream([toolCallEvent]);
580
+ }
581
+ return makeStream([{ type: CONTENT, value: 'skill loaded' }]);
582
+ });
583
+ mockSchedule.mockResolvedValue([
584
+ {
585
+ request: { callId: 'call-1', name: 'activate_skill', args: { name: 'triage' } },
586
+ response: {
587
+ responseParts: [
588
+ { functionResponse: { id: 'call-1', name: 'activate_skill', response: { result: 'ok' } } },
589
+ ],
590
+ error: undefined,
591
+ errorType: undefined,
592
+ },
593
+ },
594
+ ]);
595
+ const mockLog = vi.fn();
596
+ const audit = {
597
+ auditClient: { log: mockLog },
598
+ appId: 'app-1',
599
+ token: 'tok-1',
600
+ tenantId: 'tenant-1',
601
+ orgId: 'org-1',
602
+ };
603
+ await collectEvents(streamMessage(session, 'triage please', controller.signal, audit));
604
+ expect(mockLog).toHaveBeenCalledOnce();
605
+ const [appId, token, entry] = mockLog.mock.calls[0];
606
+ expect(appId).toBe('app-1');
607
+ expect(token).toBe('tok-1');
608
+ expect(entry['event']).toBe('session_completed');
609
+ expect(entry['resource_name']).toBe('sess-123');
610
+ const details = entry['details'];
611
+ expect(details['status']).toBe('completed');
612
+ expect(details['message']).toBe('triage please');
613
+ expect(details['response']).toBe('skill loaded');
614
+ expect(details['tenant_id']).toBe('tenant-1');
615
+ expect(details['org_id']).toBe('org-1');
616
+ expect(details['skills_activated']).toEqual(['triage']);
617
+ const toolCalls = details['tool_calls'];
618
+ expect(toolCalls).toHaveLength(1);
619
+ expect(toolCalls[0]?.['tool_name']).toBe('activate_skill');
620
+ });
621
+ it('captures tool result text in streamed audit log', async () => {
622
+ const toolCallEvent = {
623
+ type: TOOL_CALL_REQUEST,
624
+ value: {
625
+ callId: 'call-1',
626
+ name: 'shell_exec',
627
+ args: { command: 'curl http://api/alerts' },
628
+ isClientInitiated: false,
629
+ prompt_id: 'p1',
630
+ },
631
+ };
632
+ let callCount = 0;
633
+ const { session, mockSchedule } = createMockSession();
634
+ session.geminiClient.sendMessageStream = vi.fn().mockImplementation(() => {
635
+ callCount++;
636
+ if (callCount === 1) {
637
+ return makeStream([toolCallEvent]);
638
+ }
639
+ return makeStream([{ type: CONTENT, value: 'Analyzed alerts' }]);
640
+ });
641
+ mockSchedule.mockResolvedValue([
642
+ {
643
+ request: { callId: 'call-1', name: 'shell_exec' },
644
+ response: {
645
+ responseParts: [
646
+ { text: '{"alerts": [{"severity": "high", "message": "anomaly detected"}]}' },
647
+ ],
648
+ error: undefined,
649
+ errorType: undefined,
650
+ },
651
+ durationMs: 80,
652
+ },
653
+ ]);
654
+ const mockLog = vi.fn();
655
+ const audit = {
656
+ auditClient: { log: mockLog },
657
+ appId: 'app-1',
658
+ token: 'tok-1',
659
+ };
660
+ await collectEvents(streamMessage(session, 'check alerts', controller.signal, audit));
661
+ const [, , entry] = mockLog.mock.calls[0];
662
+ const details = entry['details'];
663
+ const toolCalls = details['tool_calls'];
664
+ expect(toolCalls[0]?.['result']).toBe('{"alerts": [{"severity": "high", "message": "anomaly detected"}]}');
665
+ });
666
+ it('logs session_completed with error status on LLM error', async () => {
667
+ const { session } = createMockSession([
668
+ {
669
+ type: ERROR,
670
+ value: { error: { message: 'boom' } },
671
+ },
672
+ ]);
673
+ const mockLog = vi.fn();
674
+ const audit = {
675
+ auditClient: { log: mockLog },
676
+ appId: 'app-1',
677
+ token: 'tok-1',
678
+ };
679
+ await collectEvents(streamMessage(session, 'fail', controller.signal, audit));
680
+ expect(mockLog).toHaveBeenCalledOnce();
681
+ const [, , entry] = mockLog.mock.calls[0];
682
+ expect(entry['event']).toBe('session_completed');
683
+ const details = entry['details'];
684
+ expect(details['status']).toBe('error');
685
+ expect(details['error']).toBe('boom');
686
+ });
687
+ it('logs session_completed with max_turns status', async () => {
688
+ const { session } = createMockSession();
689
+ // Always return a tool call to keep the loop going — must create fresh stream each time
690
+ session.geminiClient.sendMessageStream = vi.fn().mockImplementation(() => makeStream([{
691
+ type: TOOL_CALL_REQUEST,
692
+ value: {
693
+ callId: 'call-1',
694
+ name: 'get_info',
695
+ args: {},
696
+ isClientInitiated: false,
697
+ prompt_id: 'p1',
698
+ },
699
+ }]));
700
+ session.scheduler.schedule = vi.fn().mockResolvedValue([
701
+ {
702
+ request: { callId: 'call-1', name: 'get_info' },
703
+ response: {
704
+ responseParts: [
705
+ { functionResponse: { id: 'call-1', name: 'get_info', response: {} } },
706
+ ],
707
+ error: undefined,
708
+ errorType: undefined,
709
+ },
710
+ },
711
+ ]);
712
+ const mockLog = vi.fn();
713
+ const audit = {
714
+ auditClient: { log: mockLog },
715
+ appId: 'app-1',
716
+ token: 'tok-1',
717
+ };
718
+ await collectEvents(streamMessage(session, 'loop forever', controller.signal, audit));
719
+ expect(mockLog).toHaveBeenCalledOnce();
720
+ const [, , entry] = mockLog.mock.calls[0];
721
+ expect(entry['event']).toBe('session_completed');
722
+ const details = entry['details'];
723
+ expect(details['status']).toBe('max_turns');
724
+ expect(details['error']).toBe('Maximum turns exceeded');
725
+ });
726
+ it('intercepts ask_user tool calls and yields ask_user event', async () => {
727
+ const askUserEvent = {
728
+ type: TOOL_CALL_REQUEST,
729
+ value: {
730
+ callId: 'ask-1',
731
+ name: ASK_USER,
732
+ args: {
733
+ questions: [
734
+ { question: 'Which zone?', header: 'Zone', type: 'choice', options: [{ label: 'A', description: 'Zone A' }, { label: 'B', description: 'Zone B' }] },
735
+ ],
736
+ },
737
+ isClientInitiated: false,
738
+ prompt_id: 'p1',
739
+ },
740
+ };
741
+ let callCount = 0;
742
+ const { session } = createMockSession();
743
+ session.geminiClient.sendMessageStream = vi.fn().mockImplementation(() => {
744
+ callCount++;
745
+ if (callCount === 1) {
746
+ return makeStream([askUserEvent]);
747
+ }
748
+ return makeStream([{ type: CONTENT, value: 'Great, Zone A!' }]);
749
+ });
750
+ // Create a mock SessionManager that resolves the ask_user
751
+ const mockSessionManager = {
752
+ waitForAskUserResponse: vi.fn().mockResolvedValue({ '0': 'A' }),
753
+ resolveAskUser: vi.fn().mockReturnValue(true),
754
+ };
755
+ const events = await collectEvents(streamMessage(session, 'choose zone', controller.signal, undefined, mockSessionManager));
756
+ // Should have an ask_user event
757
+ const askEvent = events.find((e) => e['type'] === SSEEventType.AskUser);
758
+ expect(askEvent).toBeDefined();
759
+ expect(askEvent?.['ask_id']).toBe('ask-1');
760
+ expect(askEvent?.['questions']).toHaveLength(1);
761
+ // Should have tool_call_start and tool_call_result for ask_user
762
+ const toolStart = events.find((e) => e['type'] === SSEEventType.ToolCallStart && e['tool_name'] === ASK_USER);
763
+ expect(toolStart).toBeDefined();
764
+ const toolResult = events.find((e) => e['type'] === SSEEventType.ToolCallResult && e['tool_id'] === 'ask-1');
765
+ expect(toolResult).toBeDefined();
766
+ expect(toolResult?.['status']).toBe('success');
767
+ // Should continue with text response
768
+ const textEvents = events.filter((e) => e['type'] === SSEEventType.TextDelta);
769
+ expect(textEvents.length).toBeGreaterThan(0);
770
+ // waitForAskUserResponse should have been called
771
+ expect(mockSessionManager.waitForAskUserResponse).toHaveBeenCalledWith(session, 'ask-1', controller.signal);
772
+ });
773
+ it('handles ask_user timeout gracefully', async () => {
774
+ const askUserEvent = {
775
+ type: TOOL_CALL_REQUEST,
776
+ value: {
777
+ callId: 'ask-timeout',
778
+ name: ASK_USER,
779
+ args: {
780
+ questions: [{ question: 'Answer?', header: 'Q', type: 'text' }],
781
+ },
782
+ isClientInitiated: false,
783
+ prompt_id: 'p1',
784
+ },
785
+ };
786
+ let callCount = 0;
787
+ const { session } = createMockSession();
788
+ session.geminiClient.sendMessageStream = vi.fn().mockImplementation(() => {
789
+ callCount++;
790
+ if (callCount === 1) {
791
+ return makeStream([askUserEvent]);
792
+ }
793
+ return makeStream([{ type: CONTENT, value: 'Timed out' }]);
794
+ });
795
+ const mockSessionManager = {
796
+ waitForAskUserResponse: vi.fn().mockRejectedValue(new Error('ask_user response timed out')),
797
+ };
798
+ const events = await collectEvents(streamMessage(session, 'question', controller.signal, undefined, mockSessionManager));
799
+ // Should have error result for the ask_user tool call
800
+ const toolResult = events.find((e) => e['type'] === SSEEventType.ToolCallResult && e['tool_id'] === 'ask-timeout');
801
+ expect(toolResult).toBeDefined();
802
+ expect(toolResult?.['status']).toBe('error');
803
+ expect(toolResult?.['error']).toBe('ask_user response timed out');
804
+ });
805
+ it('does not yield skill_activated when activate_skill tool fails', async () => {
806
+ const toolCallEvent = {
807
+ type: TOOL_CALL_REQUEST,
808
+ value: {
809
+ callId: 'call-1',
810
+ name: 'activate_skill',
811
+ args: { name: 'nonexistent' },
812
+ isClientInitiated: false,
813
+ prompt_id: 'p1',
814
+ },
815
+ };
816
+ let callCount = 0;
817
+ const { session, mockSchedule } = createMockSession();
818
+ session.geminiClient.sendMessageStream = vi.fn().mockImplementation(() => {
819
+ callCount++;
820
+ if (callCount === 1) {
821
+ return makeStream([toolCallEvent]);
822
+ }
823
+ return makeStream([{ type: CONTENT, value: 'failed' }]);
824
+ });
825
+ mockSchedule.mockResolvedValue([
826
+ {
827
+ request: { callId: 'call-1', name: 'activate_skill', args: { name: 'nonexistent' } },
828
+ response: {
829
+ responseParts: [
830
+ { functionResponse: { id: 'call-1', name: 'activate_skill', response: { error: 'not found' } } },
831
+ ],
832
+ error: new Error('Skill not found'),
833
+ errorType: 'TOOL_EXECUTION_ERROR',
834
+ },
835
+ },
836
+ ]);
837
+ const events = await collectEvents(streamMessage(session, 'bad skill', controller.signal));
838
+ const skillEvent = events.find((e) => e['type'] === SSEEventType.SkillActivated);
839
+ expect(skillEvent).toBeUndefined();
840
+ });
841
+ });
842
+ //# sourceMappingURL=session-runner.test.js.map