@aj-archipelago/cortex 1.3.26 → 1.3.28
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/package.json +1 -1
- package/server/parser.js +1 -1
- package/server/pathwayResolver.js +2 -1
- package/server/rest.js +1 -1
- package/tests/claude3VertexPlugin.test.js +54 -0
- package/tests/openai_api.test.js +125 -0
- package/tests/subscription.test.js +381 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@aj-archipelago/cortex",
|
|
3
|
-
"version": "1.3.
|
|
3
|
+
"version": "1.3.28",
|
|
4
4
|
"description": "Cortex is a GraphQL API for AI. It provides a simple, extensible interface for using AI services from OpenAI, Azure and others.",
|
|
5
5
|
"private": false,
|
|
6
6
|
"repository": {
|
package/server/parser.js
CHANGED
|
@@ -16,7 +16,7 @@ const parseNumberedList = (str) => {
|
|
|
16
16
|
async function parseNumberedObjectList(text, format) {
|
|
17
17
|
const parsedList = await callPathway('sys_parse_numbered_object_list', { text, format });
|
|
18
18
|
try {
|
|
19
|
-
return JSON.parse(parsedList);
|
|
19
|
+
return JSON.parse(parsedList) || [];
|
|
20
20
|
} catch (error) {
|
|
21
21
|
logger.warn(`Failed to parse numbered object list: ${error.message}`);
|
|
22
22
|
return [];
|
|
@@ -179,7 +179,8 @@ class PathwayResolver {
|
|
|
179
179
|
await publishNestedRequestProgress({
|
|
180
180
|
requestId: this.rootRequestId || this.requestId,
|
|
181
181
|
progress: Math.min(completedCount, totalCount) / totalCount,
|
|
182
|
-
|
|
182
|
+
// Clients expect these to be strings
|
|
183
|
+
data: JSON.stringify(responseData),
|
|
183
184
|
info: this.tool || ''
|
|
184
185
|
});
|
|
185
186
|
}
|
package/server/rest.js
CHANGED
|
@@ -48,7 +48,7 @@ const processRestRequest = async (server, req, pathway, name, parameterMap = {})
|
|
|
48
48
|
return value.map(msg => ({
|
|
49
49
|
...msg,
|
|
50
50
|
content: Array.isArray(msg.content) ?
|
|
51
|
-
JSON.stringify(
|
|
51
|
+
msg.content.map(item => JSON.stringify(item)) :
|
|
52
52
|
msg.content
|
|
53
53
|
}));
|
|
54
54
|
} else {
|
|
@@ -212,3 +212,57 @@ test('convertMessagesToClaudeVertex user message with no content', async (t) =>
|
|
|
212
212
|
t.deepEqual(output, { system: '', modifiedMessages: [] });
|
|
213
213
|
});
|
|
214
214
|
|
|
215
|
+
test('convertMessagesToClaudeVertex with multi-part content array', async (t) => {
|
|
216
|
+
const plugin = new Claude3VertexPlugin(pathway, model);
|
|
217
|
+
|
|
218
|
+
// Test with multi-part content array
|
|
219
|
+
const multiPartContent = [
|
|
220
|
+
{
|
|
221
|
+
type: 'text',
|
|
222
|
+
text: 'Hello world'
|
|
223
|
+
},
|
|
224
|
+
{
|
|
225
|
+
type: 'text',
|
|
226
|
+
text: 'Hello2 world2'
|
|
227
|
+
},
|
|
228
|
+
{
|
|
229
|
+
type: 'image_url',
|
|
230
|
+
image_url: 'https://static.toiimg.com/thumb/msid-102827471,width-1280,height-720,resizemode-4/102827471.jpg'
|
|
231
|
+
}
|
|
232
|
+
];
|
|
233
|
+
|
|
234
|
+
const messages = [
|
|
235
|
+
{ role: 'system', content: 'System message' },
|
|
236
|
+
{ role: 'user', content: multiPartContent }
|
|
237
|
+
];
|
|
238
|
+
|
|
239
|
+
const output = await plugin.convertMessagesToClaudeVertex(messages);
|
|
240
|
+
|
|
241
|
+
// Verify system message is preserved
|
|
242
|
+
t.is(output.system, 'System message');
|
|
243
|
+
|
|
244
|
+
// Verify the user message role is preserved
|
|
245
|
+
t.is(output.modifiedMessages[0].role, 'user');
|
|
246
|
+
|
|
247
|
+
// Verify the content array has the correct number of items
|
|
248
|
+
// We expect 3 items: 2 text items and 1 image item
|
|
249
|
+
t.is(output.modifiedMessages[0].content.length, 3);
|
|
250
|
+
|
|
251
|
+
// Verify the text content items
|
|
252
|
+
t.is(output.modifiedMessages[0].content[0].type, 'text');
|
|
253
|
+
t.is(output.modifiedMessages[0].content[0].text, 'Hello world');
|
|
254
|
+
|
|
255
|
+
t.is(output.modifiedMessages[0].content[1].type, 'text');
|
|
256
|
+
t.is(output.modifiedMessages[0].content[1].text, 'Hello2 world2');
|
|
257
|
+
|
|
258
|
+
// Verify the image content item
|
|
259
|
+
t.is(output.modifiedMessages[0].content[2].type, 'image');
|
|
260
|
+
t.is(output.modifiedMessages[0].content[2].source.type, 'base64');
|
|
261
|
+
t.is(output.modifiedMessages[0].content[2].source.media_type, 'image/jpeg');
|
|
262
|
+
|
|
263
|
+
// Check if the base64 data looks reasonable
|
|
264
|
+
const base64Data = output.modifiedMessages[0].content[2].source.data;
|
|
265
|
+
const base64Regex = /^[A-Za-z0-9+/]+={0,2}$/;
|
|
266
|
+
t.true(base64Data.length > 100); // Check if the data is sufficiently long
|
|
267
|
+
t.true(base64Regex.test(base64Data)); // Check if the data matches the base64 regex
|
|
268
|
+
});
|
package/tests/openai_api.test.js
CHANGED
|
@@ -496,4 +496,129 @@ test('POST /chat/completions should return complete responses from gpt-4o', asyn
|
|
|
496
496
|
const content = response.body.choices[0].message.content;
|
|
497
497
|
t.regex(content, /END_MARKER_XYZ$/);
|
|
498
498
|
});
|
|
499
|
+
|
|
500
|
+
test('POST /chat/completions should handle array content properly', async (t) => {
|
|
501
|
+
// This test verifies the functionality in server/rest.js where array content is JSON stringified
|
|
502
|
+
// Specifically testing: content: Array.isArray(msg.content) ? msg.content.map(item => JSON.stringify(item)) : msg.content
|
|
503
|
+
|
|
504
|
+
// Create a request with MultiMessage array content
|
|
505
|
+
const testContent = [
|
|
506
|
+
{
|
|
507
|
+
type: 'text',
|
|
508
|
+
text: 'Hello world'
|
|
509
|
+
},
|
|
510
|
+
{
|
|
511
|
+
type: 'text',
|
|
512
|
+
text: 'Hello2 world2'
|
|
513
|
+
},
|
|
514
|
+
{
|
|
515
|
+
type: 'image',
|
|
516
|
+
url: 'https://example.com/test.jpg'
|
|
517
|
+
}
|
|
518
|
+
];
|
|
519
|
+
|
|
520
|
+
try {
|
|
521
|
+
// First, check if the API server is running and get available models
|
|
522
|
+
let modelToUse = '*'; // Default fallback model
|
|
523
|
+
try {
|
|
524
|
+
const modelsResponse = await got(`${API_BASE}/models`, { responseType: 'json' });
|
|
525
|
+
if (modelsResponse.body && modelsResponse.body.data && modelsResponse.body.data.length > 0) {
|
|
526
|
+
const models = modelsResponse.body.data.map(model => model.id);
|
|
527
|
+
|
|
528
|
+
// Priority 1: Find sonnet with highest version (e.g., claude-3.7-sonnet)
|
|
529
|
+
const sonnetVersions = models
|
|
530
|
+
.filter(id => id.includes('-sonnet') && id.startsWith('claude-'))
|
|
531
|
+
.sort((a, b) => {
|
|
532
|
+
// Extract version numbers and compare
|
|
533
|
+
const versionA = a.match(/claude-(\d+\.\d+)-sonnet/);
|
|
534
|
+
const versionB = b.match(/claude-(\d+\.\d+)-sonnet/);
|
|
535
|
+
if (versionA && versionB) {
|
|
536
|
+
return parseFloat(versionB[1]) - parseFloat(versionA[1]); // Descending order
|
|
537
|
+
}
|
|
538
|
+
return 0;
|
|
539
|
+
});
|
|
540
|
+
|
|
541
|
+
if (sonnetVersions.length > 0) {
|
|
542
|
+
modelToUse = sonnetVersions[0]; // Use highest version sonnet
|
|
543
|
+
} else {
|
|
544
|
+
// Priority 2: Any model ending with -sonnet
|
|
545
|
+
const anySonnet = models.find(id => id.endsWith('-sonnet'));
|
|
546
|
+
if (anySonnet) {
|
|
547
|
+
modelToUse = anySonnet;
|
|
548
|
+
} else {
|
|
549
|
+
// Priority 3: Any model starting with claude-
|
|
550
|
+
const anyClaude = models.find(id => id.startsWith('claude-'));
|
|
551
|
+
if (anyClaude) {
|
|
552
|
+
modelToUse = anyClaude;
|
|
553
|
+
} else {
|
|
554
|
+
// Fallback: Just use the first available model
|
|
555
|
+
modelToUse = models[0];
|
|
556
|
+
}
|
|
557
|
+
}
|
|
558
|
+
}
|
|
559
|
+
|
|
560
|
+
t.log(`Using model: ${modelToUse}`);
|
|
561
|
+
}
|
|
562
|
+
} catch (modelError) {
|
|
563
|
+
t.log('Could not get available models, using default model');
|
|
564
|
+
}
|
|
565
|
+
|
|
566
|
+
// Make a direct HTTP request to the REST API
|
|
567
|
+
const response = await axios.post(`${API_BASE}/chat/completions`, {
|
|
568
|
+
model: modelToUse,
|
|
569
|
+
messages: [
|
|
570
|
+
{
|
|
571
|
+
role: 'user',
|
|
572
|
+
content: testContent
|
|
573
|
+
}
|
|
574
|
+
]
|
|
575
|
+
});
|
|
576
|
+
|
|
577
|
+
t.log('Response:', response.data.choices[0].message);
|
|
578
|
+
|
|
579
|
+
const message = response.data.choices[0].message;
|
|
580
|
+
|
|
581
|
+
//message should not have anything similar to:
|
|
582
|
+
//Execution failed for sys_claude_37_sonnet: HTTP error: 400 Bad Request
|
|
583
|
+
//HTTP error:
|
|
584
|
+
t.falsy(message.content.startsWith('HTTP error:'));
|
|
585
|
+
//400 Bad Request
|
|
586
|
+
t.falsy(message.content.startsWith('400 Bad Request'));
|
|
587
|
+
//Execution failed
|
|
588
|
+
t.falsy(message.content.startsWith('Execution failed'));
|
|
589
|
+
//Invalid JSON
|
|
590
|
+
t.falsy(message.content.startsWith('Invalid JSON'));
|
|
591
|
+
|
|
592
|
+
|
|
593
|
+
// If the request succeeds, it means the array content was properly processed
|
|
594
|
+
// If the JSON.stringify was not applied correctly, the request would fail
|
|
595
|
+
t.truthy(response.data);
|
|
596
|
+
t.pass('REST API successfully processed array content');
|
|
597
|
+
} catch (error) {
|
|
598
|
+
// If there's a connection error (e.g., API not running), we'll skip this test
|
|
599
|
+
if (error.code === 'ECONNREFUSED') {
|
|
600
|
+
t.pass('Skipping test - REST API not available');
|
|
601
|
+
} else {
|
|
602
|
+
// Check if the error response contains useful information
|
|
603
|
+
if (error.response) {
|
|
604
|
+
// We got a response from the server, but with an error status
|
|
605
|
+
t.log('Server responded with:', error.response.data);
|
|
606
|
+
|
|
607
|
+
// Skip the test if the server is running but no pathway is configured to handle the request
|
|
608
|
+
if (error.response.status === 404 &&
|
|
609
|
+
error.response.data.error &&
|
|
610
|
+
error.response.data.error.includes('not found')) {
|
|
611
|
+
t.pass('Skipping test - No suitable pathway configured for this API endpoint');
|
|
612
|
+
} else {
|
|
613
|
+
t.fail(`API request failed with status ${error.response.status}: ${error.response.statusText}`);
|
|
614
|
+
}
|
|
615
|
+
} else {
|
|
616
|
+
// No response received
|
|
617
|
+
t.fail(`API request failed: ${error.message}`);
|
|
618
|
+
}
|
|
619
|
+
}
|
|
620
|
+
}
|
|
621
|
+
});
|
|
622
|
+
|
|
623
|
+
|
|
499
624
|
|
|
@@ -0,0 +1,381 @@
|
|
|
1
|
+
// subscription.test.js
|
|
2
|
+
// Tests for GraphQL subscriptions and request progress messages
|
|
3
|
+
|
|
4
|
+
import test from 'ava';
|
|
5
|
+
import serverFactory from '../index.js';
|
|
6
|
+
import { createClient } from 'graphql-ws';
|
|
7
|
+
import ws from 'ws';
|
|
8
|
+
|
|
9
|
+
let testServer;
|
|
10
|
+
let wsClient;
|
|
11
|
+
|
|
12
|
+
test.before(async () => {
|
|
13
|
+
process.env.CORTEX_ENABLE_REST = 'true';
|
|
14
|
+
const { server, startServer } = await serverFactory();
|
|
15
|
+
startServer && await startServer();
|
|
16
|
+
testServer = server;
|
|
17
|
+
|
|
18
|
+
// Create WebSocket client for subscriptions
|
|
19
|
+
wsClient = createClient({
|
|
20
|
+
url: 'ws://localhost:4000/graphql',
|
|
21
|
+
webSocketImpl: ws,
|
|
22
|
+
retryAttempts: 3,
|
|
23
|
+
connectionParams: {},
|
|
24
|
+
on: {
|
|
25
|
+
error: (error) => {
|
|
26
|
+
console.error('WS connection error:', error);
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
// Test the connection by making a simple subscription
|
|
32
|
+
try {
|
|
33
|
+
await new Promise((resolve, reject) => {
|
|
34
|
+
const subscription = wsClient.subscribe(
|
|
35
|
+
{
|
|
36
|
+
query: `
|
|
37
|
+
subscription TestConnection {
|
|
38
|
+
requestProgress(requestIds: ["test"]) {
|
|
39
|
+
requestId
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
`
|
|
43
|
+
},
|
|
44
|
+
{
|
|
45
|
+
next: () => {
|
|
46
|
+
resolve();
|
|
47
|
+
},
|
|
48
|
+
error: reject,
|
|
49
|
+
complete: () => {
|
|
50
|
+
resolve();
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
);
|
|
54
|
+
|
|
55
|
+
// Add a timeout to avoid hanging
|
|
56
|
+
setTimeout(() => {
|
|
57
|
+
resolve();
|
|
58
|
+
}, 2000);
|
|
59
|
+
});
|
|
60
|
+
} catch (error) {
|
|
61
|
+
console.error('Failed to establish WebSocket connection:', error);
|
|
62
|
+
throw error;
|
|
63
|
+
}
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
test.after.always('cleanup', async () => {
|
|
67
|
+
if (wsClient) {
|
|
68
|
+
wsClient.dispose();
|
|
69
|
+
}
|
|
70
|
+
if (testServer) {
|
|
71
|
+
await testServer.stop();
|
|
72
|
+
}
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
// Helper function to create a subscription
|
|
76
|
+
async function createSubscription(query, variables) {
|
|
77
|
+
return wsClient.subscribe(
|
|
78
|
+
{
|
|
79
|
+
query,
|
|
80
|
+
variables
|
|
81
|
+
},
|
|
82
|
+
{
|
|
83
|
+
next: () => {},
|
|
84
|
+
error: (error) => console.error('Subscription error:', error),
|
|
85
|
+
complete: () => {}
|
|
86
|
+
}
|
|
87
|
+
);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// Helper function to collect subscription events with support for different event types
|
|
91
|
+
async function collectSubscriptionEvents(subscription, timeout = 30000, options = {}) {
|
|
92
|
+
const events = [];
|
|
93
|
+
const { requireCompletion = true, minEvents = 1 } = options;
|
|
94
|
+
|
|
95
|
+
return new Promise((resolve, reject) => {
|
|
96
|
+
let timeoutId;
|
|
97
|
+
|
|
98
|
+
const checkAndResolve = () => {
|
|
99
|
+
if (!requireCompletion && events.length >= minEvents) {
|
|
100
|
+
clearTimeout(timeoutId);
|
|
101
|
+
unsubscribe();
|
|
102
|
+
resolve(events);
|
|
103
|
+
}
|
|
104
|
+
};
|
|
105
|
+
|
|
106
|
+
timeoutId = setTimeout(() => {
|
|
107
|
+
// If we have any events at all when the timeout hits, consider it a success
|
|
108
|
+
if (events.length > 0) {
|
|
109
|
+
resolve(events);
|
|
110
|
+
} else {
|
|
111
|
+
// Only reject if we have no events at all
|
|
112
|
+
reject(new Error('Subscription timed out with no events'));
|
|
113
|
+
}
|
|
114
|
+
}, timeout);
|
|
115
|
+
|
|
116
|
+
const unsubscribe = wsClient.subscribe(
|
|
117
|
+
{
|
|
118
|
+
query: subscription.query,
|
|
119
|
+
variables: subscription.variables
|
|
120
|
+
},
|
|
121
|
+
{
|
|
122
|
+
next: (event) => {
|
|
123
|
+
events.push(event);
|
|
124
|
+
|
|
125
|
+
// Check for completion or minimum events
|
|
126
|
+
if (requireCompletion && event?.data?.requestProgress?.progress === 1) {
|
|
127
|
+
clearTimeout(timeoutId);
|
|
128
|
+
unsubscribe();
|
|
129
|
+
resolve(events);
|
|
130
|
+
} else {
|
|
131
|
+
checkAndResolve();
|
|
132
|
+
}
|
|
133
|
+
},
|
|
134
|
+
error: (error) => {
|
|
135
|
+
clearTimeout(timeoutId);
|
|
136
|
+
reject(error);
|
|
137
|
+
},
|
|
138
|
+
complete: () => {
|
|
139
|
+
clearTimeout(timeoutId);
|
|
140
|
+
resolve(events);
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
);
|
|
144
|
+
});
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
// Add validation helpers
|
|
148
|
+
function validateProgressMessage(t, progress, requestId = null) {
|
|
149
|
+
// Basic field existence checks
|
|
150
|
+
t.truthy(progress, 'progress field should exist');
|
|
151
|
+
t.truthy(progress.requestId, 'requestId field should exist');
|
|
152
|
+
t.truthy(progress.progress !== undefined, 'progress value should exist');
|
|
153
|
+
|
|
154
|
+
if (requestId) {
|
|
155
|
+
t.is(progress.requestId, requestId, 'Request ID should match throughout');
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
// Validate data field if present
|
|
159
|
+
if (progress.data) {
|
|
160
|
+
t.true(typeof progress.data === 'string', 'Data field should be a string');
|
|
161
|
+
t.notThrows(() => JSON.parse(progress.data), 'Data should be valid JSON');
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
// Validate info field if present and not an error
|
|
165
|
+
if (progress.info && !progress.info.startsWith('ERROR:')) {
|
|
166
|
+
t.true(typeof progress.info === 'string', 'Info field should be a string');
|
|
167
|
+
t.notThrows(() => {
|
|
168
|
+
const parsedInfo = JSON.parse(progress.info);
|
|
169
|
+
t.true(typeof parsedInfo === 'object', 'Info should be valid JSON object');
|
|
170
|
+
}, 'Info should be valid JSON');
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
test.serial('Request progress messages have string data and info fields', async (t) => {
|
|
175
|
+
// Execute an async pathway that will generate progress messages
|
|
176
|
+
const response = await testServer.executeOperation({
|
|
177
|
+
query: `
|
|
178
|
+
query TestQuery($text: String!) {
|
|
179
|
+
chat(text: $text, async: true, stream: true) {
|
|
180
|
+
result
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
`,
|
|
184
|
+
variables: {
|
|
185
|
+
text: 'Generate a long response to test streaming'
|
|
186
|
+
}
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
console.log('Response:', JSON.stringify(response, null, 2));
|
|
190
|
+
|
|
191
|
+
// Get requestId from response
|
|
192
|
+
const requestId = response.body?.singleResult?.data?.chat?.result;
|
|
193
|
+
t.truthy(requestId, 'Should have a requestId in the result field');
|
|
194
|
+
|
|
195
|
+
// Collect all events from the subscription
|
|
196
|
+
const events = await collectSubscriptionEvents({
|
|
197
|
+
query: `
|
|
198
|
+
subscription OnRequestProgress($requestId: String!) {
|
|
199
|
+
requestProgress(requestIds: [$requestId]) {
|
|
200
|
+
requestId
|
|
201
|
+
progress
|
|
202
|
+
data
|
|
203
|
+
info
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
`,
|
|
207
|
+
variables: { requestId }
|
|
208
|
+
}, 10000, { requireCompletion: false, minEvents: 1 });
|
|
209
|
+
|
|
210
|
+
console.log('Events received:', JSON.stringify(events, null, 2));
|
|
211
|
+
t.true(events.length > 0, 'Should have received events');
|
|
212
|
+
|
|
213
|
+
// Verify each event has string data and info fields
|
|
214
|
+
for (const event of events) {
|
|
215
|
+
console.log('Processing event:', JSON.stringify(event, null, 2));
|
|
216
|
+
const progress = event.data.requestProgress;
|
|
217
|
+
|
|
218
|
+
validateProgressMessage(t, progress, requestId);
|
|
219
|
+
}
|
|
220
|
+
});
|
|
221
|
+
|
|
222
|
+
test.serial('sys_entity_start streaming works correctly', async (t) => {
|
|
223
|
+
// Execute sys_entity_start with streaming
|
|
224
|
+
const response = await testServer.executeOperation({
|
|
225
|
+
query: `
|
|
226
|
+
query TestQuery($text: String!, $chatHistory: [MultiMessage]!, $stream: Boolean!) {
|
|
227
|
+
sys_entity_start(text: $text, chatHistory: $chatHistory, stream: $stream) {
|
|
228
|
+
result
|
|
229
|
+
contextId
|
|
230
|
+
tool
|
|
231
|
+
warnings
|
|
232
|
+
errors
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
`,
|
|
236
|
+
variables: {
|
|
237
|
+
text: 'Tell me about the history of Al Jazeera',
|
|
238
|
+
chatHistory: [{ role: "user", content: ["Tell me about the history of Al Jazeera"] }],
|
|
239
|
+
stream: true
|
|
240
|
+
}
|
|
241
|
+
});
|
|
242
|
+
|
|
243
|
+
console.log('Initial Response:', JSON.stringify(response, null, 2));
|
|
244
|
+
const requestId = response.body?.singleResult?.data?.sys_entity_start?.result;
|
|
245
|
+
t.truthy(requestId, 'Should have a requestId in the result field');
|
|
246
|
+
|
|
247
|
+
// Collect events with a longer timeout since this is a real streaming operation
|
|
248
|
+
const events = await collectSubscriptionEvents({
|
|
249
|
+
query: `
|
|
250
|
+
subscription OnRequestProgress($requestId: String!) {
|
|
251
|
+
requestProgress(requestIds: [$requestId]) {
|
|
252
|
+
requestId
|
|
253
|
+
progress
|
|
254
|
+
data
|
|
255
|
+
info
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
`,
|
|
259
|
+
variables: { requestId },
|
|
260
|
+
timeout: 30000, // Longer timeout for streaming response
|
|
261
|
+
requireCompletion: false,
|
|
262
|
+
minEvents: 1
|
|
263
|
+
});
|
|
264
|
+
|
|
265
|
+
console.log('Events received:', JSON.stringify(events, null, 2));
|
|
266
|
+
t.true(events.length > 0, 'Should have received events');
|
|
267
|
+
|
|
268
|
+
// Verify streaming data format matches expected structure
|
|
269
|
+
for (const event of events) {
|
|
270
|
+
console.log('Processing event:', JSON.stringify(event, null, 2));
|
|
271
|
+
const progress = event.data.requestProgress;
|
|
272
|
+
validateProgressMessage(t, progress, requestId);
|
|
273
|
+
|
|
274
|
+
// Additional streaming-specific checks
|
|
275
|
+
if (progress.data) {
|
|
276
|
+
const parsed = JSON.parse(progress.data);
|
|
277
|
+
t.true(
|
|
278
|
+
typeof parsed === 'string' ||
|
|
279
|
+
typeof parsed === 'object',
|
|
280
|
+
'Data should be either a string or an object'
|
|
281
|
+
);
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
});
|
|
285
|
+
|
|
286
|
+
test.serial('Translate pathway handles chunked async processing correctly', async (t) => {
|
|
287
|
+
// Create a long text that will be split into chunks
|
|
288
|
+
const longText = `In the heart of the bustling metropolis, where skyscrapers pierce the clouds and streets pulse with endless energy,
|
|
289
|
+
a story unfolds. It's a tale of innovation and perseverance, of dreams taking flight in the digital age.
|
|
290
|
+
Entrepreneurs and visionaries gather in gleaming office towers, their minds focused on the next breakthrough that will reshape our world.
|
|
291
|
+
In labs and workshops, engineers and designers collaborate, their fingers dancing across keyboards as they write the future in lines of code.
|
|
292
|
+
The city never sleeps, its rhythm maintained by the constant flow of ideas and ambition. Coffee shops become impromptu meeting rooms,
|
|
293
|
+
where startups are born on napkins and partnerships forged over steaming lattes. The energy is palpable, electric, contagious.
|
|
294
|
+
In the background, servers hum in vast data centers, processing countless transactions and storing the collective knowledge of humanity.
|
|
295
|
+
The digital revolution continues unabated, transforming how we live, work, and connect with one another.
|
|
296
|
+
Young graduates fresh from university mingle with seasoned veterans, each bringing their unique perspective to the challenges at hand.
|
|
297
|
+
The boundaries between traditional industries blur as technology weaves its way into every aspect of business and society.
|
|
298
|
+
This is the story of progress, of human ingenuity pushing the boundaries of what's possible.
|
|
299
|
+
It's a narrative that continues to evolve, page by digital page, in the great book of human achievement.`.repeat(10);
|
|
300
|
+
|
|
301
|
+
// Execute translate with async mode
|
|
302
|
+
const response = await testServer.executeOperation({
|
|
303
|
+
query: `
|
|
304
|
+
query TestQuery($text: String!, $to: String!) {
|
|
305
|
+
translate_gpt4_omni(text: $text, to: $to, async: true) {
|
|
306
|
+
result
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
`,
|
|
310
|
+
variables: {
|
|
311
|
+
text: longText,
|
|
312
|
+
to: 'Spanish'
|
|
313
|
+
}
|
|
314
|
+
});
|
|
315
|
+
|
|
316
|
+
console.log('Initial Response:', JSON.stringify(response, null, 2));
|
|
317
|
+
const requestId = response.body?.singleResult?.data?.translate_gpt4_omni?.result;
|
|
318
|
+
t.truthy(requestId, 'Should have a requestId in the result field');
|
|
319
|
+
|
|
320
|
+
// Collect events with a longer timeout since this is a chunked operation
|
|
321
|
+
const events = await collectSubscriptionEvents({
|
|
322
|
+
query: `
|
|
323
|
+
subscription OnRequestProgress($requestId: String!) {
|
|
324
|
+
requestProgress(requestIds: [$requestId]) {
|
|
325
|
+
requestId
|
|
326
|
+
progress
|
|
327
|
+
data
|
|
328
|
+
info
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
`,
|
|
332
|
+
variables: { requestId },
|
|
333
|
+
timeout: 180000, // 3 minutes for large chunked processing
|
|
334
|
+
requireCompletion: true
|
|
335
|
+
});
|
|
336
|
+
|
|
337
|
+
console.log('Events received:', JSON.stringify(events, null, 2));
|
|
338
|
+
t.true(events.length > 0, 'Should have received events');
|
|
339
|
+
|
|
340
|
+
// Track progress values to ensure they increase
|
|
341
|
+
let lastProgress = -1;
|
|
342
|
+
let finalTranslation = null;
|
|
343
|
+
let progressValues = new Set();
|
|
344
|
+
let processingMessages = 0;
|
|
345
|
+
|
|
346
|
+
// Verify progress messages and final data
|
|
347
|
+
for (const event of events) {
|
|
348
|
+
console.log('Processing event:', JSON.stringify(event, null, 2));
|
|
349
|
+
const progress = event.data.requestProgress;
|
|
350
|
+
|
|
351
|
+
validateProgressMessage(t, progress, requestId);
|
|
352
|
+
|
|
353
|
+
// Verify progress increases
|
|
354
|
+
if (progress.progress !== null) {
|
|
355
|
+
t.true(progress.progress >= lastProgress, 'Progress should increase monotonically');
|
|
356
|
+
t.true(progress.progress >= 0 && progress.progress <= 1, 'Progress should be between 0 and 1');
|
|
357
|
+
progressValues.add(progress.progress);
|
|
358
|
+
lastProgress = progress.progress;
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
// Only expect translated data when progress is 1
|
|
362
|
+
if (progress.progress === 1) {
|
|
363
|
+
t.truthy(progress.data, 'Should have data in final progress message');
|
|
364
|
+
const parsed = JSON.parse(progress.data);
|
|
365
|
+
t.true(typeof parsed === 'string', 'Final data should be a string containing translation');
|
|
366
|
+
t.true(parsed.length > 0, 'Translation should not be empty');
|
|
367
|
+
finalTranslation = parsed;
|
|
368
|
+
} else {
|
|
369
|
+
// Count any non-final progress message
|
|
370
|
+
processingMessages++;
|
|
371
|
+
}
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
// Verify we got multiple distinct progress updates
|
|
375
|
+
t.true(progressValues.size >= 2, 'Should have at least 2 different progress values');
|
|
376
|
+
t.true(processingMessages >= 1, 'Should have at least one processing status message');
|
|
377
|
+
|
|
378
|
+
// Verify we got to completion with final translation
|
|
379
|
+
t.is(lastProgress, 1, 'Should have reached completion (progress = 1)');
|
|
380
|
+
t.truthy(finalTranslation, 'Should have received final translation');
|
|
381
|
+
});
|