@kognitivedev/vercel-ai-provider 0.1.8 → 0.1.9

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.
@@ -24,6 +24,98 @@ const test_1 = require("ai/test");
24
24
  return new Response("not found", { status: 404 });
25
25
  }));
26
26
  });
27
+ (0, vitest_1.it)("should capture tool-call chunks and include them in logged conversation", async () => {
28
+ const mockModel = new test_1.MockLanguageModelV3({
29
+ doStream: async () => ({
30
+ stream: (0, test_1.convertArrayToReadableStream)([
31
+ { type: "text-start", id: "t1" },
32
+ { type: "text-delta", id: "t1", delta: "Let me check" },
33
+ { type: "text-end", id: "t1" },
34
+ {
35
+ type: "tool-call",
36
+ toolCallId: "call-1",
37
+ toolName: "get_weather",
38
+ input: '{"city":"London"}',
39
+ },
40
+ {
41
+ type: "tool-result",
42
+ toolCallId: "call-1",
43
+ toolName: "get_weather",
44
+ result: { temperature: 15, unit: "celsius" },
45
+ },
46
+ {
47
+ type: "finish",
48
+ finishReason: {
49
+ unified: "tool-calls",
50
+ raw: undefined,
51
+ },
52
+ usage: {
53
+ inputTokens: { total: 20, noCache: undefined, cacheRead: undefined, cacheWrite: undefined },
54
+ outputTokens: { total: 15, text: undefined, reasoning: undefined },
55
+ },
56
+ },
57
+ ]),
58
+ }),
59
+ });
60
+ const mockProvider = () => mockModel;
61
+ const cl = (0, index_1.createCognitiveLayer)({
62
+ provider: mockProvider,
63
+ clConfig: {
64
+ apiKey: "test-api-key",
65
+ appId: "test-app",
66
+ projectId: "test-project",
67
+ processDelayMs: 0,
68
+ logLevel: "none",
69
+ },
70
+ });
71
+ const model = cl("mock-model", {
72
+ userId: "user-1",
73
+ projectId: "project-1",
74
+ sessionId: "session-1",
75
+ });
76
+ const result = (0, ai_1.streamText)({
77
+ model,
78
+ messages: [{ role: "user", content: "What's the weather in London?" }],
79
+ });
80
+ // Fully consume the stream
81
+ await result.text;
82
+ // Wait for async logConversation to complete
83
+ await new Promise((r) => setTimeout(r, 100));
84
+ // Find the log call
85
+ const logCall = fetchCalls.find((c) => c.url.includes("/api/cognitive/log"));
86
+ (0, vitest_1.expect)(logCall).toBeDefined();
87
+ const messages = logCall.body.messages;
88
+ // Assistant message should contain text + tool-call parts
89
+ const assistantMsg = messages.find((m) => m.role === "assistant");
90
+ (0, vitest_1.expect)(assistantMsg).toBeDefined();
91
+ (0, vitest_1.expect)(assistantMsg.content).toEqual([
92
+ { type: "text", text: "Let me check" },
93
+ {
94
+ type: "tool-call",
95
+ toolCallId: "call-1",
96
+ toolName: "get_weather",
97
+ input: '{"city":"London"}',
98
+ },
99
+ ]);
100
+ // Tool results should be in a separate tool message
101
+ const toolMsg = messages.find((m) => m.role === "tool");
102
+ (0, vitest_1.expect)(toolMsg).toBeDefined();
103
+ (0, vitest_1.expect)(toolMsg.content).toEqual([
104
+ {
105
+ type: "tool-result",
106
+ toolCallId: "call-1",
107
+ toolName: "get_weather",
108
+ result: { temperature: 15, unit: "celsius" },
109
+ },
110
+ ]);
111
+ // Spans should include the tool call with populated previews
112
+ const spans = logCall.body.spans;
113
+ const toolSpan = spans === null || spans === void 0 ? void 0 : spans.find((s) => s.spanType === "tool");
114
+ (0, vitest_1.expect)(toolSpan).toBeDefined();
115
+ (0, vitest_1.expect)(toolSpan.toolName).toBe("get_weather");
116
+ (0, vitest_1.expect)(toolSpan.inputPreview).toContain("London");
117
+ (0, vitest_1.expect)(toolSpan.outputPreview).toContain("15");
118
+ });
27
119
  (0, vitest_1.it)("should include assistant message in logged conversation after streaming", async () => {
28
120
  const mockModel = new test_1.MockLanguageModelV3({
29
121
  doStream: async () => ({
package/dist/index.d.ts CHANGED
@@ -55,6 +55,27 @@ export interface LogConversationPayload {
55
55
  promptSlug?: string;
56
56
  promptVersion?: number;
57
57
  promptId?: string;
58
+ traceId?: string;
59
+ parentSpanId?: string;
60
+ requestPreview?: string;
61
+ responsePreview?: string;
62
+ state?: "active" | "completed" | "error";
63
+ startedAt?: string;
64
+ endedAt?: string;
65
+ durationMs?: number;
66
+ metadata?: Record<string, unknown>;
67
+ spans?: Array<{
68
+ spanKey: string;
69
+ parentSpanKey?: string;
70
+ name: string;
71
+ spanType: string;
72
+ status?: "active" | "completed" | "error";
73
+ inputPreview?: string;
74
+ outputPreview?: string;
75
+ toolName?: string;
76
+ errorMessage?: string;
77
+ metadata?: Record<string, unknown>;
78
+ }>;
58
79
  }
59
80
  export type CognitiveLayer = CLModelWrapper & {
60
81
  streamText: (options: CLStreamTextOptions) => Promise<ReturnType<typeof aiStreamText>>;
package/dist/index.js CHANGED
@@ -13,12 +13,25 @@ var __rest = (this && this.__rest) || function (s, e) {
13
13
  Object.defineProperty(exports, "__esModule", { value: true });
14
14
  exports.createCognitiveLayer = createCognitiveLayer;
15
15
  const ai_1 = require("ai");
16
+ const crypto_1 = require("crypto");
16
17
  function isValidId(value) {
17
18
  if (value == null || typeof value !== "string")
18
19
  return false;
19
20
  const trimmed = value.trim();
20
21
  return trimmed !== "" && trimmed !== "null" && trimmed !== "undefined";
21
22
  }
23
+ function maskSecret(secret) {
24
+ if (!secret)
25
+ return "missing";
26
+ if (secret.length <= 8)
27
+ return `${secret.slice(0, 2)}***`;
28
+ return `${secret.slice(0, 4)}...${secret.slice(-4)}`;
29
+ }
30
+ function previewText(value, maxLength = 240) {
31
+ if (value.length <= maxLength)
32
+ return value;
33
+ return `${value.slice(0, maxLength)}...`;
34
+ }
22
35
  const LOG_LEVEL_PRIORITY = {
23
36
  none: 0,
24
37
  error: 1,
@@ -55,6 +68,92 @@ function createLogger(logLevel) {
55
68
  };
56
69
  }
57
70
  const PROMPT_CACHE_TTL_MS = 60000; // 1 minute
71
+ function getContentText(content) {
72
+ if (typeof content === "string")
73
+ return content;
74
+ if (!Array.isArray(content))
75
+ return "";
76
+ return content.map((part) => {
77
+ if (!part || typeof part !== "object")
78
+ return "";
79
+ if (typeof part.text === "string")
80
+ return part.text;
81
+ if (part.type === "tool-call" && typeof part.toolName === "string")
82
+ return `Called ${part.toolName}`;
83
+ if (part.type === "tool-result")
84
+ return "Received tool result";
85
+ return "";
86
+ }).filter(Boolean).join(" ");
87
+ }
88
+ /**
89
+ * Unwraps V2/V3 ToolResultOutput discriminated union to a displayable value.
90
+ * Stream ToolResult uses plain `result` (passthrough), while prompt ToolResultPart
91
+ * uses `output` with a discriminated union: text, json, error-text, error-json, content, execution-denied.
92
+ */
93
+ function extractOutputValue(raw) {
94
+ var _a;
95
+ if (raw == null)
96
+ return raw;
97
+ if (typeof raw !== 'object')
98
+ return raw;
99
+ const obj = raw;
100
+ if (typeof obj.type !== 'string')
101
+ return raw;
102
+ switch (obj.type) {
103
+ case 'text':
104
+ case 'json':
105
+ case 'error-text':
106
+ case 'error-json':
107
+ case 'content':
108
+ return obj.value;
109
+ case 'execution-denied':
110
+ return `Execution denied: ${(_a = obj.reason) !== null && _a !== void 0 ? _a : 'unknown'}`;
111
+ default:
112
+ return raw;
113
+ }
114
+ }
115
+ function buildTracePreviews(messages) {
116
+ const request = [...messages].reverse().find((message) => (message === null || message === void 0 ? void 0 : message.role) === "user");
117
+ const response = [...messages].reverse().find((message) => (message === null || message === void 0 ? void 0 : message.role) === "assistant");
118
+ return {
119
+ requestPreview: request ? getContentText(request.content).slice(0, 220) : "No request captured",
120
+ responsePreview: response ? getContentText(response.content).slice(0, 240) : "No response captured",
121
+ };
122
+ }
123
+ function buildTraceSpansFromMessages(messages) {
124
+ var _a, _b;
125
+ const resultMap = new Map();
126
+ for (const message of messages) {
127
+ if (!Array.isArray(message === null || message === void 0 ? void 0 : message.content))
128
+ continue;
129
+ for (const part of message.content) {
130
+ if ((part === null || part === void 0 ? void 0 : part.type) === "tool-result" && typeof part.toolCallId === "string") {
131
+ resultMap.set(part.toolCallId, (_a = part.result) !== null && _a !== void 0 ? _a : part.output);
132
+ }
133
+ }
134
+ }
135
+ const spans = [];
136
+ for (const message of messages) {
137
+ if (!Array.isArray(message === null || message === void 0 ? void 0 : message.content))
138
+ continue;
139
+ for (const part of message.content) {
140
+ if ((part === null || part === void 0 ? void 0 : part.type) === "tool-call" && typeof part.toolCallId === "string") {
141
+ const result = resultMap.get(part.toolCallId);
142
+ spans.push({
143
+ spanKey: part.toolCallId,
144
+ parentSpanKey: "root",
145
+ name: typeof part.toolName === "string" ? part.toolName : "tool",
146
+ spanType: "tool",
147
+ status: "completed",
148
+ inputPreview: JSON.stringify((_b = part.input) !== null && _b !== void 0 ? _b : {}).slice(0, 220),
149
+ outputPreview: result != null ? JSON.stringify(extractOutputValue(result)).slice(0, 220) : "No tool result captured",
150
+ toolName: typeof part.toolName === "string" ? part.toolName : undefined,
151
+ });
152
+ }
153
+ }
154
+ }
155
+ return spans;
156
+ }
58
157
  /**
59
158
  * Interpolate {{variable}} placeholders in a template string.
60
159
  * Unmatched variables are left as-is.
@@ -101,6 +200,7 @@ function createCognitiveLayer(config) {
101
200
  // Prompt cache: slug → CachedPrompt
102
201
  const promptCache = new Map();
103
202
  const resolvePrompt = async (slug, userId) => {
203
+ var _a;
104
204
  const cacheKey = userId ? `${slug}:${userId}` : slug;
105
205
  const cached = promptCache.get(cacheKey);
106
206
  if (cached && Date.now() - cached.fetchedAt < PROMPT_CACHE_TTL_MS) {
@@ -111,11 +211,31 @@ function createCognitiveLayer(config) {
111
211
  url.searchParams.set("slug", slug);
112
212
  if (userId)
113
213
  url.searchParams.set("userId", userId);
214
+ logger.debug("Resolving prompt from backend", {
215
+ slug,
216
+ userId,
217
+ url: url.toString(),
218
+ baseUrl,
219
+ apiKeyHint: maskSecret(clConfig.apiKey),
220
+ });
114
221
  const res = await fetch(url.toString(), {
115
222
  headers: { "Authorization": `Bearer ${clConfig.apiKey}` },
116
223
  });
224
+ logger.debug("Prompt resolve response received", {
225
+ slug,
226
+ userId,
227
+ status: res.status,
228
+ ok: res.ok,
229
+ contentType: res.headers.get("content-type"),
230
+ });
117
231
  if (!res.ok) {
118
232
  const body = await res.text();
233
+ logger.debug("Prompt resolve response body preview", {
234
+ slug,
235
+ userId,
236
+ status: res.status,
237
+ bodyPreview: previewText(body),
238
+ });
119
239
  throw new Error(`Failed to resolve prompt "${slug}": ${res.status} ${body}`);
120
240
  }
121
241
  const data = await res.json();
@@ -128,6 +248,14 @@ function createCognitiveLayer(config) {
128
248
  gatewaySlug: data.gatewaySlug,
129
249
  };
130
250
  promptCache.set(cacheKey, entry);
251
+ logger.debug("Prompt resolved payload", {
252
+ slug,
253
+ resolvedSlug: entry.slug,
254
+ version: entry.version,
255
+ promptId: entry.promptId,
256
+ contentLength: entry.content.length,
257
+ gatewaySlug: (_a = entry.gatewaySlug) !== null && _a !== void 0 ? _a : null,
258
+ });
131
259
  logger.info("Prompt resolved", { slug, version: entry.version });
132
260
  return entry;
133
261
  };
@@ -194,9 +322,25 @@ function createCognitiveLayer(config) {
194
322
  if (systemPromptToAdd === undefined) {
195
323
  try {
196
324
  const url = `${baseUrl}/api/cognitive/snapshot?userId=${userId}`;
325
+ logger.debug("Fetching snapshot from backend", {
326
+ userId,
327
+ projectId,
328
+ sessionId,
329
+ url,
330
+ baseUrl,
331
+ apiKeyHint: maskSecret(clConfig.apiKey),
332
+ });
197
333
  const res = await fetch(url, {
198
334
  headers: { "Authorization": `Bearer ${clConfig.apiKey}` },
199
335
  });
336
+ logger.debug("Snapshot response received", {
337
+ userId,
338
+ projectId,
339
+ sessionId,
340
+ status: res.status,
341
+ ok: res.ok,
342
+ contentType: res.headers.get("content-type"),
343
+ });
200
344
  if (res.ok) {
201
345
  const data = await res.json();
202
346
  const systemBlock = data.systemBlock || "";
@@ -229,7 +373,15 @@ ${userContextBlock || "None"}
229
373
  });
230
374
  }
231
375
  else {
376
+ const body = await res.text();
232
377
  logger.warn("Snapshot fetch failed", { status: res.status });
378
+ logger.debug("Snapshot response body preview", {
379
+ userId,
380
+ projectId,
381
+ sessionId,
382
+ status: res.status,
383
+ bodyPreview: previewText(body),
384
+ });
233
385
  systemPromptToAdd = "";
234
386
  sessionSnapshots.set(sessionKey, systemPromptToAdd);
235
387
  }
@@ -255,7 +407,8 @@ ${userContextBlock || "None"}
255
407
  return Object.assign(Object.assign({}, nextParams), { prompt: messagesWithMemory });
256
408
  },
257
409
  async wrapGenerate({ doGenerate, params }) {
258
- var _a, _b;
410
+ var _a;
411
+ const startedAt = new Date();
259
412
  let result;
260
413
  try {
261
414
  result = await doGenerate();
@@ -266,28 +419,57 @@ ${userContextBlock || "None"}
266
419
  throw err;
267
420
  }
268
421
  if (isValidId(userId) && isValidId(sessionId)) {
422
+ const endedAt = new Date();
269
423
  const sessionKey = `${userId}:${projectId}:${sessionId}`;
270
424
  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 }] }]
425
+ const messagesInput = params.prompt || params.messages || [];
426
+ // Build assistant message from result.content (V2/V3 GenerateResult)
427
+ const resultContent = Array.isArray(result === null || result === void 0 ? void 0 : result.content) ? result.content : [];
428
+ const assistantParts = [];
429
+ for (const part of resultContent) {
430
+ if ((part === null || part === void 0 ? void 0 : part.type) === 'text') {
431
+ assistantParts.push({ type: 'text', text: part.text });
432
+ }
433
+ else if ((part === null || part === void 0 ? void 0 : part.type) === 'tool-call') {
434
+ assistantParts.push({
435
+ type: 'tool-call',
436
+ toolCallId: part.toolCallId,
437
+ toolName: part.toolName,
438
+ input: part.input,
439
+ });
440
+ }
441
+ else if ((part === null || part === void 0 ? void 0 : part.type) === 'tool-result') {
442
+ assistantParts.push({
443
+ type: 'tool-result',
444
+ toolCallId: part.toolCallId,
445
+ toolName: part.toolName,
446
+ result: part.result,
447
+ });
448
+ }
449
+ }
450
+ const assistantMessage = assistantParts.length > 0
451
+ ? [{ role: "assistant", content: assistantParts }]
275
452
  : [];
276
- const finalMessages = Array.isArray(resultMessages) && resultMessages.length > 0
277
- ? resultMessages
278
- : [...messagesInput, ...assistantMessage];
279
- logConversation(Object.assign({ userId,
453
+ const finalMessages = [...messagesInput, ...assistantMessage];
454
+ const { requestPreview, responsePreview } = buildTracePreviews(finalMessages);
455
+ const spans = buildTraceSpansFromMessages(finalMessages);
456
+ logConversation(Object.assign(Object.assign({ userId,
280
457
  projectId,
281
458
  sessionId, messages: finalMessages, modelId, usage: result.usage }, (promptMeta && {
282
459
  promptSlug: promptMeta.promptSlug,
283
460
  promptVersion: promptMeta.promptVersion,
284
461
  promptId: promptMeta.promptId,
285
- }))).then(() => triggerProcessing(userId, projectId, sessionId));
462
+ })), { traceId: (0, crypto_1.randomUUID)(), requestPreview,
463
+ responsePreview, state: "completed", startedAt: startedAt.toISOString(), endedAt: endedAt.toISOString(), durationMs: endedAt.getTime() - startedAt.getTime(), metadata: {
464
+ appId: clConfig.appId,
465
+ }, spans })).then(() => triggerProcessing(userId, projectId, sessionId));
286
466
  }
287
467
  return result;
288
468
  },
289
469
  async wrapStream({ doStream, params }) {
290
470
  var _a;
471
+ const startedAt = new Date();
472
+ const traceId = (0, crypto_1.randomUUID)();
291
473
  let result;
292
474
  try {
293
475
  logger.debug("Starting doStream with params", JSON.stringify(params, null, 2));
@@ -302,13 +484,16 @@ ${userContextBlock || "None"}
302
484
  if (isValidId(userId) && isValidId(sessionId)) {
303
485
  const sessionKey = `${userId}:${projectId}:${sessionId}`;
304
486
  const promptMeta = sessionPromptMetadata.get(sessionKey);
305
- const messagesInput = params.messages || params.prompt || [];
487
+ const messagesInput = params.prompt || params.messages || [];
306
488
  const resultMessages = (_a = result === null || result === void 0 ? void 0 : result.response) === null || _a === void 0 ? void 0 : _a.messages;
307
489
  const finalMessages = Array.isArray(resultMessages) && resultMessages.length > 0
308
490
  ? resultMessages
309
491
  : messagesInput;
310
492
  let streamUsage;
311
493
  let accumulatedText = '';
494
+ const toolCallInputs = new Map();
495
+ const completedToolCalls = [];
496
+ const completedToolResults = [];
312
497
  const originalStream = result.stream;
313
498
  const transformStream = new TransformStream({
314
499
  transform(chunk, controller) {
@@ -318,19 +503,72 @@ ${userContextBlock || "None"}
318
503
  if (chunk.type === 'finish' && chunk.usage) {
319
504
  streamUsage = chunk.usage;
320
505
  }
506
+ // Capture tool-call stream chunks (V2/V3 shared types)
507
+ if (chunk.type === 'tool-input-start') {
508
+ toolCallInputs.set(chunk.id, { toolName: chunk.toolName, chunks: [] });
509
+ }
510
+ if (chunk.type === 'tool-input-delta') {
511
+ const entry = toolCallInputs.get(chunk.id);
512
+ if (entry)
513
+ entry.chunks.push(chunk.delta);
514
+ }
515
+ if (chunk.type === 'tool-call') {
516
+ completedToolCalls.push({
517
+ type: 'tool-call',
518
+ toolCallId: chunk.toolCallId,
519
+ toolName: chunk.toolName,
520
+ input: chunk.input,
521
+ });
522
+ }
523
+ if (chunk.type === 'tool-result') {
524
+ completedToolResults.push({
525
+ type: 'tool-result',
526
+ toolCallId: chunk.toolCallId,
527
+ toolName: chunk.toolName,
528
+ result: chunk.result,
529
+ });
530
+ }
321
531
  controller.enqueue(chunk);
322
532
  },
323
- flush() {
324
- const allMessages = accumulatedText
325
- ? [...finalMessages, { role: "assistant", content: [{ type: "text", text: accumulatedText }] }]
533
+ async flush() {
534
+ const endedAt = new Date();
535
+ // Finalize any tool calls from incremental input chunks
536
+ for (const [id, entry] of toolCallInputs) {
537
+ // Only add if not already captured via a tool-call chunk
538
+ if (!completedToolCalls.some((tc) => tc.toolCallId === id)) {
539
+ completedToolCalls.push({
540
+ type: 'tool-call',
541
+ toolCallId: id,
542
+ toolName: entry.toolName,
543
+ input: entry.chunks.join(''),
544
+ });
545
+ }
546
+ }
547
+ const assistantParts = [];
548
+ if (accumulatedText)
549
+ assistantParts.push({ type: "text", text: accumulatedText });
550
+ for (const tc of completedToolCalls)
551
+ assistantParts.push(tc);
552
+ const allMessages = assistantParts.length > 0
553
+ ? [...finalMessages, { role: "assistant", content: assistantParts }]
326
554
  : finalMessages;
327
- logConversation(Object.assign({ userId,
555
+ if (completedToolResults.length > 0) {
556
+ allMessages.push({ role: "tool", content: completedToolResults });
557
+ }
558
+ const { requestPreview, responsePreview } = buildTracePreviews(allMessages);
559
+ const spans = buildTraceSpansFromMessages(allMessages);
560
+ await logConversation(Object.assign(Object.assign({ userId,
328
561
  projectId,
329
562
  sessionId, messages: allMessages, modelId, usage: streamUsage }, (promptMeta && {
330
563
  promptSlug: promptMeta.promptSlug,
331
564
  promptVersion: promptMeta.promptVersion,
332
565
  promptId: promptMeta.promptId,
333
- }))).then(() => triggerProcessing(userId, projectId, sessionId));
566
+ })), { traceId,
567
+ requestPreview,
568
+ responsePreview, state: "completed", startedAt: startedAt.toISOString(), endedAt: endedAt.toISOString(), durationMs: endedAt.getTime() - startedAt.getTime(), metadata: {
569
+ appId: clConfig.appId,
570
+ }, spans }));
571
+ triggerProcessing(userId, projectId, sessionId);
334
572
  }
335
573
  });
336
574
  result.stream = originalStream.pipeThrough(transformStream);
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.1.9",
4
4
  "main": "dist/index.js",
5
5
  "types": "dist/index.d.ts",
6
6
  "publishConfig": {
@@ -36,6 +36,110 @@ describe("wrapStream logging", () => {
36
36
  );
37
37
  });
38
38
 
39
+ it("should capture tool-call chunks and include them in logged conversation", async () => {
40
+ const mockModel = new MockLanguageModelV3({
41
+ doStream: async () => ({
42
+ stream: convertArrayToReadableStream([
43
+ { type: "text-start" as const, id: "t1" },
44
+ { type: "text-delta" as const, id: "t1", delta: "Let me check" },
45
+ { type: "text-end" as const, id: "t1" },
46
+ {
47
+ type: "tool-call" as const,
48
+ toolCallId: "call-1",
49
+ toolName: "get_weather",
50
+ input: '{"city":"London"}',
51
+ },
52
+ {
53
+ type: "tool-result" as const,
54
+ toolCallId: "call-1",
55
+ toolName: "get_weather",
56
+ result: { temperature: 15, unit: "celsius" },
57
+ },
58
+ {
59
+ type: "finish" as const,
60
+ finishReason: {
61
+ unified: "tool-calls" as const,
62
+ raw: undefined,
63
+ },
64
+ usage: {
65
+ inputTokens: { total: 20, noCache: undefined, cacheRead: undefined, cacheWrite: undefined },
66
+ outputTokens: { total: 15, text: undefined, reasoning: undefined },
67
+ },
68
+ },
69
+ ] satisfies import("@ai-sdk/provider").LanguageModelV3StreamPart[]),
70
+ }),
71
+ });
72
+
73
+ const mockProvider = () => mockModel;
74
+
75
+ const cl = createCognitiveLayer({
76
+ provider: mockProvider,
77
+ clConfig: {
78
+ apiKey: "test-api-key",
79
+ appId: "test-app",
80
+ projectId: "test-project",
81
+ processDelayMs: 0,
82
+ logLevel: "none",
83
+ },
84
+ });
85
+
86
+ const model = cl("mock-model", {
87
+ userId: "user-1",
88
+ projectId: "project-1",
89
+ sessionId: "session-1",
90
+ });
91
+
92
+ const result = streamText({
93
+ model,
94
+ messages: [{ role: "user", content: "What's the weather in London?" }],
95
+ });
96
+
97
+ // Fully consume the stream
98
+ await result.text;
99
+
100
+ // Wait for async logConversation to complete
101
+ await new Promise((r) => setTimeout(r, 100));
102
+
103
+ // Find the log call
104
+ const logCall = fetchCalls.find((c) => c.url.includes("/api/cognitive/log"));
105
+ expect(logCall).toBeDefined();
106
+
107
+ const messages = logCall!.body.messages;
108
+
109
+ // Assistant message should contain text + tool-call parts
110
+ const assistantMsg = messages.find((m: any) => m.role === "assistant");
111
+ expect(assistantMsg).toBeDefined();
112
+ expect(assistantMsg.content).toEqual([
113
+ { type: "text", text: "Let me check" },
114
+ {
115
+ type: "tool-call",
116
+ toolCallId: "call-1",
117
+ toolName: "get_weather",
118
+ input: '{"city":"London"}',
119
+ },
120
+ ]);
121
+
122
+ // Tool results should be in a separate tool message
123
+ const toolMsg = messages.find((m: any) => m.role === "tool");
124
+ expect(toolMsg).toBeDefined();
125
+ expect(toolMsg.content).toEqual([
126
+ {
127
+ type: "tool-result",
128
+ toolCallId: "call-1",
129
+ toolName: "get_weather",
130
+ result: { temperature: 15, unit: "celsius" },
131
+ },
132
+ ]);
133
+
134
+ // Spans should include the tool call with populated previews
135
+ const spans = logCall!.body.spans;
136
+ const toolSpan = spans?.find((s: any) => s.spanType === "tool");
137
+ expect(toolSpan).toBeDefined();
138
+ expect(toolSpan.toolName).toBe("get_weather");
139
+ expect(toolSpan.inputPreview).toContain("London");
140
+ expect(toolSpan.outputPreview).toContain("15");
141
+ });
142
+
39
143
  it("should include assistant message in logged conversation after streaming", async () => {
40
144
  const mockModel = new MockLanguageModelV3({
41
145
  doStream: async () => ({
package/src/index.ts CHANGED
@@ -4,6 +4,7 @@ import {
4
4
  generateText as aiGenerateText,
5
5
  type LanguageModel,
6
6
  } from "ai";
7
+ import { randomUUID } from "crypto";
7
8
 
8
9
  /**
9
10
  * Log levels for controlling verbosity of CognitiveLayer logging.
@@ -21,6 +22,17 @@ function isValidId(value: string | undefined | null): value is string {
21
22
  return trimmed !== "" && trimmed !== "null" && trimmed !== "undefined";
22
23
  }
23
24
 
25
+ function maskSecret(secret: string | undefined | null): string {
26
+ if (!secret) return "missing";
27
+ if (secret.length <= 8) return `${secret.slice(0, 2)}***`;
28
+ return `${secret.slice(0, 4)}...${secret.slice(-4)}`;
29
+ }
30
+
31
+ function previewText(value: string, maxLength = 240): string {
32
+ if (value.length <= maxLength) return value;
33
+ return `${value.slice(0, maxLength)}...`;
34
+ }
35
+
24
36
  const LOG_LEVEL_PRIORITY: Record<LogLevel, number> = {
25
37
  none: 0,
26
38
  error: 1,
@@ -111,6 +123,27 @@ export interface LogConversationPayload {
111
123
  promptSlug?: string;
112
124
  promptVersion?: number;
113
125
  promptId?: string;
126
+ traceId?: string;
127
+ parentSpanId?: string;
128
+ requestPreview?: string;
129
+ responsePreview?: string;
130
+ state?: "active" | "completed" | "error";
131
+ startedAt?: string;
132
+ endedAt?: string;
133
+ durationMs?: number;
134
+ metadata?: Record<string, unknown>;
135
+ spans?: Array<{
136
+ spanKey: string;
137
+ parentSpanKey?: string;
138
+ name: string;
139
+ spanType: string;
140
+ status?: "active" | "completed" | "error";
141
+ inputPreview?: string;
142
+ outputPreview?: string;
143
+ toolName?: string;
144
+ errorMessage?: string;
145
+ metadata?: Record<string, unknown>;
146
+ }>;
114
147
  }
115
148
 
116
149
  export type CognitiveLayer = CLModelWrapper & {
@@ -136,6 +169,111 @@ export interface CachedPrompt {
136
169
 
137
170
  const PROMPT_CACHE_TTL_MS = 60_000; // 1 minute
138
171
 
172
+ function getContentText(content: any): string {
173
+ if (typeof content === "string") return content;
174
+ if (!Array.isArray(content)) return "";
175
+
176
+ return content.map((part) => {
177
+ if (!part || typeof part !== "object") return "";
178
+ if (typeof part.text === "string") return part.text;
179
+ if (part.type === "tool-call" && typeof part.toolName === "string") return `Called ${part.toolName}`;
180
+ if (part.type === "tool-result") return "Received tool result";
181
+ return "";
182
+ }).filter(Boolean).join(" ");
183
+ }
184
+
185
+ /**
186
+ * Unwraps V2/V3 ToolResultOutput discriminated union to a displayable value.
187
+ * Stream ToolResult uses plain `result` (passthrough), while prompt ToolResultPart
188
+ * uses `output` with a discriminated union: text, json, error-text, error-json, content, execution-denied.
189
+ */
190
+ function extractOutputValue(raw: unknown): unknown {
191
+ if (raw == null) return raw;
192
+ if (typeof raw !== 'object') return raw;
193
+ const obj = raw as Record<string, unknown>;
194
+ if (typeof obj.type !== 'string') return raw;
195
+ switch (obj.type) {
196
+ case 'text':
197
+ case 'json':
198
+ case 'error-text':
199
+ case 'error-json':
200
+ case 'content':
201
+ return obj.value;
202
+ case 'execution-denied':
203
+ return `Execution denied: ${obj.reason ?? 'unknown'}`;
204
+ default:
205
+ return raw;
206
+ }
207
+ }
208
+
209
+ function buildTracePreviews(messages: any[]): { requestPreview: string; responsePreview: string } {
210
+ const request = [...messages].reverse().find((message) => message?.role === "user");
211
+ const response = [...messages].reverse().find((message) => message?.role === "assistant");
212
+
213
+ return {
214
+ requestPreview: request ? getContentText(request.content).slice(0, 220) : "No request captured",
215
+ responsePreview: response ? getContentText(response.content).slice(0, 240) : "No response captured",
216
+ };
217
+ }
218
+
219
+ function buildTraceSpansFromMessages(messages: any[]): Array<{
220
+ spanKey: string;
221
+ parentSpanKey?: string;
222
+ name: string;
223
+ spanType: string;
224
+ status?: "active" | "completed" | "error";
225
+ inputPreview?: string;
226
+ outputPreview?: string;
227
+ toolName?: string;
228
+ errorMessage?: string;
229
+ metadata?: Record<string, unknown>;
230
+ }> {
231
+ const resultMap = new Map<string, unknown>();
232
+
233
+ for (const message of messages) {
234
+ if (!Array.isArray(message?.content)) continue;
235
+ for (const part of message.content) {
236
+ if (part?.type === "tool-result" && typeof part.toolCallId === "string") {
237
+ resultMap.set(part.toolCallId, part.result ?? part.output);
238
+ }
239
+ }
240
+ }
241
+
242
+ const spans: Array<{
243
+ spanKey: string;
244
+ parentSpanKey?: string;
245
+ name: string;
246
+ spanType: string;
247
+ status?: "active" | "completed" | "error";
248
+ inputPreview?: string;
249
+ outputPreview?: string;
250
+ toolName?: string;
251
+ errorMessage?: string;
252
+ metadata?: Record<string, unknown>;
253
+ }> = [];
254
+
255
+ for (const message of messages) {
256
+ if (!Array.isArray(message?.content)) continue;
257
+ for (const part of message.content) {
258
+ if (part?.type === "tool-call" && typeof part.toolCallId === "string") {
259
+ const result = resultMap.get(part.toolCallId);
260
+ spans.push({
261
+ spanKey: part.toolCallId,
262
+ parentSpanKey: "root",
263
+ name: typeof part.toolName === "string" ? part.toolName : "tool",
264
+ spanType: "tool",
265
+ status: "completed",
266
+ inputPreview: JSON.stringify(part.input ?? {}).slice(0, 220),
267
+ outputPreview: result != null ? JSON.stringify(extractOutputValue(result)).slice(0, 220) : "No tool result captured",
268
+ toolName: typeof part.toolName === "string" ? part.toolName : undefined,
269
+ });
270
+ }
271
+ }
272
+ }
273
+
274
+ return spans;
275
+ }
276
+
139
277
  /**
140
278
  * Interpolate {{variable}} placeholders in a template string.
141
279
  * Unmatched variables are left as-is.
@@ -206,11 +344,32 @@ export function createCognitiveLayer(config: {
206
344
  url.searchParams.set("slug", slug);
207
345
  if (userId) url.searchParams.set("userId", userId);
208
346
 
347
+ logger.debug("Resolving prompt from backend", {
348
+ slug,
349
+ userId,
350
+ url: url.toString(),
351
+ baseUrl,
352
+ apiKeyHint: maskSecret(clConfig.apiKey),
353
+ });
354
+
209
355
  const res = await fetch(url.toString(), {
210
356
  headers: { "Authorization": `Bearer ${clConfig.apiKey}` },
211
357
  });
358
+ logger.debug("Prompt resolve response received", {
359
+ slug,
360
+ userId,
361
+ status: res.status,
362
+ ok: res.ok,
363
+ contentType: res.headers.get("content-type"),
364
+ });
212
365
  if (!res.ok) {
213
366
  const body = await res.text();
367
+ logger.debug("Prompt resolve response body preview", {
368
+ slug,
369
+ userId,
370
+ status: res.status,
371
+ bodyPreview: previewText(body),
372
+ });
214
373
  throw new Error(`Failed to resolve prompt "${slug}": ${res.status} ${body}`);
215
374
  }
216
375
 
@@ -224,6 +383,14 @@ export function createCognitiveLayer(config: {
224
383
  gatewaySlug: data.gatewaySlug,
225
384
  };
226
385
  promptCache.set(cacheKey, entry);
386
+ logger.debug("Prompt resolved payload", {
387
+ slug,
388
+ resolvedSlug: entry.slug,
389
+ version: entry.version,
390
+ promptId: entry.promptId,
391
+ contentLength: entry.content.length,
392
+ gatewaySlug: entry.gatewaySlug ?? null,
393
+ });
227
394
  logger.info("Prompt resolved", { slug, version: entry.version });
228
395
  return entry;
229
396
  };
@@ -306,9 +473,25 @@ export function createCognitiveLayer(config: {
306
473
  if (systemPromptToAdd === undefined) {
307
474
  try {
308
475
  const url = `${baseUrl}/api/cognitive/snapshot?userId=${userId}`;
476
+ logger.debug("Fetching snapshot from backend", {
477
+ userId,
478
+ projectId,
479
+ sessionId,
480
+ url,
481
+ baseUrl,
482
+ apiKeyHint: maskSecret(clConfig.apiKey),
483
+ });
309
484
  const res = await fetch(url, {
310
485
  headers: { "Authorization": `Bearer ${clConfig.apiKey}` },
311
486
  });
487
+ logger.debug("Snapshot response received", {
488
+ userId,
489
+ projectId,
490
+ sessionId,
491
+ status: res.status,
492
+ ok: res.ok,
493
+ contentType: res.headers.get("content-type"),
494
+ });
312
495
  if (res.ok) {
313
496
  const data = await res.json();
314
497
  const systemBlock = data.systemBlock || "";
@@ -342,7 +525,15 @@ ${userContextBlock || "None"}
342
525
  rawData: data,
343
526
  });
344
527
  } else {
528
+ const body = await res.text();
345
529
  logger.warn("Snapshot fetch failed", { status: res.status });
530
+ logger.debug("Snapshot response body preview", {
531
+ userId,
532
+ projectId,
533
+ sessionId,
534
+ status: res.status,
535
+ bodyPreview: previewText(body),
536
+ });
346
537
  systemPromptToAdd = "";
347
538
  sessionSnapshots.set(sessionKey, systemPromptToAdd);
348
539
  }
@@ -375,6 +566,7 @@ ${userContextBlock || "None"}
375
566
  },
376
567
 
377
568
  async wrapGenerate({ doGenerate, params }: { doGenerate: any; params: any }) {
569
+ const startedAt = new Date();
378
570
  let result;
379
571
  try {
380
572
  result = await doGenerate();
@@ -385,17 +577,40 @@ ${userContextBlock || "None"}
385
577
  }
386
578
 
387
579
  if (isValidId(userId) && isValidId(sessionId)) {
580
+ const endedAt = new Date();
388
581
  const sessionKey = `${userId}:${projectId}:${sessionId}`;
389
582
  const promptMeta = sessionPromptMetadata.get(sessionKey);
390
583
 
391
- const messagesInput = (params as any).messages || (params as any).prompt || [];
392
- const resultMessages = (result as any)?.response?.messages;
393
- const assistantMessage = (result as any)?.text
394
- ? [{ role: "assistant", content: [{ type: "text", text: (result as any).text }] }]
584
+ const messagesInput = (params as any).prompt || (params as any).messages || [];
585
+
586
+ // Build assistant message from result.content (V2/V3 GenerateResult)
587
+ const resultContent = Array.isArray(result?.content) ? result.content : [];
588
+ const assistantParts: any[] = [];
589
+ for (const part of resultContent) {
590
+ if (part?.type === 'text') {
591
+ assistantParts.push({ type: 'text', text: part.text });
592
+ } else if (part?.type === 'tool-call') {
593
+ assistantParts.push({
594
+ type: 'tool-call',
595
+ toolCallId: part.toolCallId,
596
+ toolName: part.toolName,
597
+ input: part.input,
598
+ });
599
+ } else if (part?.type === 'tool-result') {
600
+ assistantParts.push({
601
+ type: 'tool-result',
602
+ toolCallId: part.toolCallId,
603
+ toolName: part.toolName,
604
+ result: part.result,
605
+ });
606
+ }
607
+ }
608
+ const assistantMessage = assistantParts.length > 0
609
+ ? [{ role: "assistant", content: assistantParts }]
395
610
  : [];
396
- const finalMessages = Array.isArray(resultMessages) && resultMessages.length > 0
397
- ? resultMessages
398
- : [...messagesInput, ...assistantMessage];
611
+ const finalMessages = [...messagesInput, ...assistantMessage];
612
+ const { requestPreview, responsePreview } = buildTracePreviews(finalMessages);
613
+ const spans = buildTraceSpansFromMessages(finalMessages);
399
614
 
400
615
  logConversation({
401
616
  userId,
@@ -409,12 +624,25 @@ ${userContextBlock || "None"}
409
624
  promptVersion: promptMeta.promptVersion,
410
625
  promptId: promptMeta.promptId,
411
626
  }),
627
+ traceId: randomUUID(),
628
+ requestPreview,
629
+ responsePreview,
630
+ state: "completed",
631
+ startedAt: startedAt.toISOString(),
632
+ endedAt: endedAt.toISOString(),
633
+ durationMs: endedAt.getTime() - startedAt.getTime(),
634
+ metadata: {
635
+ appId: clConfig.appId,
636
+ },
637
+ spans,
412
638
  }).then(() => triggerProcessing(userId, projectId, sessionId));
413
639
  }
414
640
 
415
641
  return result;
416
642
  },
417
643
  async wrapStream({ doStream, params }: { doStream: any; params: any }) {
644
+ const startedAt = new Date();
645
+ const traceId = randomUUID();
418
646
  let result;
419
647
  try {
420
648
  logger.debug("Starting doStream with params", JSON.stringify(params, null, 2));
@@ -431,7 +659,7 @@ ${userContextBlock || "None"}
431
659
  const sessionKey = `${userId}:${projectId}:${sessionId}`;
432
660
  const promptMeta = sessionPromptMetadata.get(sessionKey);
433
661
 
434
- const messagesInput = (params as any).messages || (params as any).prompt || [];
662
+ const messagesInput = (params as any).prompt || (params as any).messages || [];
435
663
  const resultMessages = (result as any)?.response?.messages;
436
664
  const finalMessages = Array.isArray(resultMessages) && resultMessages.length > 0
437
665
  ? resultMessages
@@ -439,6 +667,9 @@ ${userContextBlock || "None"}
439
667
 
440
668
  let streamUsage: Record<string, unknown> | undefined;
441
669
  let accumulatedText = '';
670
+ const toolCallInputs = new Map<string, { toolName: string; chunks: string[] }>();
671
+ const completedToolCalls: any[] = [];
672
+ const completedToolResults: any[] = [];
442
673
 
443
674
  const originalStream = result.stream;
444
675
  const transformStream = new TransformStream({
@@ -449,14 +680,64 @@ ${userContextBlock || "None"}
449
680
  if (chunk.type === 'finish' && chunk.usage) {
450
681
  streamUsage = chunk.usage;
451
682
  }
683
+ // Capture tool-call stream chunks (V2/V3 shared types)
684
+ if (chunk.type === 'tool-input-start') {
685
+ toolCallInputs.set(chunk.id, { toolName: chunk.toolName, chunks: [] });
686
+ }
687
+ if (chunk.type === 'tool-input-delta') {
688
+ const entry = toolCallInputs.get(chunk.id);
689
+ if (entry) entry.chunks.push(chunk.delta);
690
+ }
691
+ if (chunk.type === 'tool-call') {
692
+ completedToolCalls.push({
693
+ type: 'tool-call',
694
+ toolCallId: chunk.toolCallId,
695
+ toolName: chunk.toolName,
696
+ input: chunk.input,
697
+ });
698
+ }
699
+ if (chunk.type === 'tool-result') {
700
+ completedToolResults.push({
701
+ type: 'tool-result',
702
+ toolCallId: chunk.toolCallId,
703
+ toolName: chunk.toolName,
704
+ result: chunk.result,
705
+ });
706
+ }
452
707
  controller.enqueue(chunk);
453
708
  },
454
- flush() {
455
- const allMessages = accumulatedText
456
- ? [...finalMessages, { role: "assistant", content: [{ type: "text", text: accumulatedText }] }]
709
+ async flush() {
710
+ const endedAt = new Date();
711
+
712
+ // Finalize any tool calls from incremental input chunks
713
+ for (const [id, entry] of toolCallInputs) {
714
+ // Only add if not already captured via a tool-call chunk
715
+ if (!completedToolCalls.some((tc: any) => tc.toolCallId === id)) {
716
+ completedToolCalls.push({
717
+ type: 'tool-call',
718
+ toolCallId: id,
719
+ toolName: entry.toolName,
720
+ input: entry.chunks.join(''),
721
+ });
722
+ }
723
+ }
724
+
725
+ const assistantParts: any[] = [];
726
+ if (accumulatedText) assistantParts.push({ type: "text", text: accumulatedText });
727
+ for (const tc of completedToolCalls) assistantParts.push(tc);
728
+
729
+ const allMessages = assistantParts.length > 0
730
+ ? [...finalMessages, { role: "assistant", content: assistantParts }]
457
731
  : finalMessages;
458
732
 
459
- logConversation({
733
+ if (completedToolResults.length > 0) {
734
+ allMessages.push({ role: "tool", content: completedToolResults });
735
+ }
736
+
737
+ const { requestPreview, responsePreview } = buildTracePreviews(allMessages);
738
+ const spans = buildTraceSpansFromMessages(allMessages);
739
+
740
+ await logConversation({
460
741
  userId,
461
742
  projectId,
462
743
  sessionId,
@@ -468,7 +749,19 @@ ${userContextBlock || "None"}
468
749
  promptVersion: promptMeta.promptVersion,
469
750
  promptId: promptMeta.promptId,
470
751
  }),
471
- }).then(() => triggerProcessing(userId, projectId, sessionId));
752
+ traceId,
753
+ requestPreview,
754
+ responsePreview,
755
+ state: "completed",
756
+ startedAt: startedAt.toISOString(),
757
+ endedAt: endedAt.toISOString(),
758
+ durationMs: endedAt.getTime() - startedAt.getTime(),
759
+ metadata: {
760
+ appId: clConfig.appId,
761
+ },
762
+ spans,
763
+ });
764
+ triggerProcessing(userId, projectId, sessionId);
472
765
  }
473
766
  });
474
767