@langwatch/mcp-server 0.3.3 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (78) hide show
  1. package/CHANGELOG.md +32 -0
  2. package/README.md +97 -25
  3. package/dist/archive-scenario-GAE4XVFM.js +19 -0
  4. package/dist/archive-scenario-GAE4XVFM.js.map +1 -0
  5. package/dist/chunk-AAQNA53E.js +28 -0
  6. package/dist/chunk-AAQNA53E.js.map +1 -0
  7. package/dist/chunk-JVWDWL3J.js +91 -0
  8. package/dist/chunk-JVWDWL3J.js.map +1 -0
  9. package/dist/chunk-K2YFPOSD.js +40 -0
  10. package/dist/chunk-K2YFPOSD.js.map +1 -0
  11. package/dist/chunk-ZXKLPC2E.js +27 -0
  12. package/dist/chunk-ZXKLPC2E.js.map +1 -0
  13. package/dist/config-FIQWQRUB.js +11 -0
  14. package/dist/config-FIQWQRUB.js.map +1 -0
  15. package/dist/create-prompt-P35POKBW.js +22 -0
  16. package/dist/create-prompt-P35POKBW.js.map +1 -0
  17. package/dist/create-scenario-3YRZVDYF.js +26 -0
  18. package/dist/create-scenario-3YRZVDYF.js.map +1 -0
  19. package/dist/discover-scenario-schema-MEEEVND7.js +65 -0
  20. package/dist/discover-scenario-schema-MEEEVND7.js.map +1 -0
  21. package/dist/discover-schema-3T52ORPB.js +446 -0
  22. package/dist/discover-schema-3T52ORPB.js.map +1 -0
  23. package/dist/get-analytics-BAVXTAPB.js +55 -0
  24. package/dist/get-analytics-BAVXTAPB.js.map +1 -0
  25. package/dist/get-prompt-LKCPT26O.js +48 -0
  26. package/dist/get-prompt-LKCPT26O.js.map +1 -0
  27. package/dist/get-scenario-3SCDW4Z6.js +33 -0
  28. package/dist/get-scenario-3SCDW4Z6.js.map +1 -0
  29. package/dist/get-trace-QFDWJ5D4.js +50 -0
  30. package/dist/get-trace-QFDWJ5D4.js.map +1 -0
  31. package/dist/index.js +22114 -8786
  32. package/dist/index.js.map +1 -1
  33. package/dist/list-prompts-UQPBCUYA.js +33 -0
  34. package/dist/list-prompts-UQPBCUYA.js.map +1 -0
  35. package/dist/list-scenarios-573YOUKC.js +40 -0
  36. package/dist/list-scenarios-573YOUKC.js.map +1 -0
  37. package/dist/search-traces-RSMYCAN7.js +72 -0
  38. package/dist/search-traces-RSMYCAN7.js.map +1 -0
  39. package/dist/update-prompt-G2Y5EBQY.js +31 -0
  40. package/dist/update-prompt-G2Y5EBQY.js.map +1 -0
  41. package/dist/update-scenario-SSGVOBJO.js +27 -0
  42. package/dist/update-scenario-SSGVOBJO.js.map +1 -0
  43. package/package.json +3 -3
  44. package/src/__tests__/config.unit.test.ts +89 -0
  45. package/src/__tests__/date-parsing.unit.test.ts +78 -0
  46. package/src/__tests__/discover-schema.unit.test.ts +118 -0
  47. package/src/__tests__/integration.integration.test.ts +313 -0
  48. package/src/__tests__/langwatch-api.unit.test.ts +309 -0
  49. package/src/__tests__/scenario-tools.integration.test.ts +286 -0
  50. package/src/__tests__/scenario-tools.unit.test.ts +185 -0
  51. package/src/__tests__/schemas.unit.test.ts +85 -0
  52. package/src/__tests__/tools.unit.test.ts +729 -0
  53. package/src/config.ts +31 -0
  54. package/src/index.ts +383 -0
  55. package/src/langwatch-api-scenarios.ts +67 -0
  56. package/src/langwatch-api.ts +266 -0
  57. package/src/schemas/analytics-groups.ts +78 -0
  58. package/src/schemas/analytics-metrics.ts +179 -0
  59. package/src/schemas/filter-fields.ts +119 -0
  60. package/src/schemas/index.ts +3 -0
  61. package/src/tools/archive-scenario.ts +19 -0
  62. package/src/tools/create-prompt.ts +29 -0
  63. package/src/tools/create-scenario.ts +30 -0
  64. package/src/tools/discover-scenario-schema.ts +71 -0
  65. package/src/tools/discover-schema.ts +106 -0
  66. package/src/tools/get-analytics.ts +71 -0
  67. package/src/tools/get-prompt.ts +56 -0
  68. package/src/tools/get-scenario.ts +36 -0
  69. package/src/tools/get-trace.ts +61 -0
  70. package/src/tools/list-prompts.ts +35 -0
  71. package/src/tools/list-scenarios.ts +47 -0
  72. package/src/tools/search-traces.ts +91 -0
  73. package/src/tools/update-prompt.ts +44 -0
  74. package/src/tools/update-scenario.ts +32 -0
  75. package/src/utils/date-parsing.ts +31 -0
  76. package/tests/evaluations.ipynb +634 -634
  77. package/tests/scenario-openai.test.ts +3 -1
  78. package/uv.lock +1788 -1322
@@ -0,0 +1,729 @@
1
+ import { describe, it, expect, vi, beforeEach } from "vitest";
2
+
3
+ vi.mock("../langwatch-api.js", () => ({
4
+ searchTraces: vi.fn(),
5
+ getTraceById: vi.fn(),
6
+ getAnalyticsTimeseries: vi.fn(),
7
+ listPrompts: vi.fn(),
8
+ getPrompt: vi.fn(),
9
+ createPrompt: vi.fn(),
10
+ updatePrompt: vi.fn(),
11
+ createPromptVersion: vi.fn(),
12
+ }));
13
+
14
+ import {
15
+ searchTraces,
16
+ getTraceById,
17
+ getAnalyticsTimeseries,
18
+ listPrompts,
19
+ getPrompt,
20
+ createPrompt,
21
+ updatePrompt,
22
+ createPromptVersion,
23
+ type PromptSummary,
24
+ } from "../langwatch-api.js";
25
+
26
+ import { handleSearchTraces } from "../tools/search-traces.js";
27
+ import { handleGetTrace } from "../tools/get-trace.js";
28
+ import { handleGetAnalytics } from "../tools/get-analytics.js";
29
+ import { handleListPrompts } from "../tools/list-prompts.js";
30
+ import { handleGetPrompt } from "../tools/get-prompt.js";
31
+ import { handleCreatePrompt } from "../tools/create-prompt.js";
32
+ import { handleUpdatePrompt } from "../tools/update-prompt.js";
33
+
34
+ const mockSearchTraces = vi.mocked(searchTraces);
35
+ const mockGetTraceById = vi.mocked(getTraceById);
36
+ const mockGetAnalytics = vi.mocked(getAnalyticsTimeseries);
37
+ const mockListPrompts = vi.mocked(listPrompts);
38
+ const mockGetPrompt = vi.mocked(getPrompt);
39
+ const mockCreatePrompt = vi.mocked(createPrompt);
40
+ const mockUpdatePrompt = vi.mocked(updatePrompt);
41
+ const mockCreatePromptVersion = vi.mocked(createPromptVersion);
42
+
43
+ beforeEach(() => {
44
+ vi.clearAllMocks();
45
+ });
46
+
47
+ describe("handleSearchTraces()", () => {
48
+ describe("when traces are found with formatted_trace (digest mode)", () => {
49
+ it("shows formatted digest per trace", async () => {
50
+ mockSearchTraces.mockResolvedValue({
51
+ traces: [
52
+ {
53
+ trace_id: "trace-1",
54
+ formatted_trace: "LLM Call [llm] 500ms\n Input: Hello\n Output: Hi",
55
+ input: { value: "Hello world" },
56
+ output: { value: "Hi there" },
57
+ timestamps: { started_at: "2024-01-01T00:00:00Z" },
58
+ },
59
+ ],
60
+ pagination: { totalHits: 1 },
61
+ });
62
+
63
+ const result = await handleSearchTraces({});
64
+
65
+ expect(result).toContain("Found 1 traces:");
66
+ expect(result).toContain("### Trace: trace-1");
67
+ expect(result).toContain("LLM Call [llm] 500ms");
68
+ expect(result).toContain("**Time**: 2024-01-01T00:00:00Z");
69
+ });
70
+ });
71
+
72
+ describe("when traces have no formatted_trace", () => {
73
+ it("falls back to input/output truncation", async () => {
74
+ mockSearchTraces.mockResolvedValue({
75
+ traces: [
76
+ {
77
+ trace_id: "trace-2",
78
+ input: { value: "Hello world" },
79
+ output: { value: "Hi there" },
80
+ },
81
+ ],
82
+ pagination: { totalHits: 1 },
83
+ });
84
+
85
+ const result = await handleSearchTraces({});
86
+
87
+ expect(result).toContain("**Input**: Hello world");
88
+ expect(result).toContain("**Output**: Hi there");
89
+ });
90
+
91
+ it("truncates long input/output to 100 characters", async () => {
92
+ const longText = "x".repeat(150);
93
+ mockSearchTraces.mockResolvedValue({
94
+ traces: [
95
+ {
96
+ trace_id: "trace-2",
97
+ input: { value: longText },
98
+ output: { value: longText },
99
+ },
100
+ ],
101
+ pagination: { totalHits: 1 },
102
+ });
103
+
104
+ const result = await handleSearchTraces({});
105
+
106
+ expect(result).toContain("x".repeat(100) + "...");
107
+ });
108
+ });
109
+
110
+ describe("when format is json", () => {
111
+ it("returns raw JSON string", async () => {
112
+ const responseData = {
113
+ traces: [{ trace_id: "trace-1", input: { value: "Hello" } }],
114
+ pagination: { totalHits: 1 },
115
+ };
116
+ mockSearchTraces.mockResolvedValue(responseData);
117
+
118
+ const result = await handleSearchTraces({ format: "json" });
119
+
120
+ expect(JSON.parse(result)).toEqual(responseData);
121
+ });
122
+ });
123
+
124
+ it("includes scroll ID when more results are available", async () => {
125
+ mockSearchTraces.mockResolvedValue({
126
+ traces: [{ trace_id: "trace-1", input: { value: "" }, output: { value: "" } }],
127
+ pagination: { totalHits: 100, scrollId: "scroll-abc" },
128
+ });
129
+
130
+ const result = await handleSearchTraces({});
131
+
132
+ expect(result).toContain('scrollId: "scroll-abc"');
133
+ });
134
+
135
+ it("shows error information when trace has errors", async () => {
136
+ mockSearchTraces.mockResolvedValue({
137
+ traces: [
138
+ {
139
+ trace_id: "trace-err",
140
+ input: { value: "" },
141
+ output: { value: "" },
142
+ error: { message: "timeout" },
143
+ },
144
+ ],
145
+ pagination: { totalHits: 1 },
146
+ });
147
+
148
+ const result = await handleSearchTraces({});
149
+
150
+ expect(result).toContain("**Error**");
151
+ expect(result).toContain("timeout");
152
+ });
153
+
154
+ describe("when no traces are found", () => {
155
+ it("returns a no-results message", async () => {
156
+ mockSearchTraces.mockResolvedValue({ traces: [] });
157
+
158
+ const result = await handleSearchTraces({});
159
+
160
+ expect(result).toBe("No traces found matching your query.");
161
+ });
162
+ });
163
+
164
+ describe("when relative dates are provided", () => {
165
+ it("passes parsed timestamps to the API", async () => {
166
+ mockSearchTraces.mockResolvedValue({ traces: [] });
167
+
168
+ await handleSearchTraces({ startDate: "7d", endDate: "1d" });
169
+
170
+ const call = mockSearchTraces.mock.calls[0]![0] as any;
171
+ expect(call.startDate).toBeTypeOf("number");
172
+ expect(call.endDate).toBeTypeOf("number");
173
+ expect(call.startDate).toBeLessThan(call.endDate);
174
+ });
175
+ });
176
+
177
+ describe("when pageSize is specified", () => {
178
+ it("passes pageSize to the API", async () => {
179
+ mockSearchTraces.mockResolvedValue({ traces: [] });
180
+
181
+ await handleSearchTraces({ pageSize: 50 });
182
+
183
+ const call = mockSearchTraces.mock.calls[0]![0] as any;
184
+ expect(call.pageSize).toBe(50);
185
+ });
186
+ });
187
+
188
+ describe("when pageSize is not specified", () => {
189
+ it("defaults to 25", async () => {
190
+ mockSearchTraces.mockResolvedValue({ traces: [] });
191
+
192
+ await handleSearchTraces({});
193
+
194
+ const call = mockSearchTraces.mock.calls[0]![0] as any;
195
+ expect(call.pageSize).toBe(25);
196
+ });
197
+ });
198
+
199
+ it("passes format to the API", async () => {
200
+ mockSearchTraces.mockResolvedValue({ traces: [] });
201
+
202
+ await handleSearchTraces({ format: "json" });
203
+
204
+ const call = mockSearchTraces.mock.calls[0]![0] as any;
205
+ expect(call.format).toBe("json");
206
+ });
207
+
208
+ it("defaults format to digest", async () => {
209
+ mockSearchTraces.mockResolvedValue({ traces: [] });
210
+
211
+ await handleSearchTraces({});
212
+
213
+ const call = mockSearchTraces.mock.calls[0]![0] as any;
214
+ expect(call.format).toBe("digest");
215
+ });
216
+
217
+ it("includes usage tip about get_trace, format, and discover_schema", async () => {
218
+ mockSearchTraces.mockResolvedValue({
219
+ traces: [{ trace_id: "t1", input: { value: "" }, output: { value: "" } }],
220
+ pagination: { totalHits: 1 },
221
+ });
222
+
223
+ const result = await handleSearchTraces({});
224
+
225
+ expect(result).toContain("get_trace");
226
+ expect(result).toContain("discover_schema");
227
+ expect(result).toContain('"json"');
228
+ });
229
+ });
230
+
231
+ describe("handleGetTrace()", () => {
232
+ describe("when trace has formatted_trace (digest mode)", () => {
233
+ it("shows the formatted digest", async () => {
234
+ mockGetTraceById.mockResolvedValue({
235
+ trace_id: "trace-abc",
236
+ formatted_trace: "Root [server] 1200ms\n LLM Call [llm] 800ms\n Input: Hello\n Output: Hi there",
237
+ timestamps: {
238
+ started_at: "2024-01-01T00:00:00Z",
239
+ updated_at: "2024-01-01T00:01:00Z",
240
+ },
241
+ metadata: { user_id: "user-123" },
242
+ evaluations: [
243
+ { name: "Toxicity", passed: true, score: 0.95 },
244
+ ],
245
+ });
246
+
247
+ const result = await handleGetTrace({ traceId: "trace-abc" });
248
+
249
+ expect(result).toContain("# Trace: trace-abc");
250
+ expect(result).toContain("**Started**: 2024-01-01T00:00:00Z");
251
+ expect(result).toContain("**User**: user-123");
252
+ expect(result).toContain("## Evaluations");
253
+ expect(result).toContain("**Toxicity**: PASSED (score: 0.95)");
254
+ expect(result).toContain("## Trace Details");
255
+ expect(result).toContain("Root [server] 1200ms");
256
+ expect(result).toContain("LLM Call [llm] 800ms");
257
+ });
258
+
259
+ it("includes tip about json format", async () => {
260
+ mockGetTraceById.mockResolvedValue({
261
+ trace_id: "trace-abc",
262
+ formatted_trace: "some digest",
263
+ });
264
+
265
+ const result = await handleGetTrace({ traceId: "trace-abc" });
266
+
267
+ expect(result).toContain('"json"');
268
+ expect(result).toContain("get_trace");
269
+ });
270
+ });
271
+
272
+ describe("when format is json", () => {
273
+ it("returns raw JSON string", async () => {
274
+ const responseData = {
275
+ trace_id: "trace-abc",
276
+ spans: [{ span_id: "s1", name: "LLM Call" }],
277
+ evaluations: [],
278
+ metadata: {},
279
+ };
280
+ mockGetTraceById.mockResolvedValue(responseData);
281
+
282
+ const result = await handleGetTrace({ traceId: "trace-abc", format: "json" });
283
+
284
+ expect(JSON.parse(result)).toEqual(responseData);
285
+ });
286
+
287
+ it("passes json format to the API", async () => {
288
+ mockGetTraceById.mockResolvedValue({ trace_id: "trace-abc" });
289
+
290
+ await handleGetTrace({ traceId: "trace-abc", format: "json" });
291
+
292
+ expect(mockGetTraceById).toHaveBeenCalledWith("trace-abc", "json");
293
+ });
294
+ });
295
+
296
+ describe("when trace has metadata fields", () => {
297
+ it("formats metadata fields", async () => {
298
+ mockGetTraceById.mockResolvedValue({
299
+ trace_id: "trace-abc",
300
+ metadata: {
301
+ user_id: "user-123",
302
+ thread_id: "thread-456",
303
+ customer_id: "cust-789",
304
+ labels: ["production", "important"],
305
+ },
306
+ });
307
+
308
+ const result = await handleGetTrace({ traceId: "trace-abc" });
309
+
310
+ expect(result).toContain("**User**: user-123");
311
+ expect(result).toContain("**Thread**: thread-456");
312
+ expect(result).toContain("**Customer**: cust-789");
313
+ expect(result).toContain("**Labels**: production, important");
314
+ });
315
+ });
316
+
317
+ describe("when trace has evaluations", () => {
318
+ it("formats evaluations", async () => {
319
+ mockGetTraceById.mockResolvedValue({
320
+ trace_id: "trace-abc",
321
+ evaluations: [
322
+ { name: "Toxicity", passed: true, score: 0.95 },
323
+ { evaluator_id: "eval-2", passed: false, label: "bad" },
324
+ ],
325
+ });
326
+
327
+ const result = await handleGetTrace({ traceId: "trace-abc" });
328
+
329
+ expect(result).toContain("## Evaluations");
330
+ expect(result).toContain("**Toxicity**: PASSED (score: 0.95)");
331
+ expect(result).toContain("**eval-2**: FAILED");
332
+ expect(result).toContain("[bad]");
333
+ });
334
+ });
335
+
336
+ describe("when trace has no formatted_trace", () => {
337
+ it("still renders header and metadata", async () => {
338
+ mockGetTraceById.mockResolvedValue({
339
+ trace_id: "trace-abc",
340
+ timestamps: { started_at: "2024-01-01" },
341
+ });
342
+
343
+ const result = await handleGetTrace({ traceId: "trace-abc" });
344
+
345
+ expect(result).toContain("# Trace: trace-abc");
346
+ expect(result).toContain("**Started**: 2024-01-01");
347
+ expect(result).not.toContain("## Trace Details");
348
+ });
349
+ });
350
+
351
+ describe("when digest format is used (default)", () => {
352
+ it("passes digest format to the API", async () => {
353
+ mockGetTraceById.mockResolvedValue({ trace_id: "trace-abc" });
354
+
355
+ await handleGetTrace({ traceId: "trace-abc" });
356
+
357
+ expect(mockGetTraceById).toHaveBeenCalledWith("trace-abc", "digest");
358
+ });
359
+ });
360
+ });
361
+
362
+ describe("handleGetAnalytics()", () => {
363
+ describe("when data is available", () => {
364
+ it("formats a markdown table with date and value", async () => {
365
+ mockGetAnalytics.mockResolvedValue({
366
+ currentPeriod: [
367
+ { date: "2024-01-01", metric_0: 42 },
368
+ { date: "2024-01-02", metric_0: 55 },
369
+ ],
370
+ previousPeriod: [],
371
+ });
372
+
373
+ const result = await handleGetAnalytics({
374
+ metric: "performance.completion_time",
375
+ });
376
+
377
+ expect(result).toContain(
378
+ "# Analytics: performance.completion_time (avg)"
379
+ );
380
+ expect(result).toContain("| Date | Value |");
381
+ expect(result).toContain("| 2024-01-01 | 42 |");
382
+ expect(result).toContain("| 2024-01-02 | 55 |");
383
+ });
384
+
385
+ it("shows groupBy when provided", async () => {
386
+ mockGetAnalytics.mockResolvedValue({ currentPeriod: [], previousPeriod: [] });
387
+
388
+ const result = await handleGetAnalytics({
389
+ metric: "metadata.trace_id",
390
+ groupBy: "metadata.model",
391
+ });
392
+
393
+ expect(result).toContain("Grouped by: metadata.model");
394
+ });
395
+
396
+ it("uses the specified aggregation", async () => {
397
+ mockGetAnalytics.mockResolvedValue({ currentPeriod: [], previousPeriod: [] });
398
+
399
+ const result = await handleGetAnalytics({
400
+ metric: "performance.total_cost",
401
+ aggregation: "sum",
402
+ });
403
+
404
+ expect(result).toContain("(sum)");
405
+ const call = mockGetAnalytics.mock.calls[0]![0] as any;
406
+ expect(call.series[0].aggregation).toBe("sum");
407
+ });
408
+ });
409
+
410
+ describe("when no data is available", () => {
411
+ it("returns a no-data message", async () => {
412
+ mockGetAnalytics.mockResolvedValue({ currentPeriod: [], previousPeriod: [] });
413
+
414
+ const result = await handleGetAnalytics({
415
+ metric: "performance.completion_time",
416
+ });
417
+
418
+ expect(result).toContain("No data available for this period.");
419
+ });
420
+ });
421
+
422
+ describe("when metric has no dot", () => {
423
+ it("defaults category to metadata", async () => {
424
+ mockGetAnalytics.mockResolvedValue({ currentPeriod: [], previousPeriod: [] });
425
+
426
+ await handleGetAnalytics({ metric: "trace_id" });
427
+
428
+ const call = mockGetAnalytics.mock.calls[0]![0] as any;
429
+ expect(call.series[0].metric).toBe("metadata.trace_id");
430
+ });
431
+ });
432
+
433
+ it("includes discover_schema tip", async () => {
434
+ mockGetAnalytics.mockResolvedValue({ currentPeriod: [], previousPeriod: [] });
435
+
436
+ const result = await handleGetAnalytics({
437
+ metric: "performance.completion_time",
438
+ });
439
+
440
+ expect(result).toContain("discover_schema");
441
+ });
442
+ });
443
+
444
+ describe("handleListPrompts()", () => {
445
+ describe("when prompts exist", () => {
446
+ it("formats a markdown table with prompt details", async () => {
447
+ mockListPrompts.mockResolvedValue([
448
+ {
449
+ handle: "greeting",
450
+ name: "Greeting Prompt",
451
+ latestVersionNumber: 3,
452
+ description: "A friendly greeting prompt",
453
+ },
454
+ {
455
+ id: "p2",
456
+ name: "Summary",
457
+ version: 1,
458
+ description: "",
459
+ },
460
+ ]);
461
+
462
+ const result = await handleListPrompts();
463
+
464
+ expect(result).toContain("# Prompts (2 total)");
465
+ expect(result).toContain("| Handle | Name | Latest Version | Description |");
466
+ expect(result).toContain("| greeting | Greeting Prompt | v3 | A friendly greeting prompt |");
467
+ expect(result).toContain("| p2 | Summary | v1 | |");
468
+ });
469
+ });
470
+
471
+ describe("when no prompts exist", () => {
472
+ it("returns a no-prompts message", async () => {
473
+ mockListPrompts.mockResolvedValue([]);
474
+
475
+ const result = await handleListPrompts();
476
+
477
+ expect(result).toBe("No prompts found in this project.");
478
+ });
479
+ });
480
+
481
+ describe("when API returns non-array", () => {
482
+ it("returns a no-prompts message", async () => {
483
+ mockListPrompts.mockResolvedValue(null as unknown as PromptSummary[]);
484
+
485
+ const result = await handleListPrompts();
486
+
487
+ expect(result).toBe("No prompts found in this project.");
488
+ });
489
+ });
490
+
491
+ it("includes usage tip about get_prompt", async () => {
492
+ mockListPrompts.mockResolvedValue([
493
+ { handle: "test", name: "Test", latestVersionNumber: 1 },
494
+ ]);
495
+
496
+ const result = await handleListPrompts();
497
+
498
+ expect(result).toContain("get_prompt");
499
+ });
500
+ });
501
+
502
+ describe("handleGetPrompt()", () => {
503
+ describe("when prompt has full details", () => {
504
+ it("formats the prompt header and metadata", async () => {
505
+ mockGetPrompt.mockResolvedValue({
506
+ id: "p1",
507
+ handle: "greeting",
508
+ name: "Greeting Prompt",
509
+ description: "A greeting",
510
+ latestVersionNumber: 2,
511
+ versions: [
512
+ {
513
+ version: 2,
514
+ model: "gpt-4o",
515
+ modelProvider: "openai",
516
+ messages: [
517
+ { role: "system", content: "You are a greeter." },
518
+ { role: "user", content: "Hello!" },
519
+ ],
520
+ commitMessage: "Updated greeting",
521
+ },
522
+ {
523
+ version: 1,
524
+ commitMessage: "Initial version",
525
+ },
526
+ ],
527
+ });
528
+
529
+ const result = await handleGetPrompt({ idOrHandle: "greeting" });
530
+
531
+ expect(result).toContain("# Prompt: Greeting Prompt");
532
+ expect(result).toContain("**Handle**: greeting");
533
+ expect(result).toContain("**ID**: p1");
534
+ expect(result).toContain("**Description**: A greeting");
535
+ expect(result).toContain("**Latest Version**: v2");
536
+ expect(result).toContain("**Model**: gpt-4o");
537
+ expect(result).toContain("**Provider**: openai");
538
+ });
539
+
540
+ it("formats messages", async () => {
541
+ mockGetPrompt.mockResolvedValue({
542
+ name: "Test",
543
+ versions: [
544
+ {
545
+ messages: [
546
+ { role: "system", content: "You are helpful." },
547
+ { role: "user", content: "Hi there" },
548
+ ],
549
+ },
550
+ ],
551
+ });
552
+
553
+ const result = await handleGetPrompt({ idOrHandle: "test" });
554
+
555
+ expect(result).toContain("## Messages");
556
+ expect(result).toContain("### system\nYou are helpful.");
557
+ expect(result).toContain("### user\nHi there");
558
+ });
559
+
560
+ it("formats version history", async () => {
561
+ mockGetPrompt.mockResolvedValue({
562
+ name: "Test",
563
+ versions: [
564
+ { version: 3, commitMessage: "Third update" },
565
+ { version: 2, commitMessage: "Second update" },
566
+ { version: 1, commitMessage: "Initial" },
567
+ ],
568
+ });
569
+
570
+ const result = await handleGetPrompt({ idOrHandle: "test" });
571
+
572
+ expect(result).toContain("## Version History");
573
+ expect(result).toContain("- **v3**: Third update");
574
+ expect(result).toContain("- **v2**: Second update");
575
+ expect(result).toContain("- **v1**: Initial");
576
+ });
577
+ });
578
+
579
+ describe("when prompt has more than 10 versions", () => {
580
+ it("truncates version history with a count", async () => {
581
+ const versions = Array.from({ length: 12 }, (_, i) => ({
582
+ version: 12 - i,
583
+ commitMessage: `Version ${12 - i}`,
584
+ }));
585
+
586
+ mockGetPrompt.mockResolvedValue({
587
+ name: "Test",
588
+ versions,
589
+ });
590
+
591
+ const result = await handleGetPrompt({ idOrHandle: "test" });
592
+
593
+ expect(result).toContain("... and 2 more versions");
594
+ });
595
+ });
596
+
597
+ describe("when prompt has no versions", () => {
598
+ it("uses prompt-level model config", async () => {
599
+ mockGetPrompt.mockResolvedValue({
600
+ name: "Simple",
601
+ model: "gpt-3.5-turbo",
602
+ modelProvider: "openai",
603
+ messages: [{ role: "system", content: "Be brief." }],
604
+ });
605
+
606
+ const result = await handleGetPrompt({ idOrHandle: "simple" });
607
+
608
+ expect(result).toContain("**Model**: gpt-3.5-turbo");
609
+ expect(result).toContain("**Provider**: openai");
610
+ expect(result).toContain("### system\nBe brief.");
611
+ });
612
+ });
613
+ });
614
+
615
+ describe("handleCreatePrompt()", () => {
616
+ describe("when prompt is created successfully", () => {
617
+ it("formats a success message with details", async () => {
618
+ mockCreatePrompt.mockResolvedValue({
619
+ id: "new-id-123",
620
+ handle: "my-prompt",
621
+ name: "My Prompt",
622
+ latestVersionNumber: 1,
623
+ });
624
+
625
+ const result = await handleCreatePrompt({
626
+ name: "My Prompt",
627
+ handle: "my-prompt",
628
+ messages: [{ role: "system", content: "You are helpful." }],
629
+ model: "gpt-4o",
630
+ modelProvider: "openai",
631
+ });
632
+
633
+ expect(result).toContain("Prompt created successfully!");
634
+ expect(result).toContain("**ID**: new-id-123");
635
+ expect(result).toContain("**Handle**: my-prompt");
636
+ expect(result).toContain("**Name**: My Prompt");
637
+ expect(result).toContain("**Model**: gpt-4o (openai)");
638
+ expect(result).toContain("**Version**: v1");
639
+ });
640
+ });
641
+
642
+ describe("when API returns no name", () => {
643
+ it("uses the input name as fallback", async () => {
644
+ mockCreatePrompt.mockResolvedValue({ id: "p1" });
645
+
646
+ const result = await handleCreatePrompt({
647
+ name: "Fallback Name",
648
+ messages: [{ role: "system", content: "test" }],
649
+ model: "gpt-4o",
650
+ modelProvider: "openai",
651
+ });
652
+
653
+ expect(result).toContain("**Name**: Fallback Name");
654
+ });
655
+ });
656
+ });
657
+
658
+ describe("handleUpdatePrompt()", () => {
659
+ describe("when updating in place", () => {
660
+ it("formats an update success message", async () => {
661
+ mockUpdatePrompt.mockResolvedValue({
662
+ id: "p1",
663
+ handle: "greeting",
664
+ latestVersionNumber: 2,
665
+ });
666
+
667
+ const result = await handleUpdatePrompt({
668
+ idOrHandle: "greeting",
669
+ messages: [{ role: "system", content: "Updated content" }],
670
+ commitMessage: "Update system prompt",
671
+ });
672
+
673
+ expect(result).toContain("Prompt updated successfully!");
674
+ expect(result).toContain("**ID**: p1");
675
+ expect(result).toContain("**Handle**: greeting");
676
+ expect(result).toContain("**Version**: v2");
677
+ expect(result).toContain("**Commit**: Update system prompt");
678
+ });
679
+
680
+ it("calls updatePrompt API", async () => {
681
+ mockUpdatePrompt.mockResolvedValue({});
682
+
683
+ await handleUpdatePrompt({
684
+ idOrHandle: "greeting",
685
+ model: "gpt-4o",
686
+ });
687
+
688
+ expect(mockUpdatePrompt).toHaveBeenCalledWith("greeting", {
689
+ model: "gpt-4o",
690
+ });
691
+ expect(mockCreatePromptVersion).not.toHaveBeenCalled();
692
+ });
693
+ });
694
+
695
+ describe("when creating a new version", () => {
696
+ it("formats a version creation success message", async () => {
697
+ mockCreatePromptVersion.mockResolvedValue({
698
+ id: "p1",
699
+ latestVersionNumber: 3,
700
+ });
701
+
702
+ const result = await handleUpdatePrompt({
703
+ idOrHandle: "greeting",
704
+ messages: [{ role: "system", content: "New version" }],
705
+ createVersion: true,
706
+ commitMessage: "v3",
707
+ });
708
+
709
+ expect(result).toContain("New version created successfully!");
710
+ expect(result).toContain("**Version**: v3");
711
+ expect(result).toContain("**Commit**: v3");
712
+ });
713
+
714
+ it("calls createPromptVersion API", async () => {
715
+ mockCreatePromptVersion.mockResolvedValue({});
716
+
717
+ await handleUpdatePrompt({
718
+ idOrHandle: "greeting",
719
+ messages: [{ role: "system", content: "New" }],
720
+ createVersion: true,
721
+ });
722
+
723
+ expect(mockCreatePromptVersion).toHaveBeenCalledWith("greeting", {
724
+ messages: [{ role: "system", content: "New" }],
725
+ });
726
+ expect(mockUpdatePrompt).not.toHaveBeenCalled();
727
+ });
728
+ });
729
+ });