@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.
- package/README.md +5 -0
- package/dist/.last_build +0 -0
- package/dist/a2a-server.mjs +415698 -0
- package/dist/index.d.ts +7 -0
- package/dist/index.js +8 -0
- package/dist/index.js.map +1 -0
- package/dist/src/agent/executor.d.ts +41 -0
- package/dist/src/agent/executor.js +408 -0
- package/dist/src/agent/executor.js.map +1 -0
- package/dist/src/agent/task.d.ts +67 -0
- package/dist/src/agent/task.js +799 -0
- package/dist/src/agent/task.js.map +1 -0
- package/dist/src/agent/task.test.d.ts +7 -0
- package/dist/src/agent/task.test.js +435 -0
- package/dist/src/agent/task.test.js.map +1 -0
- package/dist/src/agent/task.token.test.d.ts +7 -0
- package/dist/src/agent/task.token.test.js +53 -0
- package/dist/src/agent/task.token.test.js.map +1 -0
- package/dist/src/auth/llmAuthManager.d.ts +39 -0
- package/dist/src/auth/llmAuthManager.js +209 -0
- package/dist/src/auth/llmAuthManager.js.map +1 -0
- package/dist/src/auth/llmAuthManager.test.d.ts +7 -0
- package/dist/src/auth/llmAuthManager.test.js +92 -0
- package/dist/src/auth/llmAuthManager.test.js.map +1 -0
- package/dist/src/commands/command-registry.d.ts +16 -0
- package/dist/src/commands/command-registry.js +35 -0
- package/dist/src/commands/command-registry.js.map +1 -0
- package/dist/src/commands/command-registry.test.d.ts +7 -0
- package/dist/src/commands/command-registry.test.js +100 -0
- package/dist/src/commands/command-registry.test.js.map +1 -0
- package/dist/src/commands/extensions.d.ts +19 -0
- package/dist/src/commands/extensions.js +26 -0
- package/dist/src/commands/extensions.js.map +1 -0
- package/dist/src/commands/extensions.test.d.ts +7 -0
- package/dist/src/commands/extensions.test.js +70 -0
- package/dist/src/commands/extensions.test.js.map +1 -0
- package/dist/src/commands/init.d.ts +16 -0
- package/dist/src/commands/init.js +111 -0
- package/dist/src/commands/init.js.map +1 -0
- package/dist/src/commands/init.test.d.ts +7 -0
- package/dist/src/commands/init.test.js +146 -0
- package/dist/src/commands/init.test.js.map +1 -0
- package/dist/src/commands/restore.d.ts +21 -0
- package/dist/src/commands/restore.js +126 -0
- package/dist/src/commands/restore.js.map +1 -0
- package/dist/src/commands/restore.test.d.ts +7 -0
- package/dist/src/commands/restore.test.js +111 -0
- package/dist/src/commands/restore.test.js.map +1 -0
- package/dist/src/commands/types.d.ts +33 -0
- package/dist/src/commands/types.js +8 -0
- package/dist/src/commands/types.js.map +1 -0
- package/dist/src/config/config.d.ts +24 -0
- package/dist/src/config/config.js +140 -0
- package/dist/src/config/config.js.map +1 -0
- package/dist/src/config/extension.d.ts +12 -0
- package/dist/src/config/extension.js +105 -0
- package/dist/src/config/extension.js.map +1 -0
- package/dist/src/config/settings.d.ts +15 -0
- package/dist/src/config/settings.js +20 -0
- package/dist/src/config/settings.js.map +1 -0
- package/dist/src/config/settings.test.d.ts +7 -0
- package/dist/src/config/settings.test.js +170 -0
- package/dist/src/config/settings.test.js.map +1 -0
- package/dist/src/http/app.d.ts +17 -0
- package/dist/src/http/app.js +399 -0
- package/dist/src/http/app.js.map +1 -0
- package/dist/src/http/app.test.d.ts +7 -0
- package/dist/src/http/app.test.js +1048 -0
- package/dist/src/http/app.test.js.map +1 -0
- package/dist/src/http/auth.d.ts +21 -0
- package/dist/src/http/auth.js +55 -0
- package/dist/src/http/auth.js.map +1 -0
- package/dist/src/http/auth.test.d.ts +7 -0
- package/dist/src/http/auth.test.js +53 -0
- package/dist/src/http/auth.test.js.map +1 -0
- package/dist/src/http/authRoutes.test.d.ts +7 -0
- package/dist/src/http/authRoutes.test.js +169 -0
- package/dist/src/http/authRoutes.test.js.map +1 -0
- package/dist/src/http/cors.d.ts +8 -0
- package/dist/src/http/cors.js +96 -0
- package/dist/src/http/cors.js.map +1 -0
- package/dist/src/http/cors.test.d.ts +7 -0
- package/dist/src/http/cors.test.js +62 -0
- package/dist/src/http/cors.test.js.map +1 -0
- package/dist/src/http/deferredAuth.test.d.ts +7 -0
- package/dist/src/http/deferredAuth.test.js +45 -0
- package/dist/src/http/deferredAuth.test.js.map +1 -0
- package/dist/src/http/endpoints.test.d.ts +7 -0
- package/dist/src/http/endpoints.test.js +149 -0
- package/dist/src/http/endpoints.test.js.map +1 -0
- package/dist/src/http/llmAuthMiddleware.d.ts +9 -0
- package/dist/src/http/llmAuthMiddleware.js +37 -0
- package/dist/src/http/llmAuthMiddleware.js.map +1 -0
- package/dist/src/http/relay.d.ts +28 -0
- package/dist/src/http/relay.js +342 -0
- package/dist/src/http/relay.js.map +1 -0
- package/dist/src/http/relay.test.d.ts +7 -0
- package/dist/src/http/relay.test.js +149 -0
- package/dist/src/http/relay.test.js.map +1 -0
- package/dist/src/http/replay.d.ts +19 -0
- package/dist/src/http/replay.js +90 -0
- package/dist/src/http/replay.js.map +1 -0
- package/dist/src/http/replay.test.d.ts +7 -0
- package/dist/src/http/replay.test.js +78 -0
- package/dist/src/http/replay.test.js.map +1 -0
- package/dist/src/http/requestStorage.d.ts +11 -0
- package/dist/src/http/requestStorage.js +9 -0
- package/dist/src/http/requestStorage.js.map +1 -0
- package/dist/src/http/routes/auth.d.ts +9 -0
- package/dist/src/http/routes/auth.js +125 -0
- package/dist/src/http/routes/auth.js.map +1 -0
- package/dist/src/http/server.d.ts +8 -0
- package/dist/src/http/server.js +28 -0
- package/dist/src/http/server.js.map +1 -0
- package/dist/src/index.d.ts +10 -0
- package/dist/src/index.js +11 -0
- package/dist/src/index.js.map +1 -0
- package/dist/src/persistence/gcs.d.ts +25 -0
- package/dist/src/persistence/gcs.js +248 -0
- package/dist/src/persistence/gcs.js.map +1 -0
- package/dist/src/persistence/gcs.test.d.ts +7 -0
- package/dist/src/persistence/gcs.test.js +335 -0
- package/dist/src/persistence/gcs.test.js.map +1 -0
- package/dist/src/persistence/remoteAuthStore.d.ts +21 -0
- package/dist/src/persistence/remoteAuthStore.js +74 -0
- package/dist/src/persistence/remoteAuthStore.js.map +1 -0
- package/dist/src/types.d.ts +100 -0
- package/dist/src/types.js +49 -0
- package/dist/src/types.js.map +1 -0
- package/dist/src/utils/envAliases.d.ts +7 -0
- package/dist/src/utils/envAliases.js +9 -0
- package/dist/src/utils/envAliases.js.map +1 -0
- package/dist/src/utils/executor_utils.d.ts +8 -0
- package/dist/src/utils/executor_utils.js +42 -0
- package/dist/src/utils/executor_utils.js.map +1 -0
- package/dist/src/utils/logger.d.ts +9 -0
- package/dist/src/utils/logger.js +26 -0
- package/dist/src/utils/logger.js.map +1 -0
- package/dist/src/utils/redactSecrets.d.ts +16 -0
- package/dist/src/utils/redactSecrets.js +72 -0
- package/dist/src/utils/redactSecrets.js.map +1 -0
- package/dist/src/utils/redactSecrets.test.d.ts +7 -0
- package/dist/src/utils/redactSecrets.test.js +62 -0
- package/dist/src/utils/redactSecrets.test.js.map +1 -0
- package/dist/src/utils/testing_utils.d.ts +48 -0
- package/dist/src/utils/testing_utils.js +173 -0
- package/dist/src/utils/testing_utils.js.map +1 -0
- package/dist/tsconfig.tsbuildinfo +1 -0
- package/dist/web-client/app.js +526 -0
- package/dist/web-client/index.html +43 -0
- package/dist/web-client/package.json +10 -0
- package/dist/web-client/relay-client.js +330 -0
- package/dist/web-client/style.css +189 -0
- 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
|