@slates/client 1.0.0-rc.2

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.
@@ -0,0 +1,569 @@
1
+ import {
2
+ axios,
3
+ Slate,
4
+ SlateAuth,
5
+ SlateConfig,
6
+ type SlateLogEntry,
7
+ SlateSpecification,
8
+ SlateTool,
9
+ SlateTrigger
10
+ } from '@slates/provider';
11
+ import { rm } from 'fs/promises';
12
+ import { createServer } from 'http';
13
+ import type { AddressInfo } from 'net';
14
+ import { afterEach, describe, expect, it } from 'vitest';
15
+ import { z } from 'zod';
16
+ import { createLocalSlateTransport, createSlatesClient, SlateProtocolError } from './index';
17
+
18
+ let tempDirs: string[] = [];
19
+
20
+ let waitForLogs = async () => {
21
+ await new Promise(resolve => setTimeout(resolve, 25));
22
+ };
23
+
24
+ afterEach(async () => {
25
+ await Promise.all(tempDirs.splice(0).map(dir => rm(dir, { recursive: true, force: true })));
26
+ });
27
+
28
+ let createDemoSlate = () => {
29
+ let demoConfig = SlateConfig.create(
30
+ z.object({
31
+ prefix: z.string()
32
+ })
33
+ ).getDefaultConfig(() => ({
34
+ prefix: 'Hello'
35
+ }));
36
+
37
+ let demoAuth = SlateAuth.create<{ token: string }>()
38
+ .output(
39
+ z.object({
40
+ token: z.string()
41
+ })
42
+ )
43
+ .addTokenAuth({
44
+ type: 'auth.token',
45
+ key: 'token_auth',
46
+ name: 'Token Auth',
47
+ inputSchema: z.object({
48
+ token: z.string()
49
+ }),
50
+ getDefaultInput: async () => ({
51
+ token: 'default-token'
52
+ }),
53
+ onInputChanged: async (ctx: { newInput: { token: string } }) => ({
54
+ input: {
55
+ token: ctx.newInput.token.trim()
56
+ }
57
+ }),
58
+ getOutput: async (ctx: { input: { token: string } }) => ({
59
+ output: {
60
+ token: ctx.input.token
61
+ }
62
+ }),
63
+ getProfile: async (ctx: { output: { token: string } }) => ({
64
+ profile: {
65
+ tokenPreview: ctx.output.token.slice(0, 3)
66
+ }
67
+ })
68
+ });
69
+
70
+ let spec = SlateSpecification.create({
71
+ key: 'demo-slate',
72
+ name: 'Demo Slate',
73
+ description: 'A tiny test slate',
74
+ config: demoConfig,
75
+ auth: demoAuth
76
+ });
77
+
78
+ let echoTool = SlateTool.create(spec, {
79
+ key: 'echo',
80
+ name: 'Echo'
81
+ })
82
+ .input(
83
+ z.object({
84
+ name: z.string()
85
+ })
86
+ )
87
+ .output(
88
+ z.object({
89
+ greeting: z.string(),
90
+ token: z.string()
91
+ })
92
+ )
93
+ .scopes({
94
+ AND: [
95
+ {
96
+ OR: ['scope:echo', 'scope:echo:admin']
97
+ }
98
+ ]
99
+ })
100
+ .handleInvocation(async ctx => ({
101
+ output: {
102
+ greeting: `${ctx.config.prefix} ${ctx.input.name}`,
103
+ token: ctx.auth.token
104
+ },
105
+ message: 'done'
106
+ }))
107
+ .build();
108
+
109
+ return Slate.create({
110
+ spec,
111
+ tools: [echoTool],
112
+ triggers: []
113
+ });
114
+ };
115
+
116
+ let createTraceSlate = (baseUrl: string) => {
117
+ let config = SlateConfig.create(z.object({}));
118
+ let auth = SlateAuth.create<{}>().output(z.object({}));
119
+
120
+ let spec = SlateSpecification.create({
121
+ key: 'trace-slate',
122
+ name: 'Trace Slate',
123
+ description: 'A slate that traces HTTP requests',
124
+ config,
125
+ auth
126
+ });
127
+
128
+ let traceTool = SlateTool.create(spec, {
129
+ key: 'trace_http',
130
+ name: 'Trace HTTP'
131
+ })
132
+ .input(z.object({}))
133
+ .output(
134
+ z.object({
135
+ ok: z.boolean(),
136
+ status: z.number(),
137
+ traceCount: z.number()
138
+ })
139
+ )
140
+ .handleInvocation(async ctx => {
141
+ let response = await axios.post(
142
+ `${baseUrl}/trace?api_key=request-secret&visible=yes`,
143
+ {
144
+ message: 'hello',
145
+ secret: 'client-secret'
146
+ },
147
+ {
148
+ headers: {
149
+ Authorization: 'Bearer client-token',
150
+ 'X-Api-Key': 'client-api-key'
151
+ }
152
+ }
153
+ );
154
+
155
+ return {
156
+ output: {
157
+ ok: response.data.ok,
158
+ status: response.status,
159
+ traceCount: ctx.getHttpTraces().length
160
+ },
161
+ message: 'traced'
162
+ };
163
+ })
164
+ .build();
165
+
166
+ return Slate.create({
167
+ spec,
168
+ tools: [traceTool],
169
+ triggers: []
170
+ });
171
+ };
172
+
173
+ let createTriggerTraceSlate = () => {
174
+ let config = SlateConfig.create(z.object({}));
175
+ let auth = SlateAuth.create<{}>().output(z.object({}));
176
+
177
+ let spec = SlateSpecification.create({
178
+ key: 'trigger-trace-slate',
179
+ name: 'Trigger Trace Slate',
180
+ description: 'A slate that traces trigger execution',
181
+ config,
182
+ auth
183
+ });
184
+
185
+ let pollingTrigger = SlateTrigger.create(spec, {
186
+ key: 'poll_trigger',
187
+ name: 'Poll Trigger'
188
+ })
189
+ .input(
190
+ z.object({
191
+ id: z.string()
192
+ })
193
+ )
194
+ .output(
195
+ z.object({
196
+ type: z.string(),
197
+ value: z.string()
198
+ })
199
+ )
200
+ .polling({
201
+ pollEvents: async () => ({
202
+ inputs: [{ id: 'poll-1' }, { id: 'poll-2' }]
203
+ }),
204
+ handleEvent: async ctx => ({
205
+ id: ctx.input.id,
206
+ type: 'poll.event',
207
+ output: {
208
+ type: 'poll.event',
209
+ value: ctx.input.id
210
+ }
211
+ })
212
+ })
213
+ .build();
214
+
215
+ let webhookTrigger = SlateTrigger.create(spec, {
216
+ key: 'webhook_trigger',
217
+ name: 'Webhook Trigger'
218
+ })
219
+ .input(
220
+ z.object({
221
+ id: z.string()
222
+ })
223
+ )
224
+ .output(
225
+ z.object({
226
+ type: z.string(),
227
+ value: z.string()
228
+ })
229
+ )
230
+ .webhook({
231
+ handleRequest: async () => ({
232
+ inputs: [{ id: 'webhook-1' }, { id: 'webhook-2' }]
233
+ }),
234
+ handleEvent: async ctx => ({
235
+ id: ctx.input.id,
236
+ type: 'webhook.event',
237
+ output: {
238
+ type: 'webhook.event',
239
+ value: ctx.input.id
240
+ }
241
+ })
242
+ })
243
+ .build();
244
+
245
+ return Slate.create({
246
+ spec,
247
+ tools: [],
248
+ triggers: [pollingTrigger, webhookTrigger]
249
+ });
250
+ };
251
+
252
+ describe('@slates/client local transport', () => {
253
+ it('discovers auth/config and invokes tools with session state', async () => {
254
+ let slate = createDemoSlate();
255
+ let client = createSlatesClient({
256
+ transport: createLocalSlateTransport({ slate })
257
+ });
258
+
259
+ let provider = await client.identify();
260
+ expect(provider.provider.id).toBe('demo-slate');
261
+
262
+ let actions = await client.listTools();
263
+ expect(actions).toHaveLength(1);
264
+ expect(actions[0]!.id).toBe('echo');
265
+ expect(actions[0]!.scopes).toEqual({
266
+ AND: [
267
+ {
268
+ OR: ['scope:echo', 'scope:echo:admin']
269
+ }
270
+ ]
271
+ });
272
+
273
+ let configSchema = await client.getConfigSchema();
274
+ expect(configSchema.schema.properties.prefix.type).toBe('string');
275
+
276
+ let defaultConfig = await client.getDefaultConfig();
277
+ expect(defaultConfig.config).toEqual({ prefix: 'Hello' });
278
+
279
+ let authMethods = await client.listAuthMethods();
280
+ expect(authMethods.authenticationMethods).toHaveLength(1);
281
+ expect(authMethods.authenticationMethods[0]!.type).toBe('auth.token');
282
+
283
+ let defaultInput = await client.getDefaultAuthInput('token_auth');
284
+ expect(defaultInput.input).toEqual({ token: 'default-token' });
285
+
286
+ let changedInput = await client.updateAuthInput({
287
+ authenticationMethodId: 'token_auth',
288
+ previousInput: null,
289
+ newInput: { token: ' trimmed-token ' }
290
+ });
291
+ expect(changedInput.input).toEqual({ token: 'trimmed-token' });
292
+
293
+ let authOutput = await client.getAuthOutput({
294
+ authenticationMethodId: 'token_auth',
295
+ input: changedInput.input ?? { token: '' }
296
+ });
297
+ expect(authOutput.output).toEqual({ token: 'trimmed-token' });
298
+
299
+ client.setConfig({ prefix: 'Hi' });
300
+ client.setAuth({
301
+ authenticationMethodId: 'token_auth',
302
+ output: authOutput.output
303
+ });
304
+
305
+ let result = await client.invokeTool('echo', { name: 'Tobias' });
306
+ expect(result.output).toEqual({
307
+ greeting: 'Hi Tobias',
308
+ token: 'trimmed-token'
309
+ });
310
+ expect(client.state.session?.id).toBeTruthy();
311
+ });
312
+
313
+ it('emits structured traces for provider callbacks', async () => {
314
+ let slate = createDemoSlate();
315
+ let logs: SlateLogEntry[] = [];
316
+ let client = createSlatesClient({
317
+ transport: createLocalSlateTransport({
318
+ slate,
319
+ listeners: [
320
+ entries => {
321
+ logs.push(...entries);
322
+ }
323
+ ]
324
+ })
325
+ });
326
+
327
+ await client.identify();
328
+ await client.getDefaultConfig();
329
+ let changedInput = await client.updateAuthInput({
330
+ authenticationMethodId: 'token_auth',
331
+ previousInput: null,
332
+ newInput: { token: ' traced-token ' }
333
+ });
334
+ let authOutput = await client.getAuthOutput({
335
+ authenticationMethodId: 'token_auth',
336
+ input: changedInput.input ?? { token: '' }
337
+ });
338
+
339
+ client.setConfig({ prefix: 'Hi' });
340
+ client.setAuth({
341
+ authenticationMethodId: 'token_auth',
342
+ output: authOutput.output
343
+ });
344
+ await client.invokeTool('echo', { name: 'Tracing' });
345
+
346
+ await waitForLogs();
347
+
348
+ expect(logs).toEqual(
349
+ expect.arrayContaining([
350
+ expect.objectContaining({
351
+ type: 'info',
352
+ message: 'Getting default config',
353
+ data: expect.objectContaining({
354
+ providerId: 'demo-slate',
355
+ component: 'config',
356
+ functionName: 'getDefaultConfig',
357
+ phase: 'start'
358
+ })
359
+ }),
360
+ expect.objectContaining({
361
+ type: 'info',
362
+ message: 'Authentication input change handler completed',
363
+ data: expect.objectContaining({
364
+ providerId: 'demo-slate',
365
+ component: 'auth',
366
+ functionName: 'onInputChanged',
367
+ phase: 'success',
368
+ authenticationMethodId: 'token_auth',
369
+ returnedInput: true
370
+ })
371
+ }),
372
+ expect.objectContaining({
373
+ type: 'info',
374
+ message: 'Completed tool "Echo" (echo)',
375
+ data: expect.objectContaining({
376
+ providerId: 'demo-slate',
377
+ component: 'action',
378
+ functionName: 'handleInvocation',
379
+ phase: 'success',
380
+ actionId: 'echo',
381
+ hasMessage: true,
382
+ actionResultMessage: 'done'
383
+ })
384
+ })
385
+ ])
386
+ );
387
+ });
388
+
389
+ it('emits human-readable trigger event count logs', async () => {
390
+ let logs: SlateLogEntry[] = [];
391
+ let client = createSlatesClient({
392
+ transport: createLocalSlateTransport({
393
+ slate: createTriggerTraceSlate(),
394
+ listeners: [
395
+ entries => {
396
+ logs.push(...entries);
397
+ }
398
+ ]
399
+ }),
400
+ state: {
401
+ config: {}
402
+ }
403
+ });
404
+
405
+ client.ensureSession();
406
+
407
+ await client.request('slates/action.trigger.poll_events', {
408
+ actionId: 'poll_trigger',
409
+ state: null
410
+ });
411
+
412
+ await client.request('slates/action.trigger.webhook_handle', {
413
+ actionId: 'webhook_trigger',
414
+ url: 'https://example.com/webhook',
415
+ method: 'POST',
416
+ headers: {
417
+ 'content-type': 'application/json'
418
+ },
419
+ body: null,
420
+ state: null
421
+ });
422
+
423
+ await waitForLogs();
424
+
425
+ expect(logs).toEqual(
426
+ expect.arrayContaining([
427
+ expect.objectContaining({
428
+ type: 'info',
429
+ message: 'Polled 2 event(s) for trigger "Poll Trigger" (poll_trigger)',
430
+ data: expect.objectContaining({
431
+ component: 'action',
432
+ functionName: 'pollEvents',
433
+ inputCount: 2,
434
+ actionId: 'poll_trigger'
435
+ })
436
+ }),
437
+ expect.objectContaining({
438
+ type: 'info',
439
+ message:
440
+ 'Received 2 webhook event(s) for trigger "Webhook Trigger" (webhook_trigger)',
441
+ data: expect.objectContaining({
442
+ component: 'action',
443
+ functionName: 'handleRequest',
444
+ inputCount: 2,
445
+ actionId: 'webhook_trigger'
446
+ })
447
+ })
448
+ ])
449
+ );
450
+ });
451
+
452
+ it('throws structured protocol errors for provider responses', async () => {
453
+ let client = createSlatesClient({
454
+ transport: {
455
+ async send(messages) {
456
+ let request = messages.find(message => 'id' in message && message.id);
457
+ return [
458
+ {
459
+ jsonrpc: '2.0',
460
+ id: (request as { id: string }).id,
461
+ error: {
462
+ code: 'resource.not_found',
463
+ kind: 'upstream',
464
+ message: 'Resource contact_123 was not found',
465
+ status: 404,
466
+ provider: {
467
+ service: 'demo',
468
+ operation: 'failing.invoke'
469
+ },
470
+ baggage: {
471
+ resourceId: 'contact_123'
472
+ }
473
+ }
474
+ } as any
475
+ ];
476
+ }
477
+ }
478
+ });
479
+
480
+ let promise = client.identify();
481
+
482
+ await expect(promise).rejects.toBeInstanceOf(SlateProtocolError);
483
+
484
+ try {
485
+ await promise;
486
+ } catch (error) {
487
+ expect(error).toBeInstanceOf(SlateProtocolError);
488
+ expect((error as SlateProtocolError).data).toMatchObject({
489
+ code: 'resource.not_found',
490
+ kind: 'upstream',
491
+ message: 'Resource contact_123 was not found',
492
+ status: 404,
493
+ provider: {
494
+ service: 'demo',
495
+ operation: 'failing.invoke'
496
+ },
497
+ baggage: {
498
+ resourceId: 'contact_123'
499
+ }
500
+ });
501
+ }
502
+ });
503
+
504
+ it('returns sanitized request traces for shared axios calls', async () => {
505
+ let server = createServer((_req, res) => {
506
+ res.statusCode = 200;
507
+ res.setHeader('Content-Type', 'application/json');
508
+ res.setHeader('X-Request-Id', 'req-123');
509
+ res.end(
510
+ JSON.stringify({
511
+ ok: true,
512
+ token: 'server-secret',
513
+ note: 'x'.repeat(11_000)
514
+ })
515
+ );
516
+ });
517
+
518
+ await new Promise<void>((resolve, reject) => {
519
+ server.once('error', reject);
520
+ server.listen(0, '127.0.0.1', () => resolve());
521
+ });
522
+
523
+ try {
524
+ let { port } = server.address() as AddressInfo;
525
+ let client = createSlatesClient({
526
+ transport: createLocalSlateTransport({
527
+ slate: createTraceSlate(`http://127.0.0.1:${port}`)
528
+ }),
529
+ state: {
530
+ config: {}
531
+ }
532
+ });
533
+
534
+ let result = await client.invokeTool('trace_http', {});
535
+
536
+ expect(result.output).toEqual({
537
+ ok: true,
538
+ status: 200,
539
+ traceCount: 1
540
+ });
541
+ expect(result.requestTraces).toHaveLength(1);
542
+
543
+ let trace = result.requestTraces?.[0];
544
+ expect(trace?.request.method).toBe('POST');
545
+ expect(trace?.request.url).toContain('visible=yes');
546
+ expect(trace?.request.url).not.toContain('request-secret');
547
+ expect(trace?.request.headers).toMatchObject({
548
+ accept: 'application/json, text/plain, */*',
549
+ 'user-agent': 'slates.dev@1.0.0/trace-slate',
550
+ 'x-slates-provider': 'trace-slate'
551
+ });
552
+ expect(trace?.request.headers).not.toHaveProperty('authorization');
553
+ expect(trace?.request.headers).not.toHaveProperty('x-api-key');
554
+ expect(trace?.request.body?.text).toContain('"secret":"[redacted]"');
555
+ expect(trace?.request.body?.text).not.toContain('client-secret');
556
+ expect(trace?.response?.headers).toMatchObject({
557
+ 'content-type': 'application/json',
558
+ 'x-request-id': 'req-123'
559
+ });
560
+ expect(trace?.response?.body?.truncated).toBe(true);
561
+ expect(trace?.response?.body?.text).toContain('"token":"[redacted]"');
562
+ expect(trace?.response?.body?.text).not.toContain('server-secret');
563
+ } finally {
564
+ await new Promise<void>((resolve, reject) => {
565
+ server.close(error => (error ? reject(error) : resolve()));
566
+ });
567
+ }
568
+ });
569
+ });