@flink-app/flink 1.0.0 → 2.0.0-alpha.48

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 (109) hide show
  1. package/CHANGELOG.md +6 -0
  2. package/cli/build.ts +8 -1
  3. package/cli/run.ts +8 -1
  4. package/dist/cli/build.js +8 -1
  5. package/dist/cli/run.js +8 -1
  6. package/dist/src/FlinkApp.d.ts +33 -0
  7. package/dist/src/FlinkApp.js +247 -27
  8. package/dist/src/FlinkContext.d.ts +21 -0
  9. package/dist/src/FlinkHttpHandler.d.ts +90 -1
  10. package/dist/src/TypeScriptCompiler.d.ts +42 -0
  11. package/dist/src/TypeScriptCompiler.js +346 -4
  12. package/dist/src/TypeScriptUtils.js +4 -0
  13. package/dist/src/ai/AgentRunner.d.ts +39 -0
  14. package/dist/src/ai/AgentRunner.js +625 -0
  15. package/dist/src/ai/FlinkAgent.d.ts +446 -0
  16. package/dist/src/ai/FlinkAgent.js +633 -0
  17. package/dist/src/ai/FlinkTool.d.ts +37 -0
  18. package/dist/src/ai/FlinkTool.js +2 -0
  19. package/dist/src/ai/LLMAdapter.d.ts +119 -0
  20. package/dist/src/ai/LLMAdapter.js +2 -0
  21. package/dist/src/ai/SubAgentExecutor.d.ts +36 -0
  22. package/dist/src/ai/SubAgentExecutor.js +220 -0
  23. package/dist/src/ai/ToolExecutor.d.ts +35 -0
  24. package/dist/src/ai/ToolExecutor.js +237 -0
  25. package/dist/src/ai/index.d.ts +5 -0
  26. package/dist/src/ai/index.js +21 -0
  27. package/dist/src/handlers/StreamWriterFactory.d.ts +20 -0
  28. package/dist/src/handlers/StreamWriterFactory.js +83 -0
  29. package/dist/src/index.d.ts +4 -0
  30. package/dist/src/index.js +4 -0
  31. package/dist/src/utils.d.ts +30 -0
  32. package/dist/src/utils.js +52 -0
  33. package/package.json +14 -2
  34. package/readme.md +425 -0
  35. package/spec/AgentDuplicateDetection.spec.ts +112 -0
  36. package/spec/AgentRunner.spec.ts +527 -0
  37. package/spec/ConversationHooks.spec.ts +290 -0
  38. package/spec/FlinkAgent.spec.ts +310 -0
  39. package/spec/FlinkApp.onError.spec.ts +1 -2
  40. package/spec/StreamingIntegration.spec.ts +138 -0
  41. package/spec/SubAgentSupport.spec.ts +941 -0
  42. package/spec/ToolExecutor.spec.ts +360 -0
  43. package/spec/mock-project/dist/spec/mock-project/src/handlers/GetCar.js +57 -0
  44. package/spec/mock-project/dist/spec/mock-project/src/handlers/GetCar2.js +59 -0
  45. package/spec/mock-project/dist/spec/mock-project/src/handlers/GetCarWithArraySchema.js +53 -0
  46. package/spec/mock-project/dist/spec/mock-project/src/handlers/GetCarWithArraySchema2.js +53 -0
  47. package/spec/mock-project/dist/spec/mock-project/src/handlers/GetCarWithArraySchema3.js +53 -0
  48. package/spec/mock-project/dist/spec/mock-project/src/handlers/GetCarWithLiteralSchema.js +55 -0
  49. package/spec/mock-project/dist/spec/mock-project/src/handlers/GetCarWithLiteralSchema2.js +55 -0
  50. package/spec/mock-project/dist/spec/mock-project/src/handlers/GetCarWithSchemaInFile.js +58 -0
  51. package/spec/mock-project/dist/spec/mock-project/src/handlers/GetCarWithSchemaInFile2.js +58 -0
  52. package/spec/mock-project/dist/spec/mock-project/src/handlers/ManuallyAddedHandler.js +53 -0
  53. package/spec/mock-project/dist/spec/mock-project/src/handlers/ManuallyAddedHandler2.js +55 -0
  54. package/spec/mock-project/dist/spec/mock-project/src/handlers/PatchCar.js +58 -0
  55. package/spec/mock-project/dist/spec/mock-project/src/handlers/PatchOnboardingSession.js +76 -0
  56. package/spec/mock-project/dist/spec/mock-project/src/handlers/PatchOrderWithComplexTypes.js +58 -0
  57. package/spec/mock-project/dist/spec/mock-project/src/handlers/PatchProductWithIntersection.js +59 -0
  58. package/spec/mock-project/dist/spec/mock-project/src/handlers/PatchUserWithUnion.js +59 -0
  59. package/spec/mock-project/dist/spec/mock-project/src/handlers/PostCar.js +55 -0
  60. package/spec/mock-project/dist/spec/mock-project/src/handlers/PostLogin.js +56 -0
  61. package/spec/mock-project/dist/spec/mock-project/src/handlers/PostLogout.js +55 -0
  62. package/spec/mock-project/dist/spec/mock-project/src/handlers/PutCar.js +55 -0
  63. package/spec/mock-project/dist/spec/mock-project/src/index.js +83 -0
  64. package/spec/mock-project/dist/spec/mock-project/src/repos/CarRepo.js +26 -0
  65. package/spec/mock-project/dist/spec/mock-project/src/schemas/Car.js +2 -0
  66. package/spec/mock-project/dist/spec/mock-project/src/schemas/DefaultExportSchema.js +2 -0
  67. package/spec/mock-project/dist/spec/mock-project/src/schemas/FileWithTwoSchemas.js +2 -0
  68. package/spec/mock-project/dist/src/FlinkApp.js +1012 -0
  69. package/spec/mock-project/dist/src/FlinkContext.js +2 -0
  70. package/spec/mock-project/dist/src/FlinkErrors.js +143 -0
  71. package/spec/mock-project/dist/src/FlinkHttpHandler.js +47 -0
  72. package/spec/mock-project/dist/src/FlinkJob.js +2 -0
  73. package/spec/mock-project/dist/src/FlinkLog.js +26 -0
  74. package/spec/mock-project/dist/src/FlinkPlugin.js +2 -0
  75. package/spec/mock-project/dist/src/FlinkRepo.js +224 -0
  76. package/spec/mock-project/dist/src/FlinkResponse.js +2 -0
  77. package/spec/mock-project/dist/src/ai/AgentExecutor.js +279 -0
  78. package/spec/mock-project/dist/src/ai/AgentRunner.js +625 -0
  79. package/spec/mock-project/dist/src/ai/FlinkAgent.js +633 -0
  80. package/spec/mock-project/dist/src/ai/FlinkTool.js +2 -0
  81. package/spec/mock-project/dist/src/ai/LLMAdapter.js +2 -0
  82. package/spec/mock-project/dist/src/ai/SubAgentExecutor.js +220 -0
  83. package/spec/mock-project/dist/src/ai/ToolExecutor.js +237 -0
  84. package/spec/mock-project/dist/src/auth/FlinkAuthPlugin.js +2 -0
  85. package/spec/mock-project/dist/src/auth/FlinkAuthUser.js +2 -0
  86. package/spec/mock-project/dist/src/handlers/StreamWriterFactory.js +83 -0
  87. package/spec/mock-project/dist/src/index.js +17 -69
  88. package/spec/mock-project/dist/src/mock-data-generator.js +9 -0
  89. package/spec/mock-project/dist/src/utils.js +290 -0
  90. package/spec/mock-project/tsconfig.json +6 -1
  91. package/spec/testHelpers.ts +49 -0
  92. package/spec/utils.caseConversion.spec.ts +80 -0
  93. package/spec/utils.spec.ts +13 -13
  94. package/src/FlinkApp.ts +251 -7
  95. package/src/FlinkContext.ts +22 -0
  96. package/src/FlinkHttpHandler.ts +100 -2
  97. package/src/TypeScriptCompiler.ts +398 -7
  98. package/src/TypeScriptUtils.ts +5 -0
  99. package/src/ai/AgentRunner.ts +549 -0
  100. package/src/ai/FlinkAgent.ts +770 -0
  101. package/src/ai/FlinkTool.ts +40 -0
  102. package/src/ai/LLMAdapter.ts +96 -0
  103. package/src/ai/SubAgentExecutor.ts +199 -0
  104. package/src/ai/ToolExecutor.ts +193 -0
  105. package/src/ai/index.ts +5 -0
  106. package/src/handlers/StreamWriterFactory.ts +84 -0
  107. package/src/index.ts +4 -0
  108. package/src/utils.ts +52 -0
  109. package/tsconfig.json +6 -1
package/readme.md CHANGED
@@ -218,6 +218,431 @@ export const Props: RouteProps {
218
218
  }
219
219
  ```
220
220
 
221
+ ### AI Agents
222
+
223
+ Flink includes a comprehensive AI agents and tools framework for building LLM-powered applications with multi-agent orchestration, streaming responses, and type-safe tool execution.
224
+
225
+ Agents are placed in `src/agents/` folder and extend the `FlinkAgent` base class. They are automatically registered and available via the context.
226
+
227
+ #### Creating an Agent
228
+
229
+ Agents extend `FlinkAgent<Ctx>` and define their configuration as class properties:
230
+
231
+ ```typescript
232
+ // src/agents/CarAgent.ts
233
+ import { FlinkAgent } from "@flink-app/flink";
234
+ import AppCtx from "../AppCtx";
235
+ import GetCarsTool from "../tools/GetCarsTool";
236
+
237
+ export default class CarAgent extends FlinkAgent<AppCtx> {
238
+ id = "car-agent"; // Optional: defaults to "car-agent" (kebab-case class name)
239
+ description = "Expert in car models and specifications";
240
+ instructions = "You are a knowledgeable car expert...";
241
+ tools = [GetCarsTool]; // Tool class references
242
+
243
+ model = {
244
+ adapterId: "default", // References registered LLM adapter
245
+ maxTokens: 2000,
246
+ temperature: 0.7,
247
+ };
248
+
249
+ limits = {
250
+ maxSteps: 10, // Maximum execution steps
251
+ maxSubAgentDepth: 5, // Prevents infinite recursion (default: 5)
252
+ };
253
+
254
+ // Define domain-specific methods for better type safety
255
+ async searchByBrand(brand: string) {
256
+ const response = this.execute({
257
+ message: `Find all ${brand} cars`
258
+ });
259
+ return await response.result;
260
+ }
261
+ }
262
+ ```
263
+
264
+ #### Creating Tools
265
+
266
+ Tools define actions that agents can perform. They use Zod for type-safe input/output validation:
267
+
268
+ ```typescript
269
+ // src/tools/GetCarsTool.ts
270
+ import { FlinkTool, FlinkToolProps, ToolResult } from "@flink-app/flink";
271
+ import { z } from "zod";
272
+ import AppCtx from "../AppCtx";
273
+
274
+ export const Tool: FlinkToolProps = {
275
+ id: "get-cars-tool",
276
+ description: "Search for cars in inventory",
277
+ inputSchema: z.object({
278
+ brand: z.string().optional(),
279
+ maxPrice: z.number().optional(),
280
+ }),
281
+ };
282
+
283
+ const GetCarsTool: FlinkTool<AppCtx, z.infer<typeof Tool.inputSchema>> = async ({
284
+ input,
285
+ ctx
286
+ }) => {
287
+ const cars = await ctx.repos.carRepo.findAll({
288
+ brand: input.brand,
289
+ price: { $lte: input.maxPrice }
290
+ });
291
+
292
+ return { success: true, data: cars };
293
+ };
294
+
295
+ export default GetCarsTool;
296
+ ```
297
+
298
+ #### Multi-Agent Orchestration
299
+
300
+ Agents can delegate to specialized sub-agents using class references:
301
+
302
+ ```typescript
303
+ // src/agents/OrchestratorAgent.ts
304
+ import { FlinkAgent } from "@flink-app/flink";
305
+ import AppCtx from "../AppCtx";
306
+ import CarAgent from "./CarAgent";
307
+ import PricingAgent from "./PricingAgent";
308
+
309
+ export default class OrchestratorAgent extends FlinkAgent<AppCtx> {
310
+ description = "Coordinates between specialized agents";
311
+ instructions = "Route queries to appropriate specialists...";
312
+ tools = []; // No direct tools, only sub-agents
313
+ agents = [CarAgent, PricingAgent]; // Type-safe class references!
314
+
315
+ limits = {
316
+ maxSubAgentDepth: 3, // Prevent infinite delegation loops
317
+ };
318
+ }
319
+ ```
320
+
321
+ **Recursion Protection**: The `maxSubAgentDepth` limit (default: 5) prevents infinite loops when agents delegate to each other. If Agent A calls Agent B, which calls Agent A again, the framework will throw an error when the depth limit is exceeded, preventing stack overflow and infinite execution.
322
+
323
+ #### Using Agents in Handlers
324
+
325
+ Access agents through the context and optionally bind a user for permissions:
326
+
327
+ ```typescript
328
+ // src/handlers/car/PostCarQuery.ts
329
+ import { Handler } from "@flink-app/flink";
330
+ import AppCtx from "../../AppCtx";
331
+
332
+ const PostCarQuery: Handler<AppCtx, { question: string }, { answer: string }> = async ({
333
+ ctx,
334
+ req
335
+ }) => {
336
+ const result = await ctx.agents.carAgent
337
+ .withUser(req.user)
338
+ .searchByBrand(req.body.question);
339
+
340
+ return { data: { answer: result.message } };
341
+ };
342
+
343
+ export default PostCarQuery;
344
+ ```
345
+
346
+ #### Streaming Responses
347
+
348
+ Agents support real-time streaming for better UX:
349
+
350
+ ```typescript
351
+ // src/handlers/car/PostCarQueryStream.ts
352
+ import { Handler } from "@flink-app/flink";
353
+ import AppCtx from "../../AppCtx";
354
+
355
+ const PostCarQueryStream: Handler<AppCtx, { question: string }> = async ({
356
+ ctx,
357
+ req,
358
+ res
359
+ }) => {
360
+ const response = ctx.agents.carAgent.execute({
361
+ message: req.body.question
362
+ });
363
+
364
+ res.setHeader("Content-Type", "text/event-stream");
365
+
366
+ for await (const text of response.textStream) {
367
+ res.write(`data: ${text}\n\n`);
368
+ }
369
+
370
+ res.end();
371
+ };
372
+
373
+ export default PostCarQueryStream;
374
+ ```
375
+
376
+ #### AI Permissions
377
+
378
+ Flink provides a flexible permission system for agents and tools that integrates with authentication plugins. Starting in v0.15.0, auth plugins populate `req.userPermissions` with resolved permissions based on roles, dynamic roles, or custom logic.
379
+
380
+ **Three Permission Patterns:**
381
+
382
+ 1. **String-based**: Single required permission
383
+ 2. **Array-based**: Multiple required permissions (ALL required, AND logic)
384
+ 3. **Function-based**: Custom permission logic with async support
385
+
386
+ **Agent Permissions Example:**
387
+
388
+ ```typescript
389
+ export default class CarAgent extends FlinkAgent<AppCtx> {
390
+ description = "Expert in car models";
391
+ instructions = "You are a car expert...";
392
+ tools = ["get-cars-tool", "create-car-tool"];
393
+ permissions = "car:access"; // Single permission required
394
+ }
395
+ ```
396
+
397
+ **Tool Permissions Example:**
398
+
399
+ ```typescript
400
+ // String-based: Simple permission check
401
+ export const Tool: FlinkToolProps = {
402
+ id: "create-car",
403
+ description: "Create a new car",
404
+ permissions: "car:create", // User must have this permission
405
+ inputSchema: z.object({
406
+ brand: z.string(),
407
+ model: z.string(),
408
+ }),
409
+ };
410
+
411
+ // Array-based: Multiple permissions required (AND logic)
412
+ export const Tool: FlinkToolProps = {
413
+ id: "premium-report",
414
+ description: "Generate premium analytics",
415
+ permissions: ["car:read", "premium-features"], // ALL required
416
+ inputSchema: z.object({
417
+ carId: z.string(),
418
+ }),
419
+ };
420
+
421
+ // Function-based: Custom logic (async supported)
422
+ export const Tool: FlinkToolProps = {
423
+ id: "delete-car",
424
+ description: "Delete a car (admin or owner only)",
425
+ permissions: async (input, user) => {
426
+ // Admin can delete any car
427
+ if (user?.permissions?.includes("admin")) return true;
428
+
429
+ // Owner can delete their own car
430
+ const car = await carRepo.getById(input.carId);
431
+ return car.ownerId === user?.id;
432
+ },
433
+ inputSchema: z.object({
434
+ carId: z.string(),
435
+ }),
436
+ };
437
+ ```
438
+
439
+ **Using Permissions in Handlers:**
440
+
441
+ Auth plugins (like JWT Auth Plugin) populate `req.userPermissions` during authentication. Pass this to agents for automatic permission checks:
442
+
443
+ ```typescript
444
+ const handler: Handler<AppCtx, RequestBody, ResponseBody> = async ({ req, ctx }) => {
445
+ const agent = ctx.agents.carAgent
446
+ .withUser(req.user)
447
+ .withPermissions(req.userPermissions); // Pass resolved permissions
448
+
449
+ return await agent.execute({ message: req.body.message });
450
+ };
451
+ ```
452
+
453
+ **How `userPermissions` Works:**
454
+
455
+ Auth plugins automatically populate `req.userPermissions` based on their configuration:
456
+
457
+ ```typescript
458
+ // Strategy 1: Direct permissions from user object
459
+ jwtAuthPlugin({
460
+ secret: process.env.JWT_SECRET,
461
+ getUser: async (tokenData) => {
462
+ const user = await userRepo.getById(tokenData.userId);
463
+ return {
464
+ id: user.id,
465
+ permissions: user.permissions, // Direct permissions array
466
+ };
467
+ },
468
+ rolePermissions: {},
469
+ });
470
+ // Result: req.userPermissions = user.permissions
471
+
472
+ // Strategy 2: Static role mapping
473
+ jwtAuthPlugin({
474
+ secret: process.env.JWT_SECRET,
475
+ getUser: async (tokenData) => {
476
+ return { id: tokenData.userId };
477
+ },
478
+ rolePermissions: {
479
+ admin: ["car:read", "car:create", "car:delete"],
480
+ user: ["car:read"],
481
+ premium: ["car:read", "car:create", "premium-features"],
482
+ },
483
+ });
484
+ // JWT token has roles: ["user", "premium"]
485
+ // Result: req.userPermissions = ["car:read", "car:create", "premium-features"]
486
+
487
+ // Strategy 3: Dynamic roles (multi-tenant)
488
+ jwtAuthPlugin({
489
+ secret: process.env.JWT_SECRET,
490
+ useDynamicRoles: true, // Enable dynamic role resolution
491
+ getUser: async (tokenData, req) => {
492
+ const orgId = req.headers['x-organization-id'];
493
+ const membership = await orgMembershipRepo.getByUserAndOrg(
494
+ tokenData.userId,
495
+ orgId
496
+ );
497
+ return {
498
+ id: tokenData.userId,
499
+ roles: [membership.role], // Org-specific role
500
+ };
501
+ },
502
+ rolePermissions: {
503
+ admin: ["car:read", "car:create", "car:delete"],
504
+ user: ["car:read"],
505
+ },
506
+ });
507
+ // Same user, different orgs → different permissions
508
+ // Org A: admin role → ["car:read", "car:create", "car:delete"]
509
+ // Org B: user role → ["car:read"]
510
+ ```
511
+
512
+ **Permission Benefits:**
513
+
514
+ - ✅ **Performance**: Permissions computed once per request (not per tool use)
515
+ - ✅ **Tool Filtering**: LLM only sees tools user has access to (prevents hallucination)
516
+ - ✅ **Consistent**: Same permission logic across handlers, agents, and tools
517
+ - ✅ **Flexible**: String/array for simple cases, function for complex logic
518
+ - ✅ **Multi-tenant**: Dynamic roles support org-specific permissions
519
+
520
+ **Backward Compatibility:**
521
+
522
+ The system is fully backward compatible:
523
+ - If `req.userPermissions` is not set → falls back to `user.permissions`
524
+ - Function-based permissions continue to work as before
525
+ - No breaking changes to existing apps
526
+
527
+ #### Registering LLM Adapters
528
+
529
+ LLM adapters are configured in `FlinkOptions.ai.llms`:
530
+
531
+ ```typescript
532
+ // src/index.ts
533
+ import Anthropic from "@anthropic-ai/sdk";
534
+ import { FlinkApp } from "@flink-app/flink";
535
+ import { AnthropicAdapter } from "@flink-app/anthropic-adapter";
536
+ import AppCtx from "./AppCtx";
537
+
538
+ const app = new FlinkApp<AppCtx>({
539
+ name: "My App",
540
+ ai: {
541
+ llms: {
542
+ default: new AnthropicAdapter(
543
+ new Anthropic({ apiKey: process.env.ANTHROPIC_API_KEY! }),
544
+ "claude-3-5-sonnet-20241022"
545
+ ),
546
+ fast: new AnthropicAdapter(
547
+ new Anthropic({ apiKey: process.env.ANTHROPIC_API_KEY! }),
548
+ "claude-3-haiku-20240307"
549
+ ),
550
+ },
551
+ },
552
+ });
553
+
554
+ app.start();
555
+ ```
556
+
557
+ #### Custom LLM Adapters
558
+
559
+ Create custom adapters by implementing the `LLMAdapter` interface:
560
+
561
+ ```typescript
562
+ import { LLMAdapter, LLMStreamChunk } from "@flink-app/flink";
563
+
564
+ /**
565
+ * Custom LLM adapter implementation
566
+ *
567
+ * Note: LLM adapters are streaming-only. Flink's AgentRunner only calls stream().
568
+ * The streaming approach provides better UX (time-to-first-token <500ms) and
569
+ * FlinkAgent's lazy generator supports both streaming and non-streaming consumption.
570
+ */
571
+ export class CustomAdapter implements LLMAdapter {
572
+ constructor(private client: any, private model: string) {}
573
+
574
+ async *stream(params: {
575
+ instructions: string;
576
+ messages: any[];
577
+ tools: any[];
578
+ maxTokens: number;
579
+ temperature: number;
580
+ }): AsyncGenerator<LLMStreamChunk> {
581
+ const stream = await this.client.chat.completions.create({
582
+ model: this.model,
583
+ messages: [
584
+ { role: "system", content: params.instructions },
585
+ ...params.messages
586
+ ],
587
+ tools: params.tools,
588
+ max_tokens: params.maxTokens,
589
+ temperature: params.temperature,
590
+ stream: true,
591
+ });
592
+
593
+ let inputTokens = 0;
594
+ let outputTokens = 0;
595
+
596
+ for await (const chunk of stream) {
597
+ const delta = chunk.choices[0]?.delta;
598
+
599
+ // Stream text deltas
600
+ if (delta?.content) {
601
+ yield { type: "text", delta: delta.content };
602
+ }
603
+
604
+ // Stream tool calls
605
+ if (delta?.tool_calls) {
606
+ for (const toolCall of delta.tool_calls) {
607
+ yield {
608
+ type: "tool_call",
609
+ toolCall: {
610
+ id: toolCall.id,
611
+ name: toolCall.function.name,
612
+ input: JSON.parse(toolCall.function.arguments),
613
+ },
614
+ };
615
+ }
616
+ }
617
+
618
+ // Track usage
619
+ if (chunk.usage) {
620
+ inputTokens = chunk.usage.prompt_tokens;
621
+ outputTokens = chunk.usage.completion_tokens;
622
+ }
623
+ }
624
+
625
+ // Emit usage
626
+ yield {
627
+ type: "usage",
628
+ usage: { inputTokens, outputTokens },
629
+ };
630
+
631
+ // Emit done
632
+ yield {
633
+ type: "done",
634
+ stopReason: "end_turn",
635
+ };
636
+ }
637
+ }
638
+ ```
639
+
640
+ #### Available Packages
641
+
642
+ - `@flink-app/flink` - Core framework with AI agents & tools
643
+ - `@flink-app/anthropic-adapter` - Official Anthropic Claude adapter
644
+ - `@flink-app/fake-llm-adapter` - Deterministic testing adapter
645
+
221
646
  ## 🤕 Known issues
222
647
 
223
648
  - Current TypeScript compiler is slow when project gets larger
@@ -0,0 +1,112 @@
1
+ import { toKebabCase } from "../src/utils";
2
+
3
+ describe("Agent Duplicate Detection Logic", () => {
4
+ describe("Instance name collisions (camelCase)", () => {
5
+ // Instance names are derived by lowercasing first character
6
+ const getInstanceName = (className: string) => {
7
+ return className.charAt(0).toLowerCase() + className.substring(1);
8
+ };
9
+
10
+ it("should show CarAgent vs Caragent do NOT collide (different casing)", () => {
11
+ const instance1 = getInstanceName("CarAgent");
12
+ const instance2 = getInstanceName("Caragent");
13
+
14
+ // CarAgent → carAgent, Caragent → caragent (no collision)
15
+ expect(instance1).toBe("carAgent");
16
+ expect(instance2).toBe("caragent");
17
+ expect(instance1).not.toBe(instance2);
18
+ });
19
+
20
+ it("should detect collision example: CarAgent vs CARAgent", () => {
21
+ // This is a contrived example, but shows the logic
22
+ const instance1 = getInstanceName("CarAgent");
23
+ const instance2 = getInstanceName("CarAgent"); // Exact same name
24
+
25
+ // Both become "carAgent" - COLLISION!
26
+ expect(instance1).toBe("carAgent");
27
+ expect(instance2).toBe("carAgent");
28
+ expect(instance1).toBe(instance2);
29
+ });
30
+
31
+ it("should not collide for different class names", () => {
32
+ const instance1 = getInstanceName("CarAgent");
33
+ const instance2 = getInstanceName("BikeAgent");
34
+
35
+ expect(instance1).toBe("carAgent");
36
+ expect(instance2).toBe("bikeAgent");
37
+ expect(instance1).not.toBe(instance2);
38
+ });
39
+ });
40
+
41
+ describe("Agent ID collisions (kebab-case)", () => {
42
+ it("should detect APIAgent vs ApiAgent collision", () => {
43
+ const id1 = toKebabCase("APIAgent");
44
+ const id2 = toKebabCase("ApiAgent");
45
+
46
+ // Both become "api-agent" - COLLISION!
47
+ expect(id1).toBe("api-agent");
48
+ expect(id2).toBe("api-agent");
49
+ expect(id1).toBe(id2);
50
+ });
51
+
52
+ it("should detect HTMLParser vs HtmlParser collision", () => {
53
+ const id1 = toKebabCase("HTMLParser");
54
+ const id2 = toKebabCase("HtmlParser");
55
+
56
+ // Both become "html-parser" - COLLISION!
57
+ expect(id1).toBe("html-parser");
58
+ expect(id2).toBe("html-parser");
59
+ expect(id1).toBe(id2);
60
+ });
61
+
62
+ it("should detect XMLHttpRequest vs XmlHttpRequest collision", () => {
63
+ const id1 = toKebabCase("XMLHttpRequest");
64
+ const id2 = toKebabCase("XmlHttpRequest");
65
+
66
+ // Both become "xml-http-request" - COLLISION!
67
+ expect(id1).toBe("xml-http-request");
68
+ expect(id2).toBe("xml-http-request");
69
+ expect(id1).toBe(id2);
70
+ });
71
+
72
+ it("should not collide for different agent names", () => {
73
+ const id1 = toKebabCase("CarAgent");
74
+ const id2 = toKebabCase("BikeAgent");
75
+
76
+ expect(id1).toBe("car-agent");
77
+ expect(id2).toBe("bike-agent");
78
+ expect(id1).not.toBe(id2);
79
+ });
80
+
81
+ it("should not collide for similar but distinct names", () => {
82
+ const id1 = toKebabCase("CarAgent");
83
+ const id2 = toKebabCase("CarPricingAgent");
84
+
85
+ expect(id1).toBe("car-agent");
86
+ expect(id2).toBe("car-pricing-agent");
87
+ expect(id1).not.toBe(id2);
88
+ });
89
+ });
90
+
91
+ describe("Real-world collision scenarios", () => {
92
+ it("should show that explicit IDs prevent collisions", () => {
93
+ // Even if class names would collide, explicit IDs prevent issues
94
+ const explicitId1 = "api-agent-v1";
95
+ const explicitId2 = "api-agent-v2";
96
+
97
+ expect(explicitId1).not.toBe(explicitId2);
98
+ });
99
+
100
+ it("should demonstrate the collision resolution path", () => {
101
+ // Problem: Both APIAgent and ApiAgent → "api-agent"
102
+ const problematicId1 = toKebabCase("APIAgent");
103
+ const problematicId2 = toKebabCase("ApiAgent");
104
+ expect(problematicId1).toBe(problematicId2); // Collision!
105
+
106
+ // Solution: Use explicit IDs
107
+ const resolvedId1 = "api-agent-v1";
108
+ const resolvedId2 = "api-agent-v2";
109
+ expect(resolvedId1).not.toBe(resolvedId2); // No collision
110
+ });
111
+ });
112
+ });