@kognitivedev/vercel-ai-provider 0.1.8 → 0.2.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/dist/index.js CHANGED
@@ -11,14 +11,31 @@ var __rest = (this && this.__rest) || function (s, e) {
11
11
  return t;
12
12
  };
13
13
  Object.defineProperty(exports, "__esModule", { value: true });
14
+ exports.renderTemplate = void 0;
14
15
  exports.createCognitiveLayer = createCognitiveLayer;
15
16
  const ai_1 = require("ai");
17
+ const crypto_1 = require("crypto");
18
+ var template_1 = require("./template");
19
+ Object.defineProperty(exports, "renderTemplate", { enumerable: true, get: function () { return template_1.renderTemplate; } });
20
+ const template_2 = require("./template");
16
21
  function isValidId(value) {
17
22
  if (value == null || typeof value !== "string")
18
23
  return false;
19
24
  const trimmed = value.trim();
20
25
  return trimmed !== "" && trimmed !== "null" && trimmed !== "undefined";
21
26
  }
27
+ function maskSecret(secret) {
28
+ if (!secret)
29
+ return "missing";
30
+ if (secret.length <= 8)
31
+ return `${secret.slice(0, 2)}***`;
32
+ return `${secret.slice(0, 4)}...${secret.slice(-4)}`;
33
+ }
34
+ function previewText(value, maxLength = 240) {
35
+ if (value.length <= maxLength)
36
+ return value;
37
+ return `${value.slice(0, maxLength)}...`;
38
+ }
22
39
  const LOG_LEVEL_PRIORITY = {
23
40
  none: 0,
24
41
  error: 1,
@@ -55,12 +72,91 @@ function createLogger(logLevel) {
55
72
  };
56
73
  }
57
74
  const PROMPT_CACHE_TTL_MS = 60000; // 1 minute
75
+ function getContentText(content) {
76
+ if (typeof content === "string")
77
+ return content;
78
+ if (!Array.isArray(content))
79
+ return "";
80
+ return content.map((part) => {
81
+ if (!part || typeof part !== "object")
82
+ return "";
83
+ if (typeof part.text === "string")
84
+ return part.text;
85
+ if (part.type === "tool-call" && typeof part.toolName === "string")
86
+ return `Called ${part.toolName}`;
87
+ if (part.type === "tool-result")
88
+ return "Received tool result";
89
+ return "";
90
+ }).filter(Boolean).join(" ");
91
+ }
58
92
  /**
59
- * Interpolate {{variable}} placeholders in a template string.
60
- * Unmatched variables are left as-is.
93
+ * Unwraps V2/V3 ToolResultOutput discriminated union to a displayable value.
94
+ * Stream ToolResult uses plain `result` (passthrough), while prompt ToolResultPart
95
+ * uses `output` with a discriminated union: text, json, error-text, error-json, content, execution-denied.
61
96
  */
62
- function interpolateTemplate(content, variables) {
63
- return content.replace(/\{\{(\w+)\}\}/g, (_, key) => { var _a; return (_a = variables[key]) !== null && _a !== void 0 ? _a : `{{${key}}}`; });
97
+ function extractOutputValue(raw) {
98
+ var _a;
99
+ if (raw == null)
100
+ return raw;
101
+ if (typeof raw !== 'object')
102
+ return raw;
103
+ const obj = raw;
104
+ if (typeof obj.type !== 'string')
105
+ return raw;
106
+ switch (obj.type) {
107
+ case 'text':
108
+ case 'json':
109
+ case 'error-text':
110
+ case 'error-json':
111
+ case 'content':
112
+ return obj.value;
113
+ case 'execution-denied':
114
+ return `Execution denied: ${(_a = obj.reason) !== null && _a !== void 0 ? _a : 'unknown'}`;
115
+ default:
116
+ return raw;
117
+ }
118
+ }
119
+ function buildTracePreviews(messages) {
120
+ const request = [...messages].reverse().find((message) => (message === null || message === void 0 ? void 0 : message.role) === "user");
121
+ const response = [...messages].reverse().find((message) => (message === null || message === void 0 ? void 0 : message.role) === "assistant");
122
+ return {
123
+ requestPreview: request ? getContentText(request.content).slice(0, 220) : "No request captured",
124
+ responsePreview: response ? getContentText(response.content).slice(0, 240) : "No response captured",
125
+ };
126
+ }
127
+ function buildTraceSpansFromMessages(messages) {
128
+ var _a, _b;
129
+ const resultMap = new Map();
130
+ for (const message of messages) {
131
+ if (!Array.isArray(message === null || message === void 0 ? void 0 : message.content))
132
+ continue;
133
+ for (const part of message.content) {
134
+ if ((part === null || part === void 0 ? void 0 : part.type) === "tool-result" && typeof part.toolCallId === "string") {
135
+ resultMap.set(part.toolCallId, (_a = part.result) !== null && _a !== void 0 ? _a : part.output);
136
+ }
137
+ }
138
+ }
139
+ const spans = [];
140
+ for (const message of messages) {
141
+ if (!Array.isArray(message === null || message === void 0 ? void 0 : message.content))
142
+ continue;
143
+ for (const part of message.content) {
144
+ if ((part === null || part === void 0 ? void 0 : part.type) === "tool-call" && typeof part.toolCallId === "string") {
145
+ const result = resultMap.get(part.toolCallId);
146
+ spans.push({
147
+ spanKey: part.toolCallId,
148
+ parentSpanKey: "root",
149
+ name: typeof part.toolName === "string" ? part.toolName : "tool",
150
+ spanType: "tool",
151
+ status: "completed",
152
+ inputPreview: JSON.stringify((_b = part.input) !== null && _b !== void 0 ? _b : {}).slice(0, 220),
153
+ outputPreview: result != null ? JSON.stringify(extractOutputValue(result)).slice(0, 220) : "No tool result captured",
154
+ toolName: typeof part.toolName === "string" ? part.toolName : undefined,
155
+ });
156
+ }
157
+ }
158
+ }
159
+ return spans;
64
160
  }
65
161
  // Session-scoped snapshot cache: sessionKey → formatted memory block
66
162
  const sessionSnapshots = new Map();
@@ -101,6 +197,7 @@ function createCognitiveLayer(config) {
101
197
  // Prompt cache: slug → CachedPrompt
102
198
  const promptCache = new Map();
103
199
  const resolvePrompt = async (slug, userId) => {
200
+ var _a;
104
201
  const cacheKey = userId ? `${slug}:${userId}` : slug;
105
202
  const cached = promptCache.get(cacheKey);
106
203
  if (cached && Date.now() - cached.fetchedAt < PROMPT_CACHE_TTL_MS) {
@@ -111,11 +208,31 @@ function createCognitiveLayer(config) {
111
208
  url.searchParams.set("slug", slug);
112
209
  if (userId)
113
210
  url.searchParams.set("userId", userId);
211
+ logger.debug("Resolving prompt from backend", {
212
+ slug,
213
+ userId,
214
+ url: url.toString(),
215
+ baseUrl,
216
+ apiKeyHint: maskSecret(clConfig.apiKey),
217
+ });
114
218
  const res = await fetch(url.toString(), {
115
219
  headers: { "Authorization": `Bearer ${clConfig.apiKey}` },
116
220
  });
221
+ logger.debug("Prompt resolve response received", {
222
+ slug,
223
+ userId,
224
+ status: res.status,
225
+ ok: res.ok,
226
+ contentType: res.headers.get("content-type"),
227
+ });
117
228
  if (!res.ok) {
118
229
  const body = await res.text();
230
+ logger.debug("Prompt resolve response body preview", {
231
+ slug,
232
+ userId,
233
+ status: res.status,
234
+ bodyPreview: previewText(body),
235
+ });
119
236
  throw new Error(`Failed to resolve prompt "${slug}": ${res.status} ${body}`);
120
237
  }
121
238
  const data = await res.json();
@@ -128,6 +245,14 @@ function createCognitiveLayer(config) {
128
245
  gatewaySlug: data.gatewaySlug,
129
246
  };
130
247
  promptCache.set(cacheKey, entry);
248
+ logger.debug("Prompt resolved payload", {
249
+ slug,
250
+ resolvedSlug: entry.slug,
251
+ version: entry.version,
252
+ promptId: entry.promptId,
253
+ contentLength: entry.content.length,
254
+ gatewaySlug: (_a = entry.gatewaySlug) !== null && _a !== void 0 ? _a : null,
255
+ });
131
256
  logger.info("Prompt resolved", { slug, version: entry.version });
132
257
  return entry;
133
258
  };
@@ -194,9 +319,25 @@ function createCognitiveLayer(config) {
194
319
  if (systemPromptToAdd === undefined) {
195
320
  try {
196
321
  const url = `${baseUrl}/api/cognitive/snapshot?userId=${userId}`;
322
+ logger.debug("Fetching snapshot from backend", {
323
+ userId,
324
+ projectId,
325
+ sessionId,
326
+ url,
327
+ baseUrl,
328
+ apiKeyHint: maskSecret(clConfig.apiKey),
329
+ });
197
330
  const res = await fetch(url, {
198
331
  headers: { "Authorization": `Bearer ${clConfig.apiKey}` },
199
332
  });
333
+ logger.debug("Snapshot response received", {
334
+ userId,
335
+ projectId,
336
+ sessionId,
337
+ status: res.status,
338
+ ok: res.ok,
339
+ contentType: res.headers.get("content-type"),
340
+ });
200
341
  if (res.ok) {
201
342
  const data = await res.json();
202
343
  const systemBlock = data.systemBlock || "";
@@ -229,7 +370,15 @@ ${userContextBlock || "None"}
229
370
  });
230
371
  }
231
372
  else {
373
+ const body = await res.text();
232
374
  logger.warn("Snapshot fetch failed", { status: res.status });
375
+ logger.debug("Snapshot response body preview", {
376
+ userId,
377
+ projectId,
378
+ sessionId,
379
+ status: res.status,
380
+ bodyPreview: previewText(body),
381
+ });
233
382
  systemPromptToAdd = "";
234
383
  sessionSnapshots.set(sessionKey, systemPromptToAdd);
235
384
  }
@@ -255,7 +404,8 @@ ${userContextBlock || "None"}
255
404
  return Object.assign(Object.assign({}, nextParams), { prompt: messagesWithMemory });
256
405
  },
257
406
  async wrapGenerate({ doGenerate, params }) {
258
- var _a, _b;
407
+ var _a;
408
+ const startedAt = new Date();
259
409
  let result;
260
410
  try {
261
411
  result = await doGenerate();
@@ -266,28 +416,57 @@ ${userContextBlock || "None"}
266
416
  throw err;
267
417
  }
268
418
  if (isValidId(userId) && isValidId(sessionId)) {
419
+ const endedAt = new Date();
269
420
  const sessionKey = `${userId}:${projectId}:${sessionId}`;
270
421
  const promptMeta = sessionPromptMetadata.get(sessionKey);
271
- const messagesInput = params.messages || params.prompt || [];
272
- const resultMessages = (_b = result === null || result === void 0 ? void 0 : result.response) === null || _b === void 0 ? void 0 : _b.messages;
273
- const assistantMessage = (result === null || result === void 0 ? void 0 : result.text)
274
- ? [{ role: "assistant", content: [{ type: "text", text: result.text }] }]
422
+ const messagesInput = params.prompt || params.messages || [];
423
+ // Build assistant message from result.content (V2/V3 GenerateResult)
424
+ const resultContent = Array.isArray(result === null || result === void 0 ? void 0 : result.content) ? result.content : [];
425
+ const assistantParts = [];
426
+ for (const part of resultContent) {
427
+ if ((part === null || part === void 0 ? void 0 : part.type) === 'text') {
428
+ assistantParts.push({ type: 'text', text: part.text });
429
+ }
430
+ else if ((part === null || part === void 0 ? void 0 : part.type) === 'tool-call') {
431
+ assistantParts.push({
432
+ type: 'tool-call',
433
+ toolCallId: part.toolCallId,
434
+ toolName: part.toolName,
435
+ input: part.input,
436
+ });
437
+ }
438
+ else if ((part === null || part === void 0 ? void 0 : part.type) === 'tool-result') {
439
+ assistantParts.push({
440
+ type: 'tool-result',
441
+ toolCallId: part.toolCallId,
442
+ toolName: part.toolName,
443
+ result: part.result,
444
+ });
445
+ }
446
+ }
447
+ const assistantMessage = assistantParts.length > 0
448
+ ? [{ role: "assistant", content: assistantParts }]
275
449
  : [];
276
- const finalMessages = Array.isArray(resultMessages) && resultMessages.length > 0
277
- ? resultMessages
278
- : [...messagesInput, ...assistantMessage];
279
- logConversation(Object.assign({ userId,
450
+ const finalMessages = [...messagesInput, ...assistantMessage];
451
+ const { requestPreview, responsePreview } = buildTracePreviews(finalMessages);
452
+ const spans = buildTraceSpansFromMessages(finalMessages);
453
+ logConversation(Object.assign(Object.assign({ userId,
280
454
  projectId,
281
455
  sessionId, messages: finalMessages, modelId, usage: result.usage }, (promptMeta && {
282
456
  promptSlug: promptMeta.promptSlug,
283
457
  promptVersion: promptMeta.promptVersion,
284
458
  promptId: promptMeta.promptId,
285
- }))).then(() => triggerProcessing(userId, projectId, sessionId));
459
+ })), { traceId: (0, crypto_1.randomUUID)(), requestPreview,
460
+ responsePreview, state: "completed", startedAt: startedAt.toISOString(), endedAt: endedAt.toISOString(), durationMs: endedAt.getTime() - startedAt.getTime(), metadata: {
461
+ appId: clConfig.appId,
462
+ }, spans })).then(() => triggerProcessing(userId, projectId, sessionId));
286
463
  }
287
464
  return result;
288
465
  },
289
466
  async wrapStream({ doStream, params }) {
290
467
  var _a;
468
+ const startedAt = new Date();
469
+ const traceId = (0, crypto_1.randomUUID)();
291
470
  let result;
292
471
  try {
293
472
  logger.debug("Starting doStream with params", JSON.stringify(params, null, 2));
@@ -302,13 +481,16 @@ ${userContextBlock || "None"}
302
481
  if (isValidId(userId) && isValidId(sessionId)) {
303
482
  const sessionKey = `${userId}:${projectId}:${sessionId}`;
304
483
  const promptMeta = sessionPromptMetadata.get(sessionKey);
305
- const messagesInput = params.messages || params.prompt || [];
484
+ const messagesInput = params.prompt || params.messages || [];
306
485
  const resultMessages = (_a = result === null || result === void 0 ? void 0 : result.response) === null || _a === void 0 ? void 0 : _a.messages;
307
486
  const finalMessages = Array.isArray(resultMessages) && resultMessages.length > 0
308
487
  ? resultMessages
309
488
  : messagesInput;
310
489
  let streamUsage;
311
490
  let accumulatedText = '';
491
+ const toolCallInputs = new Map();
492
+ const completedToolCalls = [];
493
+ const completedToolResults = [];
312
494
  const originalStream = result.stream;
313
495
  const transformStream = new TransformStream({
314
496
  transform(chunk, controller) {
@@ -318,19 +500,72 @@ ${userContextBlock || "None"}
318
500
  if (chunk.type === 'finish' && chunk.usage) {
319
501
  streamUsage = chunk.usage;
320
502
  }
503
+ // Capture tool-call stream chunks (V2/V3 shared types)
504
+ if (chunk.type === 'tool-input-start') {
505
+ toolCallInputs.set(chunk.id, { toolName: chunk.toolName, chunks: [] });
506
+ }
507
+ if (chunk.type === 'tool-input-delta') {
508
+ const entry = toolCallInputs.get(chunk.id);
509
+ if (entry)
510
+ entry.chunks.push(chunk.delta);
511
+ }
512
+ if (chunk.type === 'tool-call') {
513
+ completedToolCalls.push({
514
+ type: 'tool-call',
515
+ toolCallId: chunk.toolCallId,
516
+ toolName: chunk.toolName,
517
+ input: chunk.input,
518
+ });
519
+ }
520
+ if (chunk.type === 'tool-result') {
521
+ completedToolResults.push({
522
+ type: 'tool-result',
523
+ toolCallId: chunk.toolCallId,
524
+ toolName: chunk.toolName,
525
+ result: chunk.result,
526
+ });
527
+ }
321
528
  controller.enqueue(chunk);
322
529
  },
323
- flush() {
324
- const allMessages = accumulatedText
325
- ? [...finalMessages, { role: "assistant", content: [{ type: "text", text: accumulatedText }] }]
530
+ async flush() {
531
+ const endedAt = new Date();
532
+ // Finalize any tool calls from incremental input chunks
533
+ for (const [id, entry] of toolCallInputs) {
534
+ // Only add if not already captured via a tool-call chunk
535
+ if (!completedToolCalls.some((tc) => tc.toolCallId === id)) {
536
+ completedToolCalls.push({
537
+ type: 'tool-call',
538
+ toolCallId: id,
539
+ toolName: entry.toolName,
540
+ input: entry.chunks.join(''),
541
+ });
542
+ }
543
+ }
544
+ const assistantParts = [];
545
+ if (accumulatedText)
546
+ assistantParts.push({ type: "text", text: accumulatedText });
547
+ for (const tc of completedToolCalls)
548
+ assistantParts.push(tc);
549
+ const allMessages = assistantParts.length > 0
550
+ ? [...finalMessages, { role: "assistant", content: assistantParts }]
326
551
  : finalMessages;
327
- logConversation(Object.assign({ userId,
552
+ if (completedToolResults.length > 0) {
553
+ allMessages.push({ role: "tool", content: completedToolResults });
554
+ }
555
+ const { requestPreview, responsePreview } = buildTracePreviews(allMessages);
556
+ const spans = buildTraceSpansFromMessages(allMessages);
557
+ await logConversation(Object.assign(Object.assign({ userId,
328
558
  projectId,
329
559
  sessionId, messages: allMessages, modelId, usage: streamUsage }, (promptMeta && {
330
560
  promptSlug: promptMeta.promptSlug,
331
561
  promptVersion: promptMeta.promptVersion,
332
562
  promptId: promptMeta.promptId,
333
- }))).then(() => triggerProcessing(userId, projectId, sessionId));
563
+ })), { traceId,
564
+ requestPreview,
565
+ responsePreview, state: "completed", startedAt: startedAt.toISOString(), endedAt: endedAt.toISOString(), durationMs: endedAt.getTime() - startedAt.getTime(), metadata: {
566
+ appId: clConfig.appId,
567
+ }, spans }));
568
+ triggerProcessing(userId, projectId, sessionId);
334
569
  }
335
570
  });
336
571
  result.stream = originalStream.pipeThrough(transformStream);
@@ -402,7 +637,7 @@ ${userContextBlock || "None"}
402
637
  let system;
403
638
  if (resolved) {
404
639
  system = promptConfig.variables
405
- ? interpolateTemplate(resolved.content, promptConfig.variables)
640
+ ? (0, template_2.renderTemplate)(resolved.content, promptConfig.variables)
406
641
  : resolved.content;
407
642
  // Store prompt metadata for the session (read by middleware during logging)
408
643
  if (session === null || session === void 0 ? void 0 : session.sessionId) {
@@ -441,7 +676,7 @@ ${userContextBlock || "None"}
441
676
  let system;
442
677
  if (resolved) {
443
678
  system = promptConfig.variables
444
- ? interpolateTemplate(resolved.content, promptConfig.variables)
679
+ ? (0, template_2.renderTemplate)(resolved.content, promptConfig.variables)
445
680
  : resolved.content;
446
681
  // Store prompt metadata for the session (read by middleware during logging)
447
682
  if (session === null || session === void 0 ? void 0 : session.sessionId) {
@@ -0,0 +1,2 @@
1
+ export type TemplateVariables = Record<string, string | boolean>;
2
+ export declare function renderTemplate(template: string, variables: TemplateVariables): string;
@@ -0,0 +1,13 @@
1
+ "use strict";
2
+ var __importDefault = (this && this.__importDefault) || function (mod) {
3
+ return (mod && mod.__esModule) ? mod : { "default": mod };
4
+ };
5
+ Object.defineProperty(exports, "__esModule", { value: true });
6
+ exports.renderTemplate = renderTemplate;
7
+ // Use the pre-built dist to avoid `require.extensions` warning in webpack/Next.js
8
+ // eslint-disable-next-line @typescript-eslint/no-require-imports
9
+ const handlebars_1 = __importDefault(require("handlebars/dist/cjs/handlebars"));
10
+ function renderTemplate(template, variables) {
11
+ const compiled = handlebars_1.default.compile(template, { noEscape: true });
12
+ return compiled(variables);
13
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@kognitivedev/vercel-ai-provider",
3
- "version": "0.1.8",
3
+ "version": "0.2.0",
4
4
  "main": "dist/index.js",
5
5
  "types": "dist/index.d.ts",
6
6
  "publishConfig": {
@@ -12,6 +12,9 @@
12
12
  "test": "vitest run",
13
13
  "prepublishOnly": "npm run build"
14
14
  },
15
+ "dependencies": {
16
+ "handlebars": "^4.7.8"
17
+ },
15
18
  "peerDependencies": {
16
19
  "ai": "^5.0.0 || ^6.0.0"
17
20
  },
@@ -0,0 +1,161 @@
1
+ import { describe, it, expect } from "vitest";
2
+ import { renderTemplate } from "../template";
3
+
4
+ describe("renderTemplate", () => {
5
+ describe("variable interpolation", () => {
6
+ it("replaces a single variable", () => {
7
+ expect(renderTemplate("Hello {{name}}", { name: "Alice" })).toBe("Hello Alice");
8
+ });
9
+
10
+ it("replaces multiple variables", () => {
11
+ expect(
12
+ renderTemplate("{{greeting}}, {{name}}!", { greeting: "Hi", name: "Bob" })
13
+ ).toBe("Hi, Bob!");
14
+ });
15
+
16
+ it("renders undefined variables as empty string", () => {
17
+ expect(renderTemplate("Hello {{name}}", {})).toBe("Hello ");
18
+ });
19
+
20
+ it("does not HTML-escape content", () => {
21
+ expect(renderTemplate("{{content}}", { content: "<b>bold</b> & \"quoted\"" })).toBe(
22
+ "<b>bold</b> & \"quoted\""
23
+ );
24
+ });
25
+ });
26
+
27
+ describe("{{#if}} / {{/if}}", () => {
28
+ it("includes block when variable is truthy string", () => {
29
+ expect(
30
+ renderTemplate("start{{#if show}} visible{{/if}} end", { show: "yes" })
31
+ ).toBe("start visible end");
32
+ });
33
+
34
+ it("includes block when variable is true", () => {
35
+ expect(
36
+ renderTemplate("start{{#if show}} visible{{/if}} end", { show: true })
37
+ ).toBe("start visible end");
38
+ });
39
+
40
+ it("excludes block when variable is false", () => {
41
+ expect(
42
+ renderTemplate("start{{#if show}} visible{{/if}} end", { show: false })
43
+ ).toBe("start end");
44
+ });
45
+
46
+ it("excludes block when variable is undefined", () => {
47
+ expect(
48
+ renderTemplate("start{{#if show}} visible{{/if}} end", {})
49
+ ).toBe("start end");
50
+ });
51
+
52
+ it("excludes block when variable is empty string", () => {
53
+ expect(
54
+ renderTemplate("start{{#if show}} visible{{/if}} end", { show: "" })
55
+ ).toBe("start end");
56
+ });
57
+ });
58
+
59
+ describe("{{#unless}} / {{/unless}}", () => {
60
+ it("includes block when variable is falsy", () => {
61
+ expect(
62
+ renderTemplate("{{#unless premium}}Free tier{{/unless}}", { premium: false })
63
+ ).toBe("Free tier");
64
+ });
65
+
66
+ it("excludes block when variable is truthy", () => {
67
+ expect(
68
+ renderTemplate("{{#unless premium}}Free tier{{/unless}}", { premium: true })
69
+ ).toBe("");
70
+ });
71
+ });
72
+
73
+ describe("{{else}} branches", () => {
74
+ it("renders else branch when if condition is false", () => {
75
+ expect(
76
+ renderTemplate(
77
+ "{{#if vip}}VIP access{{else}}Standard access{{/if}}",
78
+ { vip: false }
79
+ )
80
+ ).toBe("Standard access");
81
+ });
82
+
83
+ it("renders if branch when condition is true", () => {
84
+ expect(
85
+ renderTemplate(
86
+ "{{#if vip}}VIP access{{else}}Standard access{{/if}}",
87
+ { vip: true }
88
+ )
89
+ ).toBe("VIP access");
90
+ });
91
+ });
92
+
93
+ describe("nested conditionals", () => {
94
+ it("handles nested if blocks", () => {
95
+ const template = "{{#if a}}A{{#if b}}-B{{/if}}{{/if}}";
96
+ expect(renderTemplate(template, { a: true, b: true })).toBe("A-B");
97
+ expect(renderTemplate(template, { a: true, b: false })).toBe("A");
98
+ expect(renderTemplate(template, { a: false, b: true })).toBe("");
99
+ });
100
+ });
101
+
102
+ describe("variables inside conditionals", () => {
103
+ it("interpolates variables within conditional blocks", () => {
104
+ expect(
105
+ renderTemplate(
106
+ "{{#if hasName}}Name: {{name}}{{/if}}",
107
+ { hasName: true, name: "Alice" }
108
+ )
109
+ ).toBe("Name: Alice");
110
+ });
111
+ });
112
+
113
+ describe("real-world fitness app template", () => {
114
+ const template = `You are a fitness coach AI assistant.
115
+
116
+ User: {{userName}}
117
+ Goal: {{fitnessGoal}}
118
+
119
+ {{#if hasImages}}
120
+ The user has attached images for form analysis. Please review them carefully.
121
+ {{/if}}
122
+ {{#if hasAttachments}}
123
+ Additional documents have been provided for context.
124
+ {{/if}}
125
+ {{#unless hasHistory}}
126
+ This is a new user with no prior conversation history. Introduce yourself.
127
+ {{/unless}}
128
+
129
+ Please provide personalized advice.`;
130
+
131
+ it("renders with all flags true", () => {
132
+ const result = renderTemplate(template, {
133
+ userName: "Sarah",
134
+ fitnessGoal: "Build muscle",
135
+ hasImages: true,
136
+ hasAttachments: true,
137
+ hasHistory: true,
138
+ });
139
+ expect(result).toContain("User: Sarah");
140
+ expect(result).toContain("Goal: Build muscle");
141
+ expect(result).toContain("attached images for form analysis");
142
+ expect(result).toContain("Additional documents");
143
+ expect(result).not.toContain("new user with no prior");
144
+ });
145
+
146
+ it("renders with all flags false", () => {
147
+ const result = renderTemplate(template, {
148
+ userName: "Tom",
149
+ fitnessGoal: "Lose weight",
150
+ hasImages: false,
151
+ hasAttachments: false,
152
+ hasHistory: false,
153
+ });
154
+ expect(result).toContain("User: Tom");
155
+ expect(result).toContain("Goal: Lose weight");
156
+ expect(result).not.toContain("attached images");
157
+ expect(result).not.toContain("Additional documents");
158
+ expect(result).toContain("new user with no prior");
159
+ });
160
+ });
161
+ });