@terminai/a2a-server 0.21.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 (154) hide show
  1. package/README.md +5 -0
  2. package/dist/.last_build +0 -0
  3. package/dist/a2a-server.mjs +415698 -0
  4. package/dist/index.d.ts +7 -0
  5. package/dist/index.js +8 -0
  6. package/dist/index.js.map +1 -0
  7. package/dist/src/agent/executor.d.ts +41 -0
  8. package/dist/src/agent/executor.js +408 -0
  9. package/dist/src/agent/executor.js.map +1 -0
  10. package/dist/src/agent/task.d.ts +67 -0
  11. package/dist/src/agent/task.js +799 -0
  12. package/dist/src/agent/task.js.map +1 -0
  13. package/dist/src/agent/task.test.d.ts +7 -0
  14. package/dist/src/agent/task.test.js +435 -0
  15. package/dist/src/agent/task.test.js.map +1 -0
  16. package/dist/src/agent/task.token.test.d.ts +7 -0
  17. package/dist/src/agent/task.token.test.js +53 -0
  18. package/dist/src/agent/task.token.test.js.map +1 -0
  19. package/dist/src/auth/llmAuthManager.d.ts +39 -0
  20. package/dist/src/auth/llmAuthManager.js +209 -0
  21. package/dist/src/auth/llmAuthManager.js.map +1 -0
  22. package/dist/src/auth/llmAuthManager.test.d.ts +7 -0
  23. package/dist/src/auth/llmAuthManager.test.js +92 -0
  24. package/dist/src/auth/llmAuthManager.test.js.map +1 -0
  25. package/dist/src/commands/command-registry.d.ts +16 -0
  26. package/dist/src/commands/command-registry.js +35 -0
  27. package/dist/src/commands/command-registry.js.map +1 -0
  28. package/dist/src/commands/command-registry.test.d.ts +7 -0
  29. package/dist/src/commands/command-registry.test.js +100 -0
  30. package/dist/src/commands/command-registry.test.js.map +1 -0
  31. package/dist/src/commands/extensions.d.ts +19 -0
  32. package/dist/src/commands/extensions.js +26 -0
  33. package/dist/src/commands/extensions.js.map +1 -0
  34. package/dist/src/commands/extensions.test.d.ts +7 -0
  35. package/dist/src/commands/extensions.test.js +70 -0
  36. package/dist/src/commands/extensions.test.js.map +1 -0
  37. package/dist/src/commands/init.d.ts +16 -0
  38. package/dist/src/commands/init.js +111 -0
  39. package/dist/src/commands/init.js.map +1 -0
  40. package/dist/src/commands/init.test.d.ts +7 -0
  41. package/dist/src/commands/init.test.js +146 -0
  42. package/dist/src/commands/init.test.js.map +1 -0
  43. package/dist/src/commands/restore.d.ts +21 -0
  44. package/dist/src/commands/restore.js +126 -0
  45. package/dist/src/commands/restore.js.map +1 -0
  46. package/dist/src/commands/restore.test.d.ts +7 -0
  47. package/dist/src/commands/restore.test.js +111 -0
  48. package/dist/src/commands/restore.test.js.map +1 -0
  49. package/dist/src/commands/types.d.ts +33 -0
  50. package/dist/src/commands/types.js +8 -0
  51. package/dist/src/commands/types.js.map +1 -0
  52. package/dist/src/config/config.d.ts +24 -0
  53. package/dist/src/config/config.js +140 -0
  54. package/dist/src/config/config.js.map +1 -0
  55. package/dist/src/config/extension.d.ts +12 -0
  56. package/dist/src/config/extension.js +105 -0
  57. package/dist/src/config/extension.js.map +1 -0
  58. package/dist/src/config/settings.d.ts +15 -0
  59. package/dist/src/config/settings.js +20 -0
  60. package/dist/src/config/settings.js.map +1 -0
  61. package/dist/src/config/settings.test.d.ts +7 -0
  62. package/dist/src/config/settings.test.js +170 -0
  63. package/dist/src/config/settings.test.js.map +1 -0
  64. package/dist/src/http/app.d.ts +17 -0
  65. package/dist/src/http/app.js +399 -0
  66. package/dist/src/http/app.js.map +1 -0
  67. package/dist/src/http/app.test.d.ts +7 -0
  68. package/dist/src/http/app.test.js +1048 -0
  69. package/dist/src/http/app.test.js.map +1 -0
  70. package/dist/src/http/auth.d.ts +21 -0
  71. package/dist/src/http/auth.js +55 -0
  72. package/dist/src/http/auth.js.map +1 -0
  73. package/dist/src/http/auth.test.d.ts +7 -0
  74. package/dist/src/http/auth.test.js +53 -0
  75. package/dist/src/http/auth.test.js.map +1 -0
  76. package/dist/src/http/authRoutes.test.d.ts +7 -0
  77. package/dist/src/http/authRoutes.test.js +169 -0
  78. package/dist/src/http/authRoutes.test.js.map +1 -0
  79. package/dist/src/http/cors.d.ts +8 -0
  80. package/dist/src/http/cors.js +96 -0
  81. package/dist/src/http/cors.js.map +1 -0
  82. package/dist/src/http/cors.test.d.ts +7 -0
  83. package/dist/src/http/cors.test.js +62 -0
  84. package/dist/src/http/cors.test.js.map +1 -0
  85. package/dist/src/http/deferredAuth.test.d.ts +7 -0
  86. package/dist/src/http/deferredAuth.test.js +45 -0
  87. package/dist/src/http/deferredAuth.test.js.map +1 -0
  88. package/dist/src/http/endpoints.test.d.ts +7 -0
  89. package/dist/src/http/endpoints.test.js +149 -0
  90. package/dist/src/http/endpoints.test.js.map +1 -0
  91. package/dist/src/http/llmAuthMiddleware.d.ts +9 -0
  92. package/dist/src/http/llmAuthMiddleware.js +37 -0
  93. package/dist/src/http/llmAuthMiddleware.js.map +1 -0
  94. package/dist/src/http/relay.d.ts +28 -0
  95. package/dist/src/http/relay.js +342 -0
  96. package/dist/src/http/relay.js.map +1 -0
  97. package/dist/src/http/relay.test.d.ts +7 -0
  98. package/dist/src/http/relay.test.js +149 -0
  99. package/dist/src/http/relay.test.js.map +1 -0
  100. package/dist/src/http/replay.d.ts +19 -0
  101. package/dist/src/http/replay.js +90 -0
  102. package/dist/src/http/replay.js.map +1 -0
  103. package/dist/src/http/replay.test.d.ts +7 -0
  104. package/dist/src/http/replay.test.js +78 -0
  105. package/dist/src/http/replay.test.js.map +1 -0
  106. package/dist/src/http/requestStorage.d.ts +11 -0
  107. package/dist/src/http/requestStorage.js +9 -0
  108. package/dist/src/http/requestStorage.js.map +1 -0
  109. package/dist/src/http/routes/auth.d.ts +9 -0
  110. package/dist/src/http/routes/auth.js +125 -0
  111. package/dist/src/http/routes/auth.js.map +1 -0
  112. package/dist/src/http/server.d.ts +8 -0
  113. package/dist/src/http/server.js +28 -0
  114. package/dist/src/http/server.js.map +1 -0
  115. package/dist/src/index.d.ts +10 -0
  116. package/dist/src/index.js +11 -0
  117. package/dist/src/index.js.map +1 -0
  118. package/dist/src/persistence/gcs.d.ts +25 -0
  119. package/dist/src/persistence/gcs.js +248 -0
  120. package/dist/src/persistence/gcs.js.map +1 -0
  121. package/dist/src/persistence/gcs.test.d.ts +7 -0
  122. package/dist/src/persistence/gcs.test.js +335 -0
  123. package/dist/src/persistence/gcs.test.js.map +1 -0
  124. package/dist/src/persistence/remoteAuthStore.d.ts +21 -0
  125. package/dist/src/persistence/remoteAuthStore.js +74 -0
  126. package/dist/src/persistence/remoteAuthStore.js.map +1 -0
  127. package/dist/src/types.d.ts +100 -0
  128. package/dist/src/types.js +49 -0
  129. package/dist/src/types.js.map +1 -0
  130. package/dist/src/utils/envAliases.d.ts +7 -0
  131. package/dist/src/utils/envAliases.js +9 -0
  132. package/dist/src/utils/envAliases.js.map +1 -0
  133. package/dist/src/utils/executor_utils.d.ts +8 -0
  134. package/dist/src/utils/executor_utils.js +42 -0
  135. package/dist/src/utils/executor_utils.js.map +1 -0
  136. package/dist/src/utils/logger.d.ts +9 -0
  137. package/dist/src/utils/logger.js +26 -0
  138. package/dist/src/utils/logger.js.map +1 -0
  139. package/dist/src/utils/redactSecrets.d.ts +16 -0
  140. package/dist/src/utils/redactSecrets.js +72 -0
  141. package/dist/src/utils/redactSecrets.js.map +1 -0
  142. package/dist/src/utils/redactSecrets.test.d.ts +7 -0
  143. package/dist/src/utils/redactSecrets.test.js +62 -0
  144. package/dist/src/utils/redactSecrets.test.js.map +1 -0
  145. package/dist/src/utils/testing_utils.d.ts +48 -0
  146. package/dist/src/utils/testing_utils.js +173 -0
  147. package/dist/src/utils/testing_utils.js.map +1 -0
  148. package/dist/tsconfig.tsbuildinfo +1 -0
  149. package/dist/web-client/app.js +526 -0
  150. package/dist/web-client/index.html +43 -0
  151. package/dist/web-client/package.json +10 -0
  152. package/dist/web-client/relay-client.js +330 -0
  153. package/dist/web-client/style.css +189 -0
  154. package/package.json +53 -0
@@ -0,0 +1,1048 @@
1
+ /**
2
+ * @license
3
+ * Copyright 2025 Google LLC
4
+ * Portions Copyright 2025 TerminaI Authors
5
+ * SPDX-License-Identifier: Apache-2.0
6
+ */
7
+ import { GeminiEventType, ApprovalMode, } from '@terminai/core';
8
+ import request from 'supertest';
9
+ import { afterAll, afterEach, beforeEach, beforeAll, describe, expect, it, vi, } from 'vitest';
10
+ import { createApp } from './app.js';
11
+ import { commandRegistry } from '../commands/command-registry.js';
12
+ import { assertUniqueFinalEventIsLast, assertTaskCreationAndWorkingStatus, createStreamMessageRequest, createMockConfig, createAuthHeader, createSignedHeaders, TEST_REMOTE_TOKEN, canListenOnLocalhost, listenOnLocalhost, closeServer, } from '../utils/testing_utils.js';
13
+ import { MockTool } from '@terminai/core';
14
+ const mockToolConfirmationFn = async () => ({});
15
+ const streamToSSEEvents = (stream) => stream
16
+ .split('\n\n')
17
+ .filter(Boolean) // Remove empty strings from trailing newlines
18
+ .map((chunk) => {
19
+ const dataLine = chunk
20
+ .split('\n')
21
+ .find((line) => line.startsWith('data: '));
22
+ if (!dataLine) {
23
+ throw new Error(`Invalid SSE chunk found: "${chunk}"`);
24
+ }
25
+ return JSON.parse(dataLine.substring(6));
26
+ });
27
+ // Mock the logger to avoid polluting test output
28
+ // Comment out to debug tests
29
+ vi.mock('../utils/logger.js', () => ({
30
+ logger: {
31
+ info: vi.fn(),
32
+ warn: vi.fn(),
33
+ error: vi.fn((...args) => console.error(...args)),
34
+ },
35
+ }));
36
+ let config;
37
+ const getToolRegistrySpy = vi.fn().mockReturnValue({
38
+ getAllTools: () => [],
39
+ getToolsByServer: () => [],
40
+ getTool: () => undefined,
41
+ });
42
+ const getApprovalModeSpy = vi.fn();
43
+ const getShellExecutionConfigSpy = vi.fn();
44
+ const getExtensionsSpy = vi.fn();
45
+ const CAN_LISTEN = await canListenOnLocalhost();
46
+ const describeIfListen = CAN_LISTEN ? describe : describe.skip;
47
+ vi.mock('../config/config.js', async () => {
48
+ const actual = await vi.importActual('../config/config.js');
49
+ return {
50
+ ...actual,
51
+ loadConfig: vi.fn().mockImplementation(async () => {
52
+ const mockConfig = createMockConfig({
53
+ getToolRegistry: getToolRegistrySpy,
54
+ getApprovalMode: getApprovalModeSpy,
55
+ getShellExecutionConfig: getShellExecutionConfigSpy,
56
+ getExtensions: getExtensionsSpy,
57
+ });
58
+ config = mockConfig;
59
+ return config;
60
+ }),
61
+ };
62
+ });
63
+ // Mock the GeminiClient to avoid actual API calls
64
+ const sendMessageStreamSpy = vi.fn();
65
+ vi.mock('@terminai/core', async () => {
66
+ const actual = await vi.importActual('@terminai/core');
67
+ return {
68
+ ...actual,
69
+ GeminiClient: vi.fn().mockImplementation(() => ({
70
+ sendMessageStream: sendMessageStreamSpy,
71
+ getUserTier: vi.fn().mockReturnValue('free'),
72
+ initialize: vi.fn(),
73
+ })),
74
+ performRestore: vi.fn(),
75
+ };
76
+ });
77
+ describeIfListen('E2E Tests', () => {
78
+ let app;
79
+ let server;
80
+ beforeAll(async () => {
81
+ process.env['GEMINI_WEB_REMOTE_TOKEN'] = TEST_REMOTE_TOKEN;
82
+ app = await createApp();
83
+ server = await listenOnLocalhost(app); // Listen on a random available port
84
+ });
85
+ beforeEach(() => {
86
+ getApprovalModeSpy.mockReturnValue(ApprovalMode.DEFAULT);
87
+ });
88
+ afterAll(async () => {
89
+ await closeServer(server);
90
+ delete process.env['GEMINI_WEB_REMOTE_TOKEN'];
91
+ });
92
+ afterEach(() => {
93
+ vi.clearAllMocks();
94
+ });
95
+ it('should create a new task and stream status updates (text-content) via POST /', async () => {
96
+ sendMessageStreamSpy.mockImplementation(async function* () {
97
+ yield* [{ type: 'content', value: 'Hello how are you?' }];
98
+ });
99
+ const agent = request.agent(server);
100
+ const body = createStreamMessageRequest('hello', 'a2a-test-message');
101
+ const res = await agent
102
+ .post('/')
103
+ .set(createSignedHeaders('POST', '/', body))
104
+ .set('Content-Type', 'application/json')
105
+ .send(body)
106
+ .expect(200);
107
+ const events = streamToSSEEvents(res.text);
108
+ assertTaskCreationAndWorkingStatus(events);
109
+ // Status update: text-content
110
+ const textContentEvent = events[2].result;
111
+ expect(textContentEvent.kind).toBe('status-update');
112
+ expect(textContentEvent.status.state).toBe('working');
113
+ expect(textContentEvent.metadata?.['coderAgent']).toMatchObject({
114
+ kind: 'text-content',
115
+ });
116
+ expect(textContentEvent.status.message?.parts).toMatchObject([
117
+ { kind: 'text', text: 'Hello how are you?' },
118
+ ]);
119
+ // Status update: input-required (final)
120
+ const finalEvent = events[3].result;
121
+ expect(finalEvent.kind).toBe('status-update');
122
+ expect(finalEvent.status?.state).toBe('input-required');
123
+ expect(finalEvent.final).toBe(true);
124
+ assertUniqueFinalEventIsLast(events);
125
+ expect(events.length).toBe(4);
126
+ });
127
+ it('should create a new task, schedule a tool call, and wait for approval', async () => {
128
+ // First call yields the tool request
129
+ sendMessageStreamSpy.mockImplementationOnce(async function* () {
130
+ yield* [
131
+ {
132
+ type: GeminiEventType.ToolCallRequest,
133
+ value: {
134
+ callId: 'test-call-id',
135
+ name: 'test-tool',
136
+ args: {},
137
+ },
138
+ },
139
+ ];
140
+ });
141
+ // Subsequent calls yield nothing
142
+ sendMessageStreamSpy.mockImplementation(async function* () {
143
+ yield* [];
144
+ });
145
+ const mockTool = new MockTool({
146
+ name: 'test-tool',
147
+ shouldConfirmExecute: vi.fn(mockToolConfirmationFn),
148
+ });
149
+ getToolRegistrySpy.mockReturnValue({
150
+ getAllTools: vi.fn().mockReturnValue([mockTool]),
151
+ getToolsByServer: vi.fn().mockReturnValue([]),
152
+ getTool: vi.fn().mockReturnValue(mockTool),
153
+ });
154
+ const agent = request.agent(server);
155
+ const body = createStreamMessageRequest('run a tool', 'a2a-tool-test-message');
156
+ const res = await agent
157
+ .post('/')
158
+ .set(createSignedHeaders('POST', '/', body))
159
+ .set('Content-Type', 'application/json')
160
+ .send(body)
161
+ .expect(200);
162
+ const events = streamToSSEEvents(res.text);
163
+ assertTaskCreationAndWorkingStatus(events);
164
+ // Status update: working
165
+ const workingEvent2 = events[2].result;
166
+ expect(workingEvent2.kind).toBe('status-update');
167
+ expect(workingEvent2.status.state).toBe('working');
168
+ expect(workingEvent2.metadata?.['coderAgent']).toMatchObject({
169
+ kind: 'state-change',
170
+ });
171
+ // Status update: tool-call-update
172
+ const toolCallUpdateEvent = events[3].result;
173
+ expect(toolCallUpdateEvent.kind).toBe('status-update');
174
+ expect(toolCallUpdateEvent.status.state).toBe('working');
175
+ expect(toolCallUpdateEvent.metadata?.['coderAgent']).toMatchObject({
176
+ kind: 'tool-call-update',
177
+ });
178
+ expect(toolCallUpdateEvent.status.message?.parts).toMatchObject([
179
+ {
180
+ data: {
181
+ status: 'validating',
182
+ request: { callId: 'test-call-id' },
183
+ },
184
+ },
185
+ ]);
186
+ // State update: awaiting_approval update
187
+ const toolCallConfirmationEvent = events[4].result;
188
+ expect(toolCallConfirmationEvent.kind).toBe('status-update');
189
+ expect(toolCallConfirmationEvent.metadata?.['coderAgent']).toMatchObject({
190
+ kind: 'tool-call-confirmation',
191
+ });
192
+ expect(toolCallConfirmationEvent.status.message?.parts).toMatchObject([
193
+ {
194
+ data: {
195
+ status: 'awaiting_approval',
196
+ request: { callId: 'test-call-id' },
197
+ },
198
+ },
199
+ ]);
200
+ expect(toolCallConfirmationEvent.status?.state).toBe('working');
201
+ assertUniqueFinalEventIsLast(events);
202
+ expect(events.length).toBe(6);
203
+ });
204
+ it('should handle multiple tool calls in a single turn', async () => {
205
+ // First call yields the tool request
206
+ sendMessageStreamSpy.mockImplementationOnce(async function* () {
207
+ yield* [
208
+ {
209
+ type: GeminiEventType.ToolCallRequest,
210
+ value: {
211
+ callId: 'test-call-id-1',
212
+ name: 'test-tool-1',
213
+ args: {},
214
+ },
215
+ },
216
+ {
217
+ type: GeminiEventType.ToolCallRequest,
218
+ value: {
219
+ callId: 'test-call-id-2',
220
+ name: 'test-tool-2',
221
+ args: {},
222
+ },
223
+ },
224
+ ];
225
+ });
226
+ // Subsequent calls yield nothing
227
+ sendMessageStreamSpy.mockImplementation(async function* () {
228
+ yield* [];
229
+ });
230
+ const mockTool1 = new MockTool({
231
+ name: 'test-tool-1',
232
+ displayName: 'Test Tool 1',
233
+ shouldConfirmExecute: vi.fn(mockToolConfirmationFn),
234
+ });
235
+ const mockTool2 = new MockTool({
236
+ name: 'test-tool-2',
237
+ displayName: 'Test Tool 2',
238
+ shouldConfirmExecute: vi.fn(mockToolConfirmationFn),
239
+ });
240
+ getToolRegistrySpy.mockReturnValue({
241
+ getAllTools: vi.fn().mockReturnValue([mockTool1, mockTool2]),
242
+ getToolsByServer: vi.fn().mockReturnValue([]),
243
+ getTool: vi.fn().mockImplementation((name) => {
244
+ if (name === 'test-tool-1')
245
+ return mockTool1;
246
+ if (name === 'test-tool-2')
247
+ return mockTool2;
248
+ return undefined;
249
+ }),
250
+ });
251
+ const agent = request.agent(server);
252
+ const body = createStreamMessageRequest('run two tools', 'a2a-multi-tool-test-message');
253
+ const res = await agent
254
+ .post('/')
255
+ .set(createSignedHeaders('POST', '/', body))
256
+ .set('Content-Type', 'application/json')
257
+ .send(body)
258
+ .expect(200);
259
+ const events = streamToSSEEvents(res.text);
260
+ assertTaskCreationAndWorkingStatus(events);
261
+ // Second working update
262
+ const workingEvent = events[2].result;
263
+ expect(workingEvent.kind).toBe('status-update');
264
+ expect(workingEvent.status.state).toBe('working');
265
+ // State Update: Validate the first tool call
266
+ const toolCallValidateEvent1 = events[3].result;
267
+ expect(toolCallValidateEvent1.metadata?.['coderAgent']).toMatchObject({
268
+ kind: 'tool-call-update',
269
+ });
270
+ expect(toolCallValidateEvent1.status.message?.parts).toMatchObject([
271
+ {
272
+ data: {
273
+ status: 'validating',
274
+ request: { callId: 'test-call-id-1' },
275
+ },
276
+ },
277
+ ]);
278
+ // --- Assert the event stream ---
279
+ // 1. Initial "submitted" status.
280
+ expect(events[0].result.status.state).toBe('submitted');
281
+ // 2. "working" status after receiving the user prompt.
282
+ expect(events[1].result.status.state).toBe('working');
283
+ // 3. A "state-change" event from the agent.
284
+ expect(events[2].result.metadata?.['coderAgent']).toMatchObject({
285
+ kind: 'state-change',
286
+ });
287
+ // 4. Tool 1 is validating.
288
+ const toolCallUpdate1 = events[3].result;
289
+ expect(toolCallUpdate1.metadata?.['coderAgent']).toMatchObject({
290
+ kind: 'tool-call-update',
291
+ });
292
+ expect(toolCallUpdate1.status.message?.parts).toMatchObject([
293
+ {
294
+ data: {
295
+ request: { callId: 'test-call-id-1' },
296
+ status: 'validating',
297
+ },
298
+ },
299
+ ]);
300
+ // 5. Tool 2 is validating.
301
+ const toolCallUpdate2 = events[4].result;
302
+ expect(toolCallUpdate2.metadata?.['coderAgent']).toMatchObject({
303
+ kind: 'tool-call-update',
304
+ });
305
+ expect(toolCallUpdate2.status.message?.parts).toMatchObject([
306
+ {
307
+ data: {
308
+ request: { callId: 'test-call-id-2' },
309
+ status: 'validating',
310
+ },
311
+ },
312
+ ]);
313
+ // 6. Tool 1 is awaiting approval.
314
+ const toolCallAwaitEvent = events[5].result;
315
+ expect(toolCallAwaitEvent.metadata?.['coderAgent']).toMatchObject({
316
+ kind: 'tool-call-confirmation',
317
+ });
318
+ expect(toolCallAwaitEvent.status.message?.parts).toMatchObject([
319
+ {
320
+ data: {
321
+ request: { callId: 'test-call-id-1' },
322
+ status: 'awaiting_approval',
323
+ },
324
+ },
325
+ ]);
326
+ // 7. The final event is "input-required".
327
+ const finalEvent = events[6].result;
328
+ expect(finalEvent.final).toBe(true);
329
+ expect(finalEvent.status.state).toBe('input-required');
330
+ // The scheduler now waits for approval, so no more events are sent.
331
+ assertUniqueFinalEventIsLast(events);
332
+ expect(events.length).toBe(7);
333
+ });
334
+ it('should handle multiple tool calls sequentially in YOLO mode', async () => {
335
+ // Set YOLO mode to auto-approve tools and test sequential execution.
336
+ getApprovalModeSpy.mockReturnValue(ApprovalMode.YOLO);
337
+ // First call yields the tool request
338
+ sendMessageStreamSpy.mockImplementationOnce(async function* () {
339
+ yield* [
340
+ {
341
+ type: GeminiEventType.ToolCallRequest,
342
+ value: {
343
+ callId: 'test-call-id-1',
344
+ name: 'test-tool-1',
345
+ args: {},
346
+ },
347
+ },
348
+ {
349
+ type: GeminiEventType.ToolCallRequest,
350
+ value: {
351
+ callId: 'test-call-id-2',
352
+ name: 'test-tool-2',
353
+ args: {},
354
+ },
355
+ },
356
+ ];
357
+ });
358
+ // Subsequent calls yield nothing, as the tools will "succeed".
359
+ sendMessageStreamSpy.mockImplementation(async function* () {
360
+ yield* [{ type: 'content', value: 'All tools executed.' }];
361
+ });
362
+ const mockTool1 = new MockTool({
363
+ name: 'test-tool-1',
364
+ displayName: 'Test Tool 1',
365
+ shouldConfirmExecute: vi.fn(mockToolConfirmationFn),
366
+ execute: vi
367
+ .fn()
368
+ .mockResolvedValue({ llmContent: 'tool 1 done', returnDisplay: '' }),
369
+ });
370
+ const mockTool2 = new MockTool({
371
+ name: 'test-tool-2',
372
+ displayName: 'Test Tool 2',
373
+ shouldConfirmExecute: vi.fn(mockToolConfirmationFn),
374
+ execute: vi
375
+ .fn()
376
+ .mockResolvedValue({ llmContent: 'tool 2 done', returnDisplay: '' }),
377
+ });
378
+ getToolRegistrySpy.mockReturnValue({
379
+ getAllTools: vi.fn().mockReturnValue([mockTool1, mockTool2]),
380
+ getToolsByServer: vi.fn().mockReturnValue([]),
381
+ getTool: vi.fn().mockImplementation((name) => {
382
+ if (name === 'test-tool-1')
383
+ return mockTool1;
384
+ if (name === 'test-tool-2')
385
+ return mockTool2;
386
+ return undefined;
387
+ }),
388
+ });
389
+ const agent = request.agent(server);
390
+ const body = createStreamMessageRequest('run two tools', 'a2a-multi-tool-test-message');
391
+ const res = await agent
392
+ .post('/')
393
+ .set(createSignedHeaders('POST', '/', body))
394
+ .set('Content-Type', 'application/json')
395
+ .send(body)
396
+ .expect(200);
397
+ const events = streamToSSEEvents(res.text);
398
+ assertTaskCreationAndWorkingStatus(events);
399
+ // --- Assert the sequential execution flow ---
400
+ const eventStream = events.slice(2).map((e) => {
401
+ const update = e.result;
402
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
403
+ const agentData = update.metadata?.['coderAgent'];
404
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
405
+ const toolData = update.status.message?.parts[0];
406
+ if (!toolData) {
407
+ return { kind: agentData.kind };
408
+ }
409
+ return {
410
+ kind: agentData.kind,
411
+ status: toolData.data?.status,
412
+ callId: toolData.data?.request.callId,
413
+ };
414
+ });
415
+ const expectedFlow = [
416
+ // Initial state change
417
+ { kind: 'state-change', status: undefined, callId: undefined },
418
+ // Tool 1 Lifecycle
419
+ {
420
+ kind: 'tool-call-update',
421
+ status: 'validating',
422
+ callId: 'test-call-id-1',
423
+ },
424
+ {
425
+ kind: 'tool-call-update',
426
+ status: 'scheduled',
427
+ callId: 'test-call-id-1',
428
+ },
429
+ {
430
+ kind: 'tool-call-update',
431
+ status: 'executing',
432
+ callId: 'test-call-id-1',
433
+ },
434
+ {
435
+ kind: 'tool-call-update',
436
+ status: 'success',
437
+ callId: 'test-call-id-1',
438
+ },
439
+ // Tool 2 Lifecycle
440
+ {
441
+ kind: 'tool-call-update',
442
+ status: 'validating',
443
+ callId: 'test-call-id-2',
444
+ },
445
+ {
446
+ kind: 'tool-call-update',
447
+ status: 'scheduled',
448
+ callId: 'test-call-id-2',
449
+ },
450
+ {
451
+ kind: 'tool-call-update',
452
+ status: 'executing',
453
+ callId: 'test-call-id-2',
454
+ },
455
+ {
456
+ kind: 'tool-call-update',
457
+ status: 'success',
458
+ callId: 'test-call-id-2',
459
+ },
460
+ // Final updates
461
+ { kind: 'state-change', status: undefined, callId: undefined },
462
+ { kind: 'text-content', status: undefined, callId: undefined },
463
+ ];
464
+ // Use `toContainEqual` for flexibility if other events are interspersed.
465
+ expect(eventStream).toEqual(expect.arrayContaining(expectedFlow));
466
+ assertUniqueFinalEventIsLast(events);
467
+ });
468
+ it('should handle tool calls that do not require approval', async () => {
469
+ // First call yields the tool request
470
+ sendMessageStreamSpy.mockImplementationOnce(async function* () {
471
+ yield* [
472
+ {
473
+ type: GeminiEventType.ToolCallRequest,
474
+ value: {
475
+ callId: 'test-call-id-no-approval',
476
+ name: 'test-tool-no-approval',
477
+ args: {},
478
+ },
479
+ },
480
+ ];
481
+ });
482
+ // Second call, after the tool runs, yields the final text
483
+ sendMessageStreamSpy.mockImplementationOnce(async function* () {
484
+ yield* [{ type: 'content', value: 'Tool executed successfully.' }];
485
+ });
486
+ const mockTool = new MockTool({
487
+ name: 'test-tool-no-approval',
488
+ displayName: 'Test Tool No Approval',
489
+ execute: vi.fn().mockResolvedValue({
490
+ llmContent: 'Tool executed successfully.',
491
+ returnDisplay: 'Tool executed successfully.',
492
+ }),
493
+ });
494
+ getToolRegistrySpy.mockReturnValue({
495
+ getAllTools: vi.fn().mockReturnValue([mockTool]),
496
+ getToolsByServer: vi.fn().mockReturnValue([]),
497
+ getTool: vi.fn().mockReturnValue(mockTool),
498
+ });
499
+ const agent = request.agent(server);
500
+ const body = createStreamMessageRequest('run a tool without approval', 'a2a-no-approval-test-message');
501
+ const res = await agent
502
+ .post('/')
503
+ .set(createSignedHeaders('POST', '/', body))
504
+ .set('Content-Type', 'application/json')
505
+ .send(body)
506
+ .expect(200);
507
+ const events = streamToSSEEvents(res.text);
508
+ assertTaskCreationAndWorkingStatus(events);
509
+ // Status update: working
510
+ const workingEvent2 = events[2].result;
511
+ expect(workingEvent2.kind).toBe('status-update');
512
+ expect(workingEvent2.status.state).toBe('working');
513
+ // Status update: tool-call-update (validating)
514
+ const validatingEvent = events[3].result;
515
+ expect(validatingEvent.metadata?.['coderAgent']).toMatchObject({
516
+ kind: 'tool-call-update',
517
+ });
518
+ expect(validatingEvent.status.message?.parts).toMatchObject([
519
+ {
520
+ data: {
521
+ status: 'validating',
522
+ request: { callId: 'test-call-id-no-approval' },
523
+ },
524
+ },
525
+ ]);
526
+ // Status update: tool-call-update (scheduled)
527
+ const scheduledEvent = events[4].result;
528
+ expect(scheduledEvent.metadata?.['coderAgent']).toMatchObject({
529
+ kind: 'tool-call-update',
530
+ });
531
+ expect(scheduledEvent.status.message?.parts).toMatchObject([
532
+ {
533
+ data: {
534
+ status: 'scheduled',
535
+ request: { callId: 'test-call-id-no-approval' },
536
+ },
537
+ },
538
+ ]);
539
+ // Status update: tool-call-update (executing)
540
+ const executingEvent = events[5].result;
541
+ expect(executingEvent.metadata?.['coderAgent']).toMatchObject({
542
+ kind: 'tool-call-update',
543
+ });
544
+ expect(executingEvent.status.message?.parts).toMatchObject([
545
+ {
546
+ data: {
547
+ status: 'executing',
548
+ request: { callId: 'test-call-id-no-approval' },
549
+ },
550
+ },
551
+ ]);
552
+ // Status update: tool-call-update (success)
553
+ const successEvent = events[6].result;
554
+ expect(successEvent.metadata?.['coderAgent']).toMatchObject({
555
+ kind: 'tool-call-update',
556
+ });
557
+ expect(successEvent.status.message?.parts).toMatchObject([
558
+ {
559
+ data: {
560
+ status: 'success',
561
+ request: { callId: 'test-call-id-no-approval' },
562
+ },
563
+ },
564
+ ]);
565
+ // Status update: working (before sending tool result to LLM)
566
+ const workingEvent3 = events[7].result;
567
+ expect(workingEvent3.kind).toBe('status-update');
568
+ expect(workingEvent3.status.state).toBe('working');
569
+ // Status update: text-content (final LLM response)
570
+ const textContentEvent = events[8].result;
571
+ expect(textContentEvent.metadata?.['coderAgent']).toMatchObject({
572
+ kind: 'text-content',
573
+ });
574
+ expect(textContentEvent.status.message?.parts).toMatchObject([
575
+ { text: 'Tool executed successfully.' },
576
+ ]);
577
+ assertUniqueFinalEventIsLast(events);
578
+ expect(events.length).toBe(10);
579
+ });
580
+ it('should bypass tool approval in YOLO mode', async () => {
581
+ // First call yields the tool request
582
+ sendMessageStreamSpy.mockImplementationOnce(async function* () {
583
+ yield* [
584
+ {
585
+ type: GeminiEventType.ToolCallRequest,
586
+ value: {
587
+ callId: 'test-call-id-yolo',
588
+ name: 'test-tool-yolo',
589
+ args: {},
590
+ },
591
+ },
592
+ ];
593
+ });
594
+ // Second call, after the tool runs, yields the final text
595
+ sendMessageStreamSpy.mockImplementationOnce(async function* () {
596
+ yield* [{ type: 'content', value: 'Tool executed successfully.' }];
597
+ });
598
+ // Set approval mode to yolo
599
+ getApprovalModeSpy.mockReturnValue(ApprovalMode.YOLO);
600
+ const mockTool = new MockTool({
601
+ name: 'test-tool-yolo',
602
+ displayName: 'Test Tool YOLO',
603
+ execute: vi.fn().mockResolvedValue({
604
+ llmContent: 'Tool executed successfully.',
605
+ returnDisplay: 'Tool executed successfully.',
606
+ }),
607
+ });
608
+ getToolRegistrySpy.mockReturnValue({
609
+ getAllTools: vi.fn().mockReturnValue([mockTool]),
610
+ getToolsByServer: vi.fn().mockReturnValue([]),
611
+ getTool: vi.fn().mockReturnValue(mockTool),
612
+ });
613
+ const agent = request.agent(server);
614
+ const body = createStreamMessageRequest('run a tool in yolo mode', 'a2a-yolo-mode-test-message');
615
+ const res = await agent
616
+ .post('/')
617
+ .set(createSignedHeaders('POST', '/', body))
618
+ .set('Content-Type', 'application/json')
619
+ .send(body)
620
+ .expect(200);
621
+ const events = streamToSSEEvents(res.text);
622
+ assertTaskCreationAndWorkingStatus(events);
623
+ // Status update: working
624
+ const workingEvent2 = events[2].result;
625
+ expect(workingEvent2.kind).toBe('status-update');
626
+ expect(workingEvent2.status.state).toBe('working');
627
+ // Status update: tool-call-update (validating)
628
+ const validatingEvent = events[3].result;
629
+ expect(validatingEvent.metadata?.['coderAgent']).toMatchObject({
630
+ kind: 'tool-call-update',
631
+ });
632
+ expect(validatingEvent.status.message?.parts).toMatchObject([
633
+ {
634
+ data: {
635
+ status: 'validating',
636
+ request: { callId: 'test-call-id-yolo' },
637
+ },
638
+ },
639
+ ]);
640
+ // Status update: tool-call-update (scheduled)
641
+ const awaitingEvent = events[4].result;
642
+ expect(awaitingEvent.metadata?.['coderAgent']).toMatchObject({
643
+ kind: 'tool-call-update',
644
+ });
645
+ expect(awaitingEvent.status.message?.parts).toMatchObject([
646
+ {
647
+ data: {
648
+ status: 'scheduled',
649
+ request: { callId: 'test-call-id-yolo' },
650
+ },
651
+ },
652
+ ]);
653
+ // Status update: tool-call-update (executing)
654
+ const executingEvent = events[5].result;
655
+ expect(executingEvent.metadata?.['coderAgent']).toMatchObject({
656
+ kind: 'tool-call-update',
657
+ });
658
+ expect(executingEvent.status.message?.parts).toMatchObject([
659
+ {
660
+ data: {
661
+ status: 'executing',
662
+ request: { callId: 'test-call-id-yolo' },
663
+ },
664
+ },
665
+ ]);
666
+ // Status update: tool-call-update (success)
667
+ const successEvent = events[6].result;
668
+ expect(successEvent.metadata?.['coderAgent']).toMatchObject({
669
+ kind: 'tool-call-update',
670
+ });
671
+ expect(successEvent.status.message?.parts).toMatchObject([
672
+ {
673
+ data: {
674
+ status: 'success',
675
+ request: { callId: 'test-call-id-yolo' },
676
+ },
677
+ },
678
+ ]);
679
+ // Status update: working (before sending tool result to LLM)
680
+ const workingEvent3 = events[7].result;
681
+ expect(workingEvent3.kind).toBe('status-update');
682
+ expect(workingEvent3.status.state).toBe('working');
683
+ // Status update: text-content (final LLM response)
684
+ const textContentEvent = events[8].result;
685
+ expect(textContentEvent.metadata?.['coderAgent']).toMatchObject({
686
+ kind: 'text-content',
687
+ });
688
+ expect(textContentEvent.status.message?.parts).toMatchObject([
689
+ { text: 'Tool executed successfully.' },
690
+ ]);
691
+ assertUniqueFinalEventIsLast(events);
692
+ expect(events.length).toBe(10);
693
+ });
694
+ it('should include traceId in status updates when available', async () => {
695
+ const traceId = 'test-trace-id';
696
+ sendMessageStreamSpy.mockImplementation(async function* () {
697
+ yield* [
698
+ { type: 'content', value: 'Hello', traceId },
699
+ { type: 'thought', value: { subject: 'Thinking...' }, traceId },
700
+ ];
701
+ });
702
+ const agent = request.agent(server);
703
+ const body = createStreamMessageRequest('hello', 'a2a-trace-id-test');
704
+ const res = await agent
705
+ .post('/')
706
+ .set(createSignedHeaders('POST', '/', body))
707
+ .set('Content-Type', 'application/json')
708
+ .send(body)
709
+ .expect(200);
710
+ const events = streamToSSEEvents(res.text);
711
+ // The first two events are task-creation and working status
712
+ const textContentEvent = events[2].result;
713
+ expect(textContentEvent.kind).toBe('status-update');
714
+ expect(textContentEvent.metadata?.['traceId']).toBe(traceId);
715
+ const thoughtEvent = events[3].result;
716
+ expect(thoughtEvent.kind).toBe('status-update');
717
+ expect(thoughtEvent.metadata?.['traceId']).toBe(traceId);
718
+ });
719
+ describe('/listCommands', () => {
720
+ it('should return a list of top-level commands', async () => {
721
+ const mockCommands = [
722
+ {
723
+ name: 'test-command',
724
+ description: 'A test command',
725
+ topLevel: true,
726
+ arguments: [{ name: 'arg1', description: 'Argument 1' }],
727
+ subCommands: [
728
+ {
729
+ name: 'sub-command',
730
+ description: 'A sub command',
731
+ topLevel: false,
732
+ execute: vi.fn(),
733
+ },
734
+ ],
735
+ execute: vi.fn(),
736
+ },
737
+ {
738
+ name: 'another-command',
739
+ description: 'Another test command',
740
+ topLevel: true,
741
+ execute: vi.fn(),
742
+ },
743
+ {
744
+ name: 'not-top-level',
745
+ description: 'Not a top level command',
746
+ topLevel: false,
747
+ execute: vi.fn(),
748
+ },
749
+ ];
750
+ const getAllCommandsSpy = vi
751
+ .spyOn(commandRegistry, 'getAllCommands')
752
+ .mockReturnValue(mockCommands);
753
+ const agent = request.agent(server);
754
+ const res = await agent
755
+ .get('/listCommands')
756
+ .set(createAuthHeader())
757
+ .expect(200);
758
+ expect(res.body).toEqual({
759
+ commands: [
760
+ {
761
+ name: 'test-command',
762
+ description: 'A test command',
763
+ arguments: [{ name: 'arg1', description: 'Argument 1' }],
764
+ subCommands: [
765
+ {
766
+ name: 'sub-command',
767
+ description: 'A sub command',
768
+ arguments: [],
769
+ subCommands: [],
770
+ },
771
+ ],
772
+ },
773
+ {
774
+ name: 'another-command',
775
+ description: 'Another test command',
776
+ arguments: [],
777
+ subCommands: [],
778
+ },
779
+ ],
780
+ });
781
+ expect(getAllCommandsSpy).toHaveBeenCalledOnce();
782
+ getAllCommandsSpy.mockRestore();
783
+ });
784
+ it('should handle cyclic commands gracefully', async () => {
785
+ const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => { });
786
+ const cyclicCommand = {
787
+ name: 'cyclic-command',
788
+ description: 'A cyclic command',
789
+ topLevel: true,
790
+ execute: vi.fn(),
791
+ subCommands: [],
792
+ };
793
+ cyclicCommand.subCommands?.push(cyclicCommand); // Create cycle
794
+ const getAllCommandsSpy = vi
795
+ .spyOn(commandRegistry, 'getAllCommands')
796
+ .mockReturnValue([cyclicCommand]);
797
+ const agent = request.agent(server);
798
+ const res = await agent
799
+ .get('/listCommands')
800
+ .set(createAuthHeader())
801
+ .expect(200);
802
+ expect(res.body.commands[0].name).toBe('cyclic-command');
803
+ expect(res.body.commands[0].subCommands).toEqual([]);
804
+ expect(warnSpy).toHaveBeenCalledWith('Command cyclic-command already inserted in the response, skipping');
805
+ getAllCommandsSpy.mockRestore();
806
+ warnSpy.mockRestore();
807
+ });
808
+ });
809
+ describe('/executeCommand', () => {
810
+ const mockExtensions = [{ name: 'test-extension', version: '0.0.1' }];
811
+ beforeEach(() => {
812
+ getExtensionsSpy.mockReturnValue(mockExtensions);
813
+ });
814
+ afterEach(() => {
815
+ getExtensionsSpy.mockClear();
816
+ });
817
+ it('should return extensions for valid command', async () => {
818
+ const mockExtensionsCommand = {
819
+ name: 'extensions list',
820
+ description: 'a mock command',
821
+ execute: vi.fn(async (context) => {
822
+ // Simulate the actual command's behavior
823
+ const extensions = context.config.getExtensions();
824
+ return { name: 'extensions list', data: extensions };
825
+ }),
826
+ };
827
+ vi.spyOn(commandRegistry, 'get').mockReturnValue(mockExtensionsCommand);
828
+ const agent = request.agent(server);
829
+ const body = { command: 'extensions list', args: [] };
830
+ const res = await agent
831
+ .post('/executeCommand')
832
+ .set(createSignedHeaders('POST', '/executeCommand', body))
833
+ .set('Content-Type', 'application/json')
834
+ .send(body)
835
+ .expect(200);
836
+ expect(res.body).toEqual({
837
+ name: 'extensions list',
838
+ data: mockExtensions,
839
+ });
840
+ expect(getExtensionsSpy).toHaveBeenCalled();
841
+ });
842
+ it('should return 404 for invalid command', async () => {
843
+ vi.spyOn(commandRegistry, 'get').mockReturnValue(undefined);
844
+ const agent = request.agent(server);
845
+ const body = { command: 'invalid command' };
846
+ const res = await agent
847
+ .post('/executeCommand')
848
+ .set(createSignedHeaders('POST', '/executeCommand', body))
849
+ .set('Content-Type', 'application/json')
850
+ .send(body)
851
+ .expect(404);
852
+ expect(res.body.error).toBe('Command not found: invalid command');
853
+ expect(getExtensionsSpy).not.toHaveBeenCalled();
854
+ });
855
+ it('should return 400 for missing command', async () => {
856
+ const agent = request.agent(server);
857
+ const body = { args: [] };
858
+ await agent
859
+ .post('/executeCommand')
860
+ .set(createSignedHeaders('POST', '/executeCommand', body))
861
+ .set('Content-Type', 'application/json')
862
+ .send(body)
863
+ .expect(400);
864
+ expect(getExtensionsSpy).not.toHaveBeenCalled();
865
+ });
866
+ it('should return 400 if args is not an array', async () => {
867
+ const agent = request.agent(server);
868
+ const body = { command: 'extensions.list', args: 'not-an-array' };
869
+ const res = await agent
870
+ .post('/executeCommand')
871
+ .set(createSignedHeaders('POST', '/executeCommand', body))
872
+ .set('Content-Type', 'application/json')
873
+ .send(body)
874
+ .expect(400);
875
+ expect(res.body.error).toBe('"args" field must be an array.');
876
+ expect(getExtensionsSpy).not.toHaveBeenCalled();
877
+ });
878
+ it('should execute a command that does not require a workspace when CODER_AGENT_WORKSPACE_PATH is not set', async () => {
879
+ const mockCommand = {
880
+ name: 'test-command',
881
+ description: 'a mock command',
882
+ execute: vi
883
+ .fn()
884
+ .mockResolvedValue({ name: 'test-command', data: 'success' }),
885
+ };
886
+ vi.spyOn(commandRegistry, 'get').mockReturnValue(mockCommand);
887
+ delete process.env['CODER_AGENT_WORKSPACE_PATH'];
888
+ const body = { command: 'test-command', args: [] };
889
+ const response = await request(server)
890
+ .post('/executeCommand')
891
+ .set(createSignedHeaders('POST', '/executeCommand', body))
892
+ .set('Content-Type', 'application/json')
893
+ .send(body);
894
+ expect(response.status).toBe(200);
895
+ expect(response.body.data).toBe('success');
896
+ });
897
+ it('should return 400 for a command that requires a workspace when CODER_AGENT_WORKSPACE_PATH is not set', async () => {
898
+ const mockWorkspaceCommand = {
899
+ name: 'workspace-command',
900
+ description: 'A command that requires a workspace',
901
+ requiresWorkspace: true,
902
+ execute: vi
903
+ .fn()
904
+ .mockResolvedValue({ name: 'workspace-command', data: 'success' }),
905
+ };
906
+ vi.spyOn(commandRegistry, 'get').mockReturnValue(mockWorkspaceCommand);
907
+ delete process.env['CODER_AGENT_WORKSPACE_PATH'];
908
+ const body = { command: 'workspace-command', args: [] };
909
+ const response = await request(server)
910
+ .post('/executeCommand')
911
+ .set(createSignedHeaders('POST', '/executeCommand', body))
912
+ .set('Content-Type', 'application/json')
913
+ .send(body);
914
+ expect(response.status).toBe(400);
915
+ expect(response.body.error).toBe('Command "workspace-command" requires a workspace, but CODER_AGENT_WORKSPACE_PATH is not set.');
916
+ });
917
+ it('should execute a command that requires a workspace when CODER_AGENT_WORKSPACE_PATH is set', async () => {
918
+ const mockWorkspaceCommand = {
919
+ name: 'workspace-command',
920
+ description: 'A command that requires a workspace',
921
+ requiresWorkspace: true,
922
+ execute: vi
923
+ .fn()
924
+ .mockResolvedValue({ name: 'workspace-command', data: 'success' }),
925
+ };
926
+ vi.spyOn(commandRegistry, 'get').mockReturnValue(mockWorkspaceCommand);
927
+ process.env['CODER_AGENT_WORKSPACE_PATH'] = '/tmp/test-workspace';
928
+ const body = { command: 'workspace-command', args: [] };
929
+ const response = await request(server)
930
+ .post('/executeCommand')
931
+ .set(createSignedHeaders('POST', '/executeCommand', body))
932
+ .set('Content-Type', 'application/json')
933
+ .send(body);
934
+ expect(response.status).toBe(200);
935
+ expect(response.body.data).toBe('success');
936
+ });
937
+ it('should include agentExecutor in context', async () => {
938
+ const mockCommand = {
939
+ name: 'context-check-command',
940
+ description: 'checks context',
941
+ execute: vi.fn(async (context) => {
942
+ if (!context.agentExecutor) {
943
+ throw new Error('agentExecutor missing');
944
+ }
945
+ return { name: 'context-check-command', data: 'success' };
946
+ }),
947
+ };
948
+ vi.spyOn(commandRegistry, 'get').mockReturnValue(mockCommand);
949
+ const agent = request.agent(server);
950
+ const body = { command: 'context-check-command', args: [] };
951
+ const res = await agent
952
+ .post('/executeCommand')
953
+ .set(createSignedHeaders('POST', '/executeCommand', body))
954
+ .set('Content-Type', 'application/json')
955
+ .send(body)
956
+ .expect(200);
957
+ expect(res.body.data).toBe('success');
958
+ });
959
+ describe('/executeCommand streaming', () => {
960
+ it('should execute a streaming command and stream back events', (done) => {
961
+ const executeSpy = vi.fn(async (context) => {
962
+ context.eventBus?.publish({
963
+ kind: 'status-update',
964
+ status: { state: 'working' },
965
+ taskId: 'test-task',
966
+ contextId: 'test-context',
967
+ final: false,
968
+ });
969
+ context.eventBus?.publish({
970
+ kind: 'status-update',
971
+ status: { state: 'completed' },
972
+ taskId: 'test-task',
973
+ contextId: 'test-context',
974
+ final: true,
975
+ });
976
+ return { name: 'stream-test', data: 'done' };
977
+ });
978
+ const mockStreamCommand = {
979
+ name: 'stream-test',
980
+ description: 'A test streaming command',
981
+ streaming: true,
982
+ execute: executeSpy,
983
+ };
984
+ vi.spyOn(commandRegistry, 'get').mockReturnValue(mockStreamCommand);
985
+ const agent = request.agent(server);
986
+ const body = { command: 'stream-test', args: [] };
987
+ agent
988
+ .post('/executeCommand')
989
+ .set(createSignedHeaders('POST', '/executeCommand', body))
990
+ .set('Content-Type', 'application/json')
991
+ .set('Accept', 'text/event-stream')
992
+ .send(body)
993
+ .on('response', (res) => {
994
+ let data = '';
995
+ res.on('data', (chunk) => {
996
+ data += chunk.toString();
997
+ });
998
+ res.on('end', () => {
999
+ try {
1000
+ const events = streamToSSEEvents(data);
1001
+ expect(events.length).toBe(2);
1002
+ expect(events[0].result).toEqual({
1003
+ kind: 'status-update',
1004
+ status: { state: 'working' },
1005
+ taskId: 'test-task',
1006
+ contextId: 'test-context',
1007
+ final: false,
1008
+ });
1009
+ expect(events[1].result).toEqual({
1010
+ kind: 'status-update',
1011
+ status: { state: 'completed' },
1012
+ taskId: 'test-task',
1013
+ contextId: 'test-context',
1014
+ final: true,
1015
+ });
1016
+ expect(executeSpy).toHaveBeenCalled();
1017
+ done();
1018
+ }
1019
+ catch (e) {
1020
+ done(e);
1021
+ }
1022
+ });
1023
+ })
1024
+ .end();
1025
+ });
1026
+ it('should handle non-streaming commands gracefully', async () => {
1027
+ const mockNonStreamCommand = {
1028
+ name: 'non-stream-test',
1029
+ description: 'A test non-streaming command',
1030
+ execute: vi
1031
+ .fn()
1032
+ .mockResolvedValue({ name: 'non-stream-test', data: 'done' }),
1033
+ };
1034
+ vi.spyOn(commandRegistry, 'get').mockReturnValue(mockNonStreamCommand);
1035
+ const agent = request.agent(server);
1036
+ const body = { command: 'non-stream-test', args: [] };
1037
+ const res = await agent
1038
+ .post('/executeCommand')
1039
+ .set(createSignedHeaders('POST', '/executeCommand', body))
1040
+ .set('Content-Type', 'application/json')
1041
+ .send(body)
1042
+ .expect(200);
1043
+ expect(res.body).toEqual({ name: 'non-stream-test', data: 'done' });
1044
+ });
1045
+ });
1046
+ });
1047
+ });
1048
+ //# sourceMappingURL=app.test.js.map