ag-ai 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (55) hide show
  1. package/README.md +31 -0
  2. package/dist/client/adapters/cluster.d.ts +12 -0
  3. package/dist/client/adapters/cluster.js +567 -0
  4. package/dist/client/adapters/google.d.ts +12 -0
  5. package/dist/client/adapters/google.js +478 -0
  6. package/dist/client/client.d.ts +11 -0
  7. package/dist/client/client.js +192 -0
  8. package/dist/client/index.d.ts +3 -0
  9. package/dist/client/index.js +18 -0
  10. package/dist/client/quota.d.ts +55 -0
  11. package/dist/client/quota.js +148 -0
  12. package/dist/client/types.d.ts +124 -0
  13. package/dist/client/types.js +2 -0
  14. package/dist/index.d.ts +2 -0
  15. package/dist/index.js +18 -0
  16. package/dist/internal/apikey.d.ts +5 -0
  17. package/dist/internal/apikey.js +51 -0
  18. package/dist/internal/array.d.ts +2 -0
  19. package/dist/internal/array.js +6 -0
  20. package/dist/internal/log.d.ts +9 -0
  21. package/dist/internal/log.js +92 -0
  22. package/dist/internal/node-cache.d.ts +71 -0
  23. package/dist/internal/node-cache.js +104 -0
  24. package/dist/internal/retryOnError.d.ts +26 -0
  25. package/dist/internal/retryOnError.js +123 -0
  26. package/dist/internal/string/redact.d.ts +2 -0
  27. package/dist/internal/string/redact.js +94 -0
  28. package/dist/internal/string/truncate.d.ts +8 -0
  29. package/dist/internal/string/truncate.js +16 -0
  30. package/dist/k8s/assets/network-init.sh +52 -0
  31. package/dist/k8s/common.d.ts +35 -0
  32. package/dist/k8s/common.js +56 -0
  33. package/dist/k8s/index.d.ts +9 -0
  34. package/dist/k8s/index.js +33 -0
  35. package/dist/k8s/opencode.d.ts +4 -0
  36. package/dist/k8s/opencode.js +55 -0
  37. package/dist/k8s/runner.d.ts +4 -0
  38. package/dist/k8s/runner.js +247 -0
  39. package/dist/k8s/t3code.d.ts +4 -0
  40. package/dist/k8s/t3code.js +60 -0
  41. package/package.json +56 -0
  42. package/runtime/bridge.mjs +1093 -0
  43. package/runtime/executor/opencode.mjs +430 -0
  44. package/runtime/image/adapter.mjs +213 -0
  45. package/runtime/index.mjs +53 -0
  46. package/runtime/protocol.mjs +384 -0
  47. package/runtime/server.mjs +621 -0
  48. package/runtime/store.mjs +568 -0
  49. package/runtime/telemetry.mjs +137 -0
  50. package/runtime/tests/bridge.test.mjs +554 -0
  51. package/runtime/tests/image-adapter.test.mjs +148 -0
  52. package/runtime/tests/protocol.test.mjs +156 -0
  53. package/runtime/tests/server.test.mjs +231 -0
  54. package/runtime/tests/store.test.mjs +114 -0
  55. package/runtime/tests/telemetry.test.mjs +138 -0
@@ -0,0 +1,156 @@
1
+ import assert from 'node:assert/strict';
2
+ import test from 'node:test';
3
+ /* eslint-disable import/extensions -- Native Node ESM requires file extensions. */
4
+
5
+ import {
6
+ GenerationProtocolError,
7
+ normalizeGenerationRequest,
8
+ normalizeModelCatalog,
9
+ toRunnerInput,
10
+ } from '../protocol.mjs';
11
+
12
+ const models = [
13
+ {
14
+ id: 'gpt-5.6-sol',
15
+ isDefault: true,
16
+ inputModalities: ['text', 'image'],
17
+ supportedReasoningEfforts: [{ reasoningEffort: 'low' }, { reasoningEffort: 'high' }],
18
+ defaultReasoningEffort: 'low',
19
+ },
20
+ {
21
+ id: 'gpt-5.6-luna',
22
+ inputModalities: ['text', 'image'],
23
+ supportedReasoningEfforts: [{ reasoningEffort: 'medium' }],
24
+ defaultReasoningEffort: 'medium',
25
+ },
26
+ {
27
+ id: 'gpt-5.5',
28
+ imageGeneration: true,
29
+ inputModalities: ['text', 'image'],
30
+ supportedReasoningEfforts: [{ reasoningEffort: 'high' }],
31
+ defaultReasoningEffort: 'high',
32
+ },
33
+ {
34
+ id: 'gpt-5.3-ag-ai-spark',
35
+ inputModalities: ['text'],
36
+ supportedReasoningEfforts: [{ reasoningEffort: 'low' }],
37
+ defaultReasoningEffort: 'low',
38
+ },
39
+ ];
40
+
41
+ const request = (overrides = {}) => ({
42
+ version: 1,
43
+ input: [{ role: 'user', content: [{ type: 'text', text: 'hello' }] }],
44
+ output: ['text'],
45
+ ...overrides,
46
+ });
47
+
48
+ test('normalizes the actual model catalog without inventing availability', () => {
49
+ const catalog = normalizeModelCatalog(models);
50
+ assert.deepEqual(
51
+ catalog.map(({ id }) => id),
52
+ models.map(({ id }) => id),
53
+ );
54
+ assert.deepEqual(catalog[0].outputModalities, ['text']);
55
+ assert.deepEqual(catalog[1].outputModalities, ['text']);
56
+ assert.deepEqual(catalog[2].outputModalities, ['text', 'image']);
57
+ assert.deepEqual(catalog[3].outputModalities, ['text']);
58
+ assert.throws(
59
+ () => normalizeGenerationRequest(request({ model: 'missing-model' }), models),
60
+ (error) => {
61
+ assert(error instanceof GenerationProtocolError);
62
+ assert.equal(error.status, 422);
63
+ return true;
64
+ },
65
+ );
66
+ });
67
+
68
+ test('selects preferences from available models and keeps explicit model exact', () => {
69
+ assert.equal(
70
+ normalizeGenerationRequest(request({ prefer: 'fast' }), models).model,
71
+ 'gpt-5.6-luna',
72
+ );
73
+ assert.equal(
74
+ normalizeGenerationRequest(request({ prefer: 'quality' }), models).model,
75
+ 'gpt-5.6-sol',
76
+ );
77
+ const selected = normalizeGenerationRequest(
78
+ request({ model: 'gpt-5.6-luna', reasoningEffort: 'medium' }),
79
+ models,
80
+ );
81
+ assert.equal(selected.model, 'gpt-5.6-luna');
82
+ assert.equal(selected.reasoningEffort, 'medium');
83
+ });
84
+
85
+ test('rejects unsupported provider options and modalities explicitly', () => {
86
+ for (const value of [
87
+ request({ maxOutputTokens: 100 }),
88
+ request({ webSearch: true }),
89
+ request({
90
+ input: [{ role: 'user', content: [{ type: 'audio', mimeType: 'audio/wav', data: 'AAAA' }] }],
91
+ }),
92
+ request({ output: ['file'] }),
93
+ ]) {
94
+ assert.throws(
95
+ () => normalizeGenerationRequest(value, models),
96
+ (error) => {
97
+ assert(error instanceof GenerationProtocolError);
98
+ assert.equal(error.status, 422);
99
+ return true;
100
+ },
101
+ );
102
+ }
103
+
104
+ assert.throws(
105
+ () => normalizeGenerationRequest(request({ model: 'gpt-5.6-sol', output: ['image'] }), models),
106
+ (error) => {
107
+ assert(error instanceof GenerationProtocolError);
108
+ assert.equal(error.status, 422);
109
+ return true;
110
+ },
111
+ );
112
+
113
+ assert.deepEqual(
114
+ normalizeGenerationRequest(
115
+ request({ model: 'gpt-5.5', reasoningEffort: 'high', output: ['text', 'image'] }),
116
+ models,
117
+ ).output,
118
+ ['text', 'image'],
119
+ );
120
+ });
121
+
122
+ test('runner conversion keeps the generic media shape until executor translation', () => {
123
+ const normalized = normalizeGenerationRequest(
124
+ request({
125
+ model: 'gpt-5.5',
126
+ reasoningEffort: 'high',
127
+ input: [
128
+ {
129
+ role: 'user',
130
+ content: [
131
+ { type: 'text', text: 'describe this' },
132
+ { type: 'image', mimeType: 'image/png', data: 'iVBORw0KGgo=' },
133
+ ],
134
+ },
135
+ ],
136
+ output: ['text', 'image'],
137
+ }),
138
+ models,
139
+ );
140
+ const runnerInput = toRunnerInput(normalized, 'run-1', models);
141
+ assert.equal(runnerInput.messages[0].content[0].type, 'text');
142
+ assert.equal(runnerInput.messages[0].content[1].type, 'image');
143
+ assert.equal(runnerInput.messages[0].content[1].mimeType, 'image/png');
144
+ assert.equal(runnerInput.messages[0].content[1].data, 'iVBORw0KGgo=');
145
+ });
146
+
147
+ test('requires canonical request fields and rejects unknown fields', () => {
148
+ assert.throws(
149
+ () => normalizeGenerationRequest({ version: 1, output: ['text'] }, models),
150
+ GenerationProtocolError,
151
+ );
152
+ assert.throws(
153
+ () => normalizeGenerationRequest(request({ legacyText: 'x' }), models),
154
+ GenerationProtocolError,
155
+ );
156
+ });
@@ -0,0 +1,231 @@
1
+ import assert from 'node:assert/strict';
2
+ import { mkdtemp, rm } from 'node:fs/promises';
3
+ import { createServer } from 'node:net';
4
+ import { tmpdir } from 'node:os';
5
+ import path from 'node:path';
6
+ import test from 'node:test';
7
+ /* eslint-disable import/extensions -- Native Node ESM requires file extensions. */
8
+
9
+ import { idempotencyDigest, requestDigest } from '../protocol.mjs';
10
+ import {
11
+ GenerationServer,
12
+ createLocalRunner,
13
+ isAllowedPeer,
14
+ normalizeBindHost,
15
+ } from '../server.mjs';
16
+ import { GenerationStore } from '../store.mjs';
17
+
18
+ const baseModels = [
19
+ {
20
+ id: 'gpt-5.6-sol',
21
+ model: 'gpt-5.6-sol',
22
+ isDefault: true,
23
+ inputModalities: ['text', 'image'],
24
+ supportedReasoningEfforts: [{ reasoningEffort: 'low' }, { reasoningEffort: 'high' }],
25
+ defaultReasoningEffort: 'low',
26
+ },
27
+ {
28
+ id: 'gpt-5.6-luna',
29
+ model: 'gpt-5.6-luna',
30
+ inputModalities: ['text', 'image'],
31
+ supportedReasoningEfforts: [{ reasoningEffort: 'medium' }],
32
+ defaultReasoningEffort: 'medium',
33
+ },
34
+ ];
35
+
36
+ const request = {
37
+ version: 1,
38
+ input: [{ role: 'user', content: [{ type: 'text', text: 'hello' }] }],
39
+ output: ['text'],
40
+ prefer: 'quality',
41
+ };
42
+
43
+ const getFreePort = async () => {
44
+ const socket = createServer();
45
+ await new Promise((resolve) => socket.listen(0, '127.0.0.1', resolve));
46
+ const port = socket.address().port;
47
+ await new Promise((resolve) => socket.close(resolve));
48
+ return port;
49
+ };
50
+
51
+ const setup = async () => {
52
+ const root = await mkdtemp(path.join(tmpdir(), 'ag-ai-generation-server-'));
53
+ const store = new GenerationStore(root);
54
+ await store.init();
55
+ let currentModels = structuredClone(baseModels);
56
+ const events = [];
57
+ const server = new GenerationServer({
58
+ host: '127.0.0.1',
59
+ port: await getFreePort(),
60
+ token: 'test-token',
61
+ store,
62
+ getModels: () => currentModels,
63
+ isReady: () => true,
64
+ analytics: {
65
+ emit: (...segments) => {
66
+ events.push(segments.join('/'));
67
+ return Promise.resolve(true);
68
+ },
69
+ },
70
+ });
71
+ await server.listen();
72
+ return {
73
+ root,
74
+ store,
75
+ server,
76
+ events,
77
+ setModels: (nextModels) => {
78
+ currentModels = nextModels;
79
+ },
80
+ };
81
+ };
82
+
83
+ const call = (address, pathName, options = {}) =>
84
+ fetch(`http://${address.address}:${address.port}${pathName}`, {
85
+ ...options,
86
+ headers: {
87
+ authorization: 'Bearer test-token',
88
+ ...(options.body === undefined ? {} : { 'content-type': 'application/json' }),
89
+ ...options.headers,
90
+ },
91
+ });
92
+
93
+ test('bind and peer checks remain private-LAN-only', () => {
94
+ assert.equal(isAllowedPeer('127.0.0.1'), true);
95
+ assert.equal(isAllowedPeer('::ffff:127.0.0.1'), true);
96
+ assert.equal(isAllowedPeer('192.168.0.42'), true);
97
+ assert.equal(isAllowedPeer('192.168.1.42'), true);
98
+ assert.equal(isAllowedPeer('192.168.0.42', '192.168.0.'), true);
99
+ assert.equal(isAllowedPeer('192.168.1.42', '192.168.0.'), false);
100
+ assert.equal(isAllowedPeer('8.8.8.8'), false);
101
+ assert.throws(() => normalizeBindHost('0.0.0.0'));
102
+ assert.throws(() => normalizeBindHost('203.0.113.5'));
103
+ });
104
+
105
+ test('emits received and terminal events for every authenticated or rejected request', async () => {
106
+ const { root, store, server, events } = await setup();
107
+ try {
108
+ const address = server.address();
109
+ const unauthorized = await call(address, '/v1/models', {
110
+ headers: { authorization: '' },
111
+ });
112
+ assert.equal(unauthorized.status, 401);
113
+ const accepted = await call(address, '/v1/models');
114
+ assert.equal(accepted.status, 200);
115
+ await new Promise((resolve) => setImmediate(resolve));
116
+
117
+ assert.equal(events.filter((event) => event.endsWith('/received')).length, 2);
118
+ assert.equal(events.filter((event) => event.includes('/responded/')).length, 2);
119
+ assert.ok(events.includes('http/models/authentication-rejected'));
120
+ assert.ok(events.includes('http/models/model-catalog-loaded'));
121
+ assert.ok(events.includes('http/models/responded/401'));
122
+ assert.ok(events.includes('http/models/responded/200'));
123
+ } finally {
124
+ await server.close();
125
+ await store.close();
126
+ await rm(root, { recursive: true, force: true });
127
+ }
128
+ });
129
+
130
+ test('serves actual model capabilities and preserves raw idempotency across catalog changes', async () => {
131
+ const { root, store, server, setModels } = await setup();
132
+ try {
133
+ const address = server.address();
134
+ const modelsResponse = await call(address, '/v1/models');
135
+ assert.equal(modelsResponse.status, 200);
136
+ const catalog = await modelsResponse.json();
137
+ assert.equal(catalog.models[1].id, 'gpt-5.6-luna');
138
+ const key = 'request-1';
139
+ const firstResponse = await call(address, '/v1/jobs', {
140
+ method: 'POST',
141
+ headers: { 'idempotency-key': key },
142
+ body: JSON.stringify(request),
143
+ });
144
+ assert.equal(firstResponse.status, 202);
145
+ const first = await firstResponse.json();
146
+ const claimed = await store.claimNext();
147
+ assert.equal(claimed.model, 'gpt-5.6-sol');
148
+
149
+ setModels([
150
+ {
151
+ ...baseModels[1],
152
+ isDefault: true,
153
+ },
154
+ ]);
155
+ const replayResponse = await call(address, '/v1/jobs', {
156
+ method: 'POST',
157
+ headers: { 'idempotency-key': key },
158
+ body: JSON.stringify(request),
159
+ });
160
+ assert.equal(replayResponse.status, 200);
161
+ const replay = await replayResponse.json();
162
+ assert.equal(replay.id, first.id);
163
+ assert.equal(replay.status, 'running');
164
+
165
+ const conflictResponse = await call(address, '/v1/jobs', {
166
+ method: 'POST',
167
+ headers: { 'idempotency-key': key },
168
+ body: JSON.stringify({ ...request, instructions: 'different' }),
169
+ });
170
+ assert.equal(conflictResponse.status, 409);
171
+
172
+ await store.complete(claimed.id, {
173
+ output: [{ type: 'text', text: 'done' }],
174
+ model: claimed.model,
175
+ generatedAt: Date.now(),
176
+ });
177
+ const resultResponse = await call(address, `/v1/jobs/${first.id}`);
178
+ assert.equal(resultResponse.status, 200);
179
+ assert.equal((await resultResponse.json()).result.output[0].text, 'done');
180
+ } finally {
181
+ await server.close();
182
+ await store.close();
183
+ await rm(root, { recursive: true, force: true });
184
+ }
185
+ });
186
+
187
+ test('local runner resolves uploaded image references into inline LAN results', async () => {
188
+ const { root, store, server } = await setup();
189
+ try {
190
+ const imageRequest = {
191
+ ...request,
192
+ model: 'gpt-5.6-luna',
193
+ reasoningEffort: 'medium',
194
+ output: ['text', 'image'],
195
+ };
196
+ const created = await store.create({
197
+ idempotencyKeyHash: idempotencyDigest('image-job'),
198
+ requestHash: requestDigest(imageRequest),
199
+ request: imageRequest,
200
+ model: imageRequest.model,
201
+ reasoningEffort: imageRequest.reasoningEffort,
202
+ });
203
+ const claimed = await store.claimNext();
204
+ const local = createLocalRunner({ store, record: claimed });
205
+ const png = Buffer.from('89504e470d0a1a0a', 'hex');
206
+ await local.upload(
207
+ claimed.id,
208
+ claimed.leaseToken,
209
+ Buffer.from(
210
+ JSON.stringify({
211
+ output: [
212
+ { type: 'text', text: 'done' },
213
+ { type: 'image', mimeType: 'image/png', name: 'image-0.png' },
214
+ ],
215
+ model: claimed.model,
216
+ generatedAt: Date.now(),
217
+ }),
218
+ ),
219
+ 'response.json',
220
+ );
221
+ await local.upload(claimed.id, claimed.leaseToken, png, 'image-0.png');
222
+ await local.complete();
223
+ const result = await store.get(created.record.id);
224
+ assert.equal(result.result.output[1].data, png.toString('base64'));
225
+ assert.equal(typeof result.result.generatedAt, 'number');
226
+ } finally {
227
+ await server.close();
228
+ await store.close();
229
+ await rm(root, { recursive: true, force: true });
230
+ }
231
+ });
@@ -0,0 +1,114 @@
1
+ import assert from 'node:assert/strict';
2
+ import { mkdtemp, readdir, rm, writeFile } from 'node:fs/promises';
3
+ import { tmpdir } from 'node:os';
4
+ import path from 'node:path';
5
+ import test from 'node:test';
6
+ /* eslint-disable import/extensions -- Native Node ESM requires file extensions. */
7
+
8
+ import { idempotencyDigest, requestDigest } from '../protocol.mjs';
9
+ import { GenerationStore } from '../store.mjs';
10
+
11
+ const request = {
12
+ version: 1,
13
+ input: [{ role: 'user', content: [{ type: 'text', text: 'hello' }] }],
14
+ output: ['text'],
15
+ model: 'gpt-5.6-sol',
16
+ reasoningEffort: 'low',
17
+ };
18
+
19
+ const createStore = async () => {
20
+ const root = await mkdtemp(path.join(tmpdir(), 'ag-ai-generation-store-'));
21
+ const store = new GenerationStore(root);
22
+ await store.init();
23
+ return { root, store };
24
+ };
25
+
26
+ const createJob = (store, key = 'key-1', body = request) =>
27
+ store.create({
28
+ idempotencyKeyHash: idempotencyDigest(key),
29
+ requestHash: requestDigest(body),
30
+ request: body,
31
+ model: body.model,
32
+ reasoningEffort: body.reasoningEffort,
33
+ });
34
+
35
+ test('persists idempotency and keeps request bodies off the in-memory metadata map', async () => {
36
+ const { root, store } = await createStore();
37
+ try {
38
+ const first = await createJob(store);
39
+ const replay = await createJob(store);
40
+ assert.equal(first.created, true);
41
+ assert.equal(replay.created, false);
42
+ assert.equal(replay.record.id, first.record.id);
43
+ assert.equal(store.records.get(first.record.id).bodyPresent, true);
44
+ assert.equal('request' in store.records.get(first.record.id), false);
45
+ await assert.rejects(
46
+ createJob(store, 'key-1', { ...request, instructions: 'different' }),
47
+ (error) => error.code === 'idempotency_conflict',
48
+ );
49
+ } finally {
50
+ await store.close();
51
+ await rm(root, { recursive: true, force: true });
52
+ }
53
+ });
54
+
55
+ test('marks in-flight jobs unknown after restart', async () => {
56
+ const { root, store } = await createStore();
57
+ try {
58
+ const created = await createJob(store);
59
+ const claimed = await store.claimNext();
60
+ assert.equal(claimed.id, created.record.id);
61
+ const restarted = new GenerationStore(root);
62
+ await restarted.init();
63
+ const record = await restarted.get(created.record.id);
64
+ assert.equal(record.status, 'unknown');
65
+ assert.equal(record.error.code, 'unknown_after_restart');
66
+ await restarted.close();
67
+ } finally {
68
+ await store.close();
69
+ await rm(root, { recursive: true, force: true });
70
+ }
71
+ });
72
+
73
+ test('recovers a compaction crash with a tombstone and preserves replay identity', async () => {
74
+ const { root, store } = await createStore();
75
+ try {
76
+ const created = await createJob(store);
77
+ await store.claimNext();
78
+ await store.complete(created.record.id, {
79
+ output: [{ type: 'text', text: 'done' }],
80
+ model: request.model,
81
+ generatedAt: Date.now(),
82
+ });
83
+ const full = await store.get(created.record.id);
84
+ const metadata = {
85
+ version: 1,
86
+ id: full.id,
87
+ idempotencyKeyHash: full.idempotencyKeyHash,
88
+ requestHash: full.requestHash,
89
+ model: full.model,
90
+ reasoningEffort: full.reasoningEffort,
91
+ requestBytes: full.requestBytes,
92
+ status: full.status,
93
+ createdAt: full.createdAt,
94
+ updatedAt: full.updatedAt,
95
+ completedAt: full.completedAt,
96
+ cancelRequested: false,
97
+ bodyPresent: false,
98
+ };
99
+ await writeFile(path.join(root, `${full.id}.tombstone.json`), JSON.stringify(metadata));
100
+ const restarted = new GenerationStore(root);
101
+ await restarted.init();
102
+ const recovered = await restarted.get(full.id);
103
+ assert.equal(recovered.status, 'completed');
104
+ assert.equal(recovered.bodyPresent, false);
105
+ assert.equal(recovered.result, undefined);
106
+ assert.equal((await readdir(root)).includes(`${full.id}.json`), false);
107
+ const replay = await restarted.findByIdempotencyKeyHash(full.idempotencyKeyHash);
108
+ assert.equal(replay.id, full.id);
109
+ await restarted.close();
110
+ } finally {
111
+ await store.close();
112
+ await rm(root, { recursive: true, force: true });
113
+ }
114
+ });
@@ -0,0 +1,138 @@
1
+ import assert from 'node:assert/strict';
2
+ import test from 'node:test';
3
+ /* eslint-disable import/extensions -- Native Node ESM requires file extensions. */
4
+
5
+ import {
6
+ DEFAULT_ANALYTICA_EVENT_PREFIX,
7
+ DEFAULT_ANALYTICA_PAGE_LOCATION,
8
+ AnalyticaEvents,
9
+ analyticaEventName,
10
+ } from '../telemetry.mjs';
11
+
12
+ test('Analytica events contain bounded low-cardinality flow names and pod attribution', async () => {
13
+ const requests = [];
14
+ const analytics = new AnalyticaEvents({
15
+ token: 'test-token',
16
+ source: 'ag-ai-runner',
17
+ eventUrl: 'https://telemetry.example/api/site/event',
18
+ fetchImpl: async (url, init) => {
19
+ requests.push({ url, init });
20
+ return new Response(null, { status: 204 });
21
+ },
22
+ });
23
+
24
+ assert.equal(await analytics.emit('job', 'local', 'turn completed'), true);
25
+ await analytics.flush();
26
+
27
+ assert.equal(requests.length, 1);
28
+ assert.equal(requests[0].url, 'https://telemetry.example/api/site/event');
29
+ assert.equal(requests[0].init.headers.authorization, '');
30
+ const payload = JSON.parse(requests[0].init.body);
31
+ assert.deepEqual(payload, {
32
+ key: 'test-token',
33
+ data: {
34
+ eventName: `${DEFAULT_ANALYTICA_EVENT_PREFIX}/job/local/turn-completed`,
35
+ browserFingerprint: 'ag-ai-runner',
36
+ pageLocation: DEFAULT_ANALYTICA_PAGE_LOCATION,
37
+ },
38
+ });
39
+ });
40
+
41
+ test('Analytica event prefix and page location are configurable', async () => {
42
+ const requests = [];
43
+ const analytics = new AnalyticaEvents({
44
+ token: 'test-token',
45
+ source: 'runner',
46
+ eventPrefix: 'example/custom',
47
+ pageLocation: 'cluster://example',
48
+ fetchImpl: async (url, init) => {
49
+ requests.push({ url, init });
50
+ return new Response(null, { status: 204 });
51
+ },
52
+ });
53
+
54
+ await analytics.emit('flow', 'one');
55
+ await analytics.flush();
56
+ const payload = JSON.parse(requests[0].init.body);
57
+ assert.equal(payload.data.eventName, 'example/custom/flow/one');
58
+ assert.equal(payload.data.pageLocation, 'cluster://example');
59
+ });
60
+
61
+ test('Analytica delivery failures never fail a generation request', async () => {
62
+ const analytics = new AnalyticaEvents({
63
+ token: 'test-token',
64
+ source: 'runner',
65
+ fetchImpl: async () => {
66
+ throw new Error('collector unavailable');
67
+ },
68
+ });
69
+
70
+ assert.equal(await analytics.emit('http', 'jobs-create', 'received'), false);
71
+ await analytics.flush();
72
+ });
73
+
74
+ test('event name normalization never includes request-controlled path text', () => {
75
+ assert.equal(
76
+ analyticaEventName(
77
+ DEFAULT_ANALYTICA_EVENT_PREFIX,
78
+ 'http',
79
+ '../v1/jobs/private-id?token=secret',
80
+ 'responded',
81
+ 200,
82
+ ),
83
+ `${DEFAULT_ANALYTICA_EVENT_PREFIX}/http/v1-jobs-private-id-token-secret/responded/200`,
84
+ );
85
+ });
86
+
87
+ test('Analytica delivery limits concurrent requests and drains queued events', async () => {
88
+ const releases = [];
89
+ let active = 0;
90
+ let maximumActive = 0;
91
+ const analytics = new AnalyticaEvents({
92
+ token: 'test-token',
93
+ source: 'runner',
94
+ maxConcurrent: 2,
95
+ fetchImpl: async () => {
96
+ active += 1;
97
+ maximumActive = Math.max(maximumActive, active);
98
+ await new Promise((resolve) => releases.push(resolve));
99
+ active -= 1;
100
+ return new Response(null, { status: 204 });
101
+ },
102
+ });
103
+
104
+ const deliveries = [
105
+ analytics.emit('flow', 'one'),
106
+ analytics.emit('flow', 'two'),
107
+ analytics.emit('flow', 'three'),
108
+ ];
109
+ assert.equal(releases.length, 2);
110
+ releases.shift()();
111
+ await new Promise((resolve) => setImmediate(resolve));
112
+ assert.equal(releases.length, 2);
113
+ releases.shift()();
114
+ releases.shift()();
115
+ assert.deepEqual(await Promise.all(deliveries), [true, true, true]);
116
+ assert.equal(maximumActive, 2);
117
+ });
118
+
119
+ test('Analytica logs but safely drops collector delivery when its queue is full', async () => {
120
+ let release;
121
+ const analytics = new AnalyticaEvents({
122
+ token: 'test-token',
123
+ source: 'runner',
124
+ maxConcurrent: 1,
125
+ maxQueued: 0,
126
+ fetchImpl: async () => {
127
+ await new Promise((resolve) => {
128
+ release = resolve;
129
+ });
130
+ return new Response(null, { status: 204 });
131
+ },
132
+ });
133
+
134
+ const first = analytics.emit('flow', 'active');
135
+ assert.equal(await analytics.emit('flow', 'dropped'), false);
136
+ release();
137
+ assert.equal(await first, true);
138
+ });