@langwatch/mcp-server 0.8.0 → 0.10.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 (121) hide show
  1. package/CHANGELOG.md +40 -0
  2. package/LICENSE +7 -0
  3. package/dist/archive-project-HZBRPR2S.js +19 -0
  4. package/dist/{archive-scenario-YKGLJ4MZ.js → archive-scenario-P5RRITRM.js} +3 -3
  5. package/dist/{archive-suite-QFNDO3Y3.js → archive-suite-NUUFD2EM.js} +3 -3
  6. package/dist/{assign-prompt-tag-Y2B4CBBT.js → assign-prompt-tag-FKCCZPML.js} +2 -2
  7. package/dist/{chunk-YH2IBTEL.js → chunk-42ZHHHIJ.js} +14 -3
  8. package/dist/chunk-ADSJ2FDA.js +44 -0
  9. package/dist/{chunk-L6QENXYA.js → chunk-BB7EOKHR.js} +2 -2
  10. package/dist/chunk-BBJ5WAJB.js +24 -0
  11. package/dist/{chunk-PZUWCWOS.js → chunk-BYWYM2RP.js} +2 -2
  12. package/dist/{chunk-NACSR6B7.js → chunk-CCM4ZFGF.js} +2 -2
  13. package/dist/{chunk-FBRKHHJV.js → chunk-CHKCEZBP.js} +2 -2
  14. package/dist/{chunk-B57MZ4WI.js → chunk-FVDAZ4XG.js} +763 -90
  15. package/dist/{chunk-PO7FM7IX.js → chunk-HKY3GW45.js} +2 -2
  16. package/dist/{chunk-KZNBCKWG.js → chunk-JHOSVOHT.js} +2 -2
  17. package/dist/{chunk-HDJDFKZR.js → chunk-R7C7OUVG.js} +2 -2
  18. package/dist/{chunk-OVQF3D5R.js → chunk-TROIWSEQ.js} +2 -2
  19. package/dist/{chunk-CPUPGDNA.js → chunk-X2SEURT2.js} +2 -2
  20. package/dist/{chunk-YI3OG4PI.js → chunk-XC62KJIT.js} +3 -3
  21. package/dist/{create-agent-YRSDH7P7.js → create-agent-K5Q7BFGZ.js} +3 -3
  22. package/dist/{create-annotation-FYQNKMVJ.js → create-annotation-KN3GE3DK.js} +3 -3
  23. package/dist/create-api-key-OQI5PUNQ.js +26 -0
  24. package/dist/{create-dashboard-CORBR4YQ.js → create-dashboard-G4QA5AXP.js} +3 -3
  25. package/dist/{create-dataset-ZC63T6BB.js → create-dataset-WQSC37N7.js} +3 -3
  26. package/dist/{create-dataset-records-ILVNVKVR.js → create-dataset-records-B4RL3FLC.js} +3 -3
  27. package/dist/{create-evaluator-2AU3PXFB.js → create-evaluator-YFFQIR7E.js} +3 -3
  28. package/dist/create-mcp-server.d.ts +3 -1
  29. package/dist/create-mcp-server.js +2 -1
  30. package/dist/create-project-CUVQ67VN.js +44 -0
  31. package/dist/{create-prompt-NKRJ47YY.js → create-prompt-NGXKSP4X.js} +2 -2
  32. package/dist/{create-prompt-tag-S4JX3ULL.js → create-prompt-tag-IUKYPVHH.js} +2 -2
  33. package/dist/{create-scenario-BPRF75KF.js → create-scenario-FNBXALH3.js} +3 -3
  34. package/dist/{create-suite-4YN7D6K2.js → create-suite-DJEPD4ZB.js} +3 -3
  35. package/dist/{delete-agent-Y72R2YQA.js → delete-agent-TJI5XBBM.js} +3 -3
  36. package/dist/{delete-annotation-UHWDMHSH.js → delete-annotation-KRP53TUU.js} +3 -3
  37. package/dist/{delete-dashboard-LPWT36KN.js → delete-dashboard-N5SZJIR3.js} +3 -3
  38. package/dist/{delete-dataset-GGPQRHKX.js → delete-dataset-KDUOHL2P.js} +3 -3
  39. package/dist/{delete-dataset-records-LTG5LT26.js → delete-dataset-records-DM4W3PAW.js} +3 -3
  40. package/dist/{delete-evaluator-F7QLXDHU.js → delete-evaluator-7ZATLJOA.js} +3 -3
  41. package/dist/{delete-prompt-tag-B5UKZL45.js → delete-prompt-tag-I6HNMSVJ.js} +2 -2
  42. package/dist/{delete-workflow-4DGUDIDE.js → delete-workflow-QEO74JQR.js} +3 -3
  43. package/dist/{get-agent-YEVICUX4.js → get-agent-3KDWCP5N.js} +3 -3
  44. package/dist/{get-analytics-3W5CMU3F.js → get-analytics-GPVJX7SF.js} +2 -2
  45. package/dist/{get-dashboard-NCQHN6MW.js → get-dashboard-YPTIE5YY.js} +3 -3
  46. package/dist/{get-dataset-KBK6PH36.js → get-dataset-6DQFYITM.js} +3 -3
  47. package/dist/{get-evaluator-QDUL5LXR.js → get-evaluator-PCM6XQAU.js} +3 -3
  48. package/dist/get-project-JCMWXVLU.js +26 -0
  49. package/dist/{get-prompt-XCSSNRKR.js → get-prompt-5XAGWWBG.js} +2 -2
  50. package/dist/{get-scenario-FNRKOY7E.js → get-scenario-7XBCDZWX.js} +3 -3
  51. package/dist/{get-simulation-run-WOEKUF7K.js → get-simulation-run-AV24FJWI.js} +3 -3
  52. package/dist/{get-suite-U5AOD5TC.js → get-suite-4DBVHFEM.js} +3 -3
  53. package/dist/{get-trace-MUWEJM2Z.js → get-trace-MRBDS3HJ.js} +2 -2
  54. package/dist/{get-workflow-SOA7MT7L.js → get-workflow-UR4Y6UFX.js} +3 -3
  55. package/dist/{http-server-7DMOCB75.js → http-server-XUQLIFE7.js} +5 -3
  56. package/dist/index.js +1175 -222
  57. package/dist/{langwatch-api-annotations-Q57NDUVR.js → langwatch-api-annotations-ZJDRB4JH.js} +3 -3
  58. package/dist/{langwatch-api-dashboards-VBWWXV3Y.js → langwatch-api-dashboards-WARLI6NF.js} +3 -3
  59. package/dist/{langwatch-api-monitors-L3IY7FDT.js → langwatch-api-monitors-4XKXHYCG.js} +2 -2
  60. package/dist/{langwatch-api-secrets-S5HHF3XR.js → langwatch-api-secrets-6SHK5G7Q.js} +2 -2
  61. package/dist/{langwatch-api-triggers-ME33QE26.js → langwatch-api-triggers-5SX6R2WU.js} +2 -2
  62. package/dist/{langwatch-api-workflows-4CJQAYP2.js → langwatch-api-workflows-NLJENNXE.js} +3 -3
  63. package/dist/{list-agents-3HGIBJFM.js → list-agents-NECO7MPI.js} +3 -3
  64. package/dist/{list-annotations-KWXYVEPI.js → list-annotations-3OJEO774.js} +3 -3
  65. package/dist/list-api-keys-AWW7ROLB.js +37 -0
  66. package/dist/{list-dashboards-VQ2LH666.js → list-dashboards-DVPVVVKW.js} +3 -3
  67. package/dist/{list-dataset-records-RNRGROJP.js → list-dataset-records-I4BYORF5.js} +3 -3
  68. package/dist/{list-datasets-I4A2LT2S.js → list-datasets-WUAE5HGX.js} +3 -3
  69. package/dist/{list-evaluators-AKKRJ5MX.js → list-evaluators-B6OSVAM2.js} +3 -3
  70. package/dist/{list-model-providers-DVFEHFHZ.js → list-model-providers-NL6IP4UZ.js} +3 -3
  71. package/dist/list-projects-5FHMJ4PQ.js +42 -0
  72. package/dist/{list-prompt-tags-PL5OISUA.js → list-prompt-tags-Z2DD6FLQ.js} +2 -2
  73. package/dist/{list-prompts-7VTFWE2Z.js → list-prompts-VNXKHK7T.js} +2 -2
  74. package/dist/{list-scenarios-5BZWZ6ZN.js → list-scenarios-CFZHXL76.js} +3 -3
  75. package/dist/{list-simulation-runs-WCZ6RLPE.js → list-simulation-runs-AM2BXLBW.js} +3 -3
  76. package/dist/{list-suites-RRQ2JX5Y.js → list-suites-ZDOHAAXF.js} +3 -3
  77. package/dist/{list-workflows-UQB5FCX5.js → list-workflows-WBRBKLIT.js} +3 -3
  78. package/dist/{rename-prompt-tag-TURD6KYU.js → rename-prompt-tag-A4RJUK7Z.js} +2 -2
  79. package/dist/revoke-api-key-ZSPSPF5G.js +15 -0
  80. package/dist/{run-agent-BEJ4K4CM.js → run-agent-JFNDY7MR.js} +3 -3
  81. package/dist/{run-suite-U7V432G2.js → run-suite-TH7LO5TJ.js} +3 -3
  82. package/dist/{run-workflow-5WX7DK5T.js → run-workflow-CK3JP5U2.js} +3 -3
  83. package/dist/{search-traces-ZCHNOMTX.js → search-traces-3BBVYPC3.js} +2 -2
  84. package/dist/{set-model-provider-6CFHMCNE.js → set-model-provider-WOC5FNMV.js} +3 -3
  85. package/dist/{update-agent-UE6UWLZP.js → update-agent-YMEV5MTL.js} +3 -3
  86. package/dist/{update-dataset-4XQZZE4S.js → update-dataset-EYT5POFM.js} +3 -3
  87. package/dist/{update-dataset-record-GNNM5TKM.js → update-dataset-record-VFF5JTX4.js} +3 -3
  88. package/dist/{update-evaluator-LHVDXIZD.js → update-evaluator-6IJHFG2E.js} +3 -3
  89. package/dist/update-project-OIT4WPFI.js +24 -0
  90. package/dist/{update-prompt-3XEHECJ7.js → update-prompt-ZU2DD6KM.js} +2 -2
  91. package/dist/{update-scenario-DFPREYCD.js → update-scenario-3VUTPDRU.js} +3 -3
  92. package/dist/{update-suite-RATU5THO.js → update-suite-C6JFDQHL.js} +3 -3
  93. package/package.json +2 -2
  94. package/pnpm-workspace.yaml +7 -0
  95. package/pyproject.toml +3 -0
  96. package/src/__tests__/experiment-list-tools.unit.test.ts +157 -0
  97. package/src/__tests__/experiment-status.unit.test.ts +194 -0
  98. package/src/__tests__/get-experiment-results.unit.test.ts +313 -0
  99. package/src/__tests__/project-api-key-tools.unit.test.ts +451 -0
  100. package/src/create-mcp-server.d.ts +3 -1
  101. package/src/create-mcp-server.ts +263 -13
  102. package/src/http-server.ts +5 -0
  103. package/src/langwatch-api-api-keys.ts +54 -0
  104. package/src/langwatch-api-projects.ts +82 -0
  105. package/src/langwatch-api.ts +17 -3
  106. package/src/tools/archive-project.ts +14 -0
  107. package/src/tools/create-api-key.ts +29 -0
  108. package/src/tools/create-project.ts +45 -0
  109. package/src/tools/experiment-run-status.ts +36 -0
  110. package/src/tools/get-experiment-results.ts +306 -0
  111. package/src/tools/get-project.ts +20 -0
  112. package/src/tools/list-api-keys.ts +33 -0
  113. package/src/tools/list-experiment-runs.ts +142 -0
  114. package/src/tools/list-experiments.ts +95 -0
  115. package/src/tools/list-projects.ts +41 -0
  116. package/src/tools/revoke-api-key.ts +9 -0
  117. package/src/tools/run-experiment.ts +182 -0
  118. package/src/tools/update-project.ts +22 -0
  119. package/uv.lock +7 -3
  120. package/dist/run-evaluation-AJ7WZNQD.js +0 -64
  121. package/src/tools/run-evaluation.ts +0 -87
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@langwatch/mcp-server",
3
- "version": "0.8.0",
3
+ "version": "0.10.0",
4
4
  "description": "An MCP server for Langwatch.",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
@@ -45,7 +45,7 @@
45
45
  "@types/node": "^25.6.0",
46
46
  "@types/yargs": "^17.0.33",
47
47
  "@typescript/native-preview": "7.0.0-dev.20250911.1",
48
- "ai": "^6.0.0",
48
+ "ai": "^6.0.161",
49
49
  "dotenv": "^17.2.2",
50
50
  "esbuild": "^0.25.0",
51
51
  "eslint": "^10.2.1",
@@ -1,2 +1,9 @@
1
+ minimumReleaseAge: 10080
2
+
3
+ overrides:
4
+ "protobufjs": ">=7.5.6 <8.0.0 || >=8.0.2"
5
+ "langsmith@<0.6.0": "0.6.0"
6
+ "fast-uri@<3.1.2": "3.1.2"
7
+
1
8
  onlyBuiltDependencies:
2
9
  - node-pty
package/pyproject.toml CHANGED
@@ -13,5 +13,8 @@ dependencies = [
13
13
  "tenacity>=9.1.2",
14
14
  ]
15
15
 
16
+ [tool.uv]
17
+ exclude-newer = "7 days"
18
+
16
19
  [tool.uv.sources]
17
20
  langwatch = { path = "../python-sdk", editable = true }
@@ -0,0 +1,157 @@
1
+ import { describe, it, expect, vi, beforeEach } from "vitest";
2
+
3
+ vi.mock("../langwatch-api.js", async (importOriginal) => {
4
+ const actual = (await importOriginal()) as Record<string, unknown>;
5
+ return {
6
+ ...actual,
7
+ makeRequest: vi.fn(),
8
+ };
9
+ });
10
+
11
+ import { makeRequest } from "../langwatch-api.js";
12
+ import { handleExperimentList } from "../tools/list-experiments.js";
13
+ import { handleExperimentListRuns } from "../tools/list-experiment-runs.js";
14
+
15
+ const mockMakeRequest = vi.mocked(makeRequest);
16
+
17
+ describe("handleExperimentList()", () => {
18
+ beforeEach(() => {
19
+ vi.clearAllMocks();
20
+ });
21
+
22
+ describe("given experiments exist", () => {
23
+ describe("when invoked with default limit", () => {
24
+ /** @scenario "Lists experiments as markdown" */
25
+ it("renders a markdown table with each slug", async () => {
26
+ mockMakeRequest.mockResolvedValueOnce({
27
+ experiments: [
28
+ {
29
+ id: "exp_1",
30
+ slug: "checkout-flow",
31
+ name: "Checkout Flow",
32
+ type: "EVALUATIONS_V3",
33
+ workflowId: null,
34
+ createdAt: "2026-01-01T00:00:00Z",
35
+ updatedAt: "2026-01-02T00:00:00Z",
36
+ runsCount: 3,
37
+ lastRunAt: "2026-01-02T00:00:00Z",
38
+ },
39
+ ],
40
+ pagination: {
41
+ page: 1,
42
+ pageSize: 25,
43
+ totalHits: 1,
44
+ hasMore: false,
45
+ },
46
+ });
47
+
48
+ const out = await handleExperimentList({});
49
+ expect(out).toContain("# Experiments");
50
+ expect(out).toContain("`checkout-flow`");
51
+ expect(out).toContain("Checkout Flow");
52
+ });
53
+ });
54
+
55
+ describe("when invoked with limit above the cap", () => {
56
+ /** @scenario "Limit is bounded to protect agent context" */
57
+ it("clamps the effective pageSize to 100", async () => {
58
+ mockMakeRequest.mockResolvedValueOnce({
59
+ experiments: [],
60
+ pagination: { page: 1, pageSize: 100, totalHits: 0, hasMore: false },
61
+ });
62
+
63
+ await handleExperimentList({ limit: 5000 });
64
+ const path = mockMakeRequest.mock.calls[0]![1] as string;
65
+ expect(path).toContain("pageSize=100");
66
+ });
67
+ });
68
+ });
69
+
70
+ describe("given no experiments exist", () => {
71
+ describe("when invoked", () => {
72
+ it("returns an empty-state message", async () => {
73
+ mockMakeRequest.mockResolvedValueOnce({
74
+ experiments: [],
75
+ pagination: { page: 1, pageSize: 25, totalHits: 0, hasMore: false },
76
+ });
77
+ const out = await handleExperimentList({});
78
+ expect(out).toContain("No experiments");
79
+ });
80
+ });
81
+ });
82
+ });
83
+
84
+ describe("handleExperimentListRuns()", () => {
85
+ beforeEach(() => {
86
+ vi.clearAllMocks();
87
+ });
88
+
89
+ describe("given a known experiment with runs", () => {
90
+ describe("when invoked", () => {
91
+ /** @scenario "Lists runs for a known experiment" */
92
+ it("renders a markdown table with each runId", async () => {
93
+ mockMakeRequest.mockResolvedValueOnce({
94
+ experimentId: "exp_1",
95
+ experimentSlug: "checkout-flow",
96
+ runs: [
97
+ {
98
+ experimentId: "exp_1",
99
+ runId: "run_visible",
100
+ workflowVersion: null,
101
+ timestamps: { createdAt: 1000, updatedAt: 2000, finishedAt: 2000 },
102
+ summary: { evaluations: {} },
103
+ },
104
+ ],
105
+ pagination: {
106
+ page: 1,
107
+ pageSize: 25,
108
+ totalHits: 1,
109
+ hasMore: false,
110
+ },
111
+ });
112
+
113
+ const out = await handleExperimentListRuns({
114
+ experimentSlug: "checkout-flow",
115
+ });
116
+ expect(out).toContain("# Evaluation Runs: checkout-flow");
117
+ expect(out).toContain("`run_visible`");
118
+ });
119
+ });
120
+ });
121
+
122
+ describe("given the experiment slug is unknown", () => {
123
+ describe("when the API throws 404", () => {
124
+ /** @scenario "Unknown experiment slug returns a graceful not-found message" */
125
+ it("returns a graceful not-found message suggesting platform_experiment_list", async () => {
126
+ mockMakeRequest.mockRejectedValueOnce(
127
+ new Error("LangWatch API error 404: experiment not found"),
128
+ );
129
+
130
+ const out = await handleExperimentListRuns({
131
+ experimentSlug: "does-not-exist",
132
+ });
133
+ expect(out).toContain("not found");
134
+ expect(out).toContain("platform_experiment_list");
135
+ });
136
+ });
137
+ });
138
+
139
+ describe("given the experiment exists but has no runs", () => {
140
+ describe("when invoked", () => {
141
+ it("returns an empty-state message that points at platform_run_experiment", async () => {
142
+ mockMakeRequest.mockResolvedValueOnce({
143
+ experimentId: "exp_1",
144
+ experimentSlug: "support-bot",
145
+ runs: [],
146
+ pagination: { page: 1, pageSize: 25, totalHits: 0, hasMore: false },
147
+ });
148
+
149
+ const out = await handleExperimentListRuns({
150
+ experimentSlug: "support-bot",
151
+ });
152
+ expect(out).toContain("no runs yet");
153
+ expect(out).toContain("platform_run_experiment");
154
+ });
155
+ });
156
+ });
157
+ });
@@ -0,0 +1,194 @@
1
+ import { describe, it, expect, vi, beforeEach } from "vitest";
2
+
3
+ vi.mock("../langwatch-api.js", async (importOriginal) => {
4
+ const actual = (await importOriginal()) as Record<string, unknown>;
5
+ return {
6
+ ...actual,
7
+ makeRequest: vi.fn(),
8
+ };
9
+ });
10
+
11
+ import { LangWatchApiError, makeRequest } from "../langwatch-api.js";
12
+ import { handleExperimentStatus } from "../tools/run-experiment.js";
13
+ import {
14
+ deriveRunStatus,
15
+ isTerminalStatus,
16
+ } from "../tools/experiment-run-status.js";
17
+
18
+ const mockMakeRequest = vi.mocked(makeRequest);
19
+
20
+ beforeEach(() => {
21
+ vi.clearAllMocks();
22
+ });
23
+
24
+ describe("deriveRunStatus()", () => {
25
+ describe("when stoppedAt is set", () => {
26
+ it("is stopped", () => {
27
+ expect(deriveRunStatus({ stoppedAt: 5 })).toBe("stopped");
28
+ });
29
+ });
30
+
31
+ describe("when finishedAt is set", () => {
32
+ it("is completed", () => {
33
+ expect(deriveRunStatus({ finishedAt: 5 })).toBe("completed");
34
+ });
35
+ });
36
+
37
+ describe("when no terminal marker and updates are recent", () => {
38
+ it("is running", () => {
39
+ const now = 1_000_000;
40
+ expect(deriveRunStatus({ updatedAt: now - 1000 }, now)).toBe("running");
41
+ });
42
+ });
43
+
44
+ describe("when no terminal marker and no recent updates", () => {
45
+ it("is interrupted", () => {
46
+ const now = 1_000_000;
47
+ expect(deriveRunStatus({ updatedAt: now - 6 * 60 * 1000 }, now)).toBe(
48
+ "interrupted",
49
+ );
50
+ });
51
+ });
52
+
53
+ describe("terminal classification", () => {
54
+ it("treats completed and stopped as terminal", () => {
55
+ expect(isTerminalStatus("completed")).toBe(true);
56
+ expect(isTerminalStatus("stopped")).toBe(true);
57
+ expect(isTerminalStatus("running")).toBe(false);
58
+ expect(isTerminalStatus("interrupted")).toBe(false);
59
+ });
60
+ });
61
+ });
62
+
63
+ describe("handleExperimentStatus()", () => {
64
+ describe("given the run state exists in Redis", () => {
65
+ it("reports the status straight from the status endpoint", async () => {
66
+ mockMakeRequest.mockResolvedValueOnce({
67
+ runId: "run_1",
68
+ status: "completed",
69
+ progress: 3,
70
+ total: 3,
71
+ });
72
+
73
+ const out = await handleExperimentStatus({ runId: "run_1" });
74
+
75
+ expect(mockMakeRequest).toHaveBeenCalledWith(
76
+ "GET",
77
+ "/api/experiments/runs/run_1",
78
+ );
79
+ expect(out).toContain("**Status**: completed");
80
+ expect(out).toContain("3/3 cells");
81
+ });
82
+ });
83
+
84
+ describe("given an SDK-logged run with no Redis state", () => {
85
+ it("falls back to deriving status from the results endpoint", async () => {
86
+ mockMakeRequest
87
+ .mockRejectedValueOnce(
88
+ new LangWatchApiError("missing", 404, "Run not found"),
89
+ )
90
+ .mockResolvedValueOnce({
91
+ progress: 5,
92
+ total: 5,
93
+ dataset: [1, 2, 3, 4, 5],
94
+ timestamps: {
95
+ createdAt: 1,
96
+ updatedAt: 2,
97
+ finishedAt: 3,
98
+ stoppedAt: null,
99
+ },
100
+ });
101
+
102
+ const out = await handleExperimentStatus({
103
+ runId: "sdk_run",
104
+ experimentSlug: "doc-qa",
105
+ });
106
+
107
+ expect(mockMakeRequest).toHaveBeenNthCalledWith(
108
+ 1,
109
+ "GET",
110
+ "/api/experiments/runs/sdk_run",
111
+ );
112
+ expect(mockMakeRequest).toHaveBeenNthCalledWith(
113
+ 2,
114
+ "GET",
115
+ "/api/experiments/runs/sdk_run/results?experimentSlug=doc-qa",
116
+ );
117
+ expect(out).toContain("**Status**: completed");
118
+ expect(out).toContain("5/5 cells");
119
+ expect(out).toContain("platform_experiment_results");
120
+ });
121
+
122
+ it("reports interrupted for a stale unfinished SDK run", async () => {
123
+ mockMakeRequest
124
+ .mockRejectedValueOnce(
125
+ new LangWatchApiError("missing", 404, "Run not found"),
126
+ )
127
+ .mockResolvedValueOnce({
128
+ progress: 2,
129
+ total: 5,
130
+ dataset: [1, 2],
131
+ timestamps: {
132
+ createdAt: Date.now() - 60 * 60 * 1000,
133
+ updatedAt: Date.now() - 30 * 60 * 1000,
134
+ finishedAt: null,
135
+ stoppedAt: null,
136
+ },
137
+ });
138
+
139
+ const out = await handleExperimentStatus({
140
+ runId: "sdk_run",
141
+ experimentSlug: "doc-qa",
142
+ });
143
+
144
+ expect(out).toContain("**Status**: interrupted");
145
+ expect(out).toContain("2/5 cells");
146
+ });
147
+ });
148
+
149
+ describe("given the run cannot be found anywhere", () => {
150
+ it("returns actionable guidance instead of a raw 404", async () => {
151
+ mockMakeRequest
152
+ .mockRejectedValueOnce(
153
+ new LangWatchApiError("missing", 404, "Run not found"),
154
+ )
155
+ .mockRejectedValueOnce(
156
+ new LangWatchApiError("missing", 404, "Run not found"),
157
+ );
158
+
159
+ const out = await handleExperimentStatus({
160
+ runId: "nope",
161
+ experimentSlug: "doc-qa",
162
+ });
163
+
164
+ expect(out).toContain("not found");
165
+ expect(out).toContain("experimentSlug");
166
+ expect(out).toContain("platform_experiment_results");
167
+ });
168
+
169
+ it("rethrows non-404 errors from the status endpoint", async () => {
170
+ mockMakeRequest.mockRejectedValueOnce(
171
+ new LangWatchApiError("boom", 500, "Internal error"),
172
+ );
173
+
174
+ await expect(
175
+ handleExperimentStatus({ runId: "nope" }),
176
+ ).rejects.toThrow();
177
+ });
178
+
179
+ it("propagates a real (non-404) error from the results fallback instead of faking not-found", async () => {
180
+ mockMakeRequest
181
+ .mockRejectedValueOnce(
182
+ new LangWatchApiError("missing", 404, "Run not found"),
183
+ )
184
+ .mockRejectedValueOnce(
185
+ new LangWatchApiError("boom", 500, "Internal error"),
186
+ );
187
+
188
+ // The fallback's 500 ("boom") must surface, not the original 404 ("missing").
189
+ await expect(
190
+ handleExperimentStatus({ runId: "sdk_run", experimentSlug: "doc-qa" }),
191
+ ).rejects.toThrow(/boom/);
192
+ });
193
+ });
194
+ });
@@ -0,0 +1,313 @@
1
+ import { describe, it, expect, vi, beforeEach } from "vitest";
2
+
3
+ vi.mock("../langwatch-api.js", async (importOriginal) => {
4
+ const actual = (await importOriginal()) as Record<string, unknown>;
5
+ return {
6
+ ...actual,
7
+ makeRequest: vi.fn(),
8
+ };
9
+ });
10
+
11
+ import { LangWatchApiError, makeRequest } from "../langwatch-api.js";
12
+ import { handleExperimentResults } from "../tools/get-experiment-results.js";
13
+
14
+ const mockMakeRequest = vi.mocked(makeRequest);
15
+
16
+ const sample = {
17
+ experimentId: "exp_1",
18
+ runId: "run_1",
19
+ projectId: "proj_1",
20
+ progress: 3,
21
+ total: 3,
22
+ dataset: [
23
+ { index: 0, entry: { input: "hello world" } },
24
+ { index: 1, entry: { input: "broken" }, error: "boom" },
25
+ { index: 2, entry: { input: "low" } },
26
+ ],
27
+ evaluations: [
28
+ { evaluator: "quality", index: 0, status: "processed", score: 0.9, passed: true },
29
+ { evaluator: "quality", index: 2, status: "processed", score: 0.2, passed: false, details: "off-topic" },
30
+ { evaluator: "safety", index: 0, status: "processed", score: 1.0, passed: true },
31
+ ],
32
+ timestamps: { createdAt: 0, updatedAt: 0, finishedAt: 1 },
33
+ };
34
+
35
+ beforeEach(() => {
36
+ vi.clearAllMocks();
37
+ });
38
+
39
+ describe("handleExperimentResults()", () => {
40
+ describe("given a completed run", () => {
41
+ describe("when called without filters", () => {
42
+ it("hits the v3 results endpoint with the run id", async () => {
43
+ mockMakeRequest.mockResolvedValueOnce(sample);
44
+ await handleExperimentResults({ runId: "run_1" });
45
+ expect(mockMakeRequest).toHaveBeenCalledWith(
46
+ "GET",
47
+ "/api/experiments/runs/run_1/results",
48
+ );
49
+ });
50
+
51
+ it("renders evaluator averages and per-row sections in markdown", async () => {
52
+ mockMakeRequest.mockResolvedValueOnce(sample);
53
+ const out = await handleExperimentResults({ runId: "run_1" });
54
+ expect(out).toContain("# Evaluation Results: run_1");
55
+ expect(out).toContain("## Evaluator Summary");
56
+ expect(out).toContain("quality");
57
+ expect(out).toContain("safety");
58
+ expect(out).toContain("Row #0");
59
+ expect(out).toContain("Row #1");
60
+ expect(out).toContain("Row #2");
61
+ });
62
+ });
63
+
64
+ describe("when filter is 'failed'", () => {
65
+ it("includes only failed/errored rows", async () => {
66
+ mockMakeRequest.mockResolvedValueOnce(sample);
67
+ const out = await handleExperimentResults({
68
+ runId: "run_1",
69
+ filter: "failed",
70
+ });
71
+ expect(out).toContain("Row #1");
72
+ expect(out).toContain("Row #2");
73
+ expect(out).not.toContain("Row #0");
74
+ });
75
+ });
76
+
77
+ describe("when an evaluator name is supplied", () => {
78
+ it("limits the report to that evaluator", async () => {
79
+ mockMakeRequest.mockResolvedValueOnce(sample);
80
+ const out = await handleExperimentResults({
81
+ runId: "run_1",
82
+ evaluator: "quality",
83
+ });
84
+ expect(out).toContain("quality");
85
+ // The per-row sections should not list the safety evaluator bullet
86
+ expect(out).not.toMatch(/\*\*safety\*\*/);
87
+ });
88
+ });
89
+
90
+ describe("when there are more rows than the cap", () => {
91
+ it("truncates and notes the cap", async () => {
92
+ const big = {
93
+ ...sample,
94
+ dataset: Array.from({ length: 80 }, (_, i) => ({
95
+ index: i,
96
+ entry: { input: `row ${i}` },
97
+ })),
98
+ evaluations: [],
99
+ };
100
+ mockMakeRequest.mockResolvedValueOnce(big);
101
+ const out = await handleExperimentResults({ runId: "run_1" });
102
+ expect(out).toContain("Output truncated to 50 rows of 80");
103
+ });
104
+ });
105
+
106
+ describe("when a caller asks for more than the row cap", () => {
107
+ it("still caps output at 50 rows", async () => {
108
+ const big = {
109
+ ...sample,
110
+ dataset: Array.from({ length: 80 }, (_, i) => ({
111
+ index: i,
112
+ entry: { input: `row ${i}` },
113
+ })),
114
+ evaluations: [],
115
+ };
116
+ mockMakeRequest.mockResolvedValueOnce(big);
117
+ const out = await handleExperimentResults({
118
+ runId: "run_1",
119
+ limit: 5000,
120
+ });
121
+ expect(out).toContain("Output truncated to 50 rows of 80");
122
+ });
123
+ });
124
+
125
+ describe("when only some rows have evaluations", () => {
126
+ it("explains rows with no evaluation coverage", async () => {
127
+ mockMakeRequest.mockResolvedValueOnce({
128
+ ...sample,
129
+ dataset: [
130
+ { index: 0, entry: { input: "covered" } },
131
+ { index: 1, entry: { input: "uncovered" } },
132
+ ],
133
+ evaluations: [
134
+ {
135
+ evaluator: "quality",
136
+ index: 0,
137
+ status: "processed",
138
+ score: 0.9,
139
+ passed: true,
140
+ },
141
+ ],
142
+ });
143
+
144
+ const out = await handleExperimentResults({ runId: "run_1" });
145
+
146
+ expect(out).toContain("uncovered");
147
+ expect(out).toContain("No evaluations recorded for this row");
148
+ });
149
+ });
150
+
151
+ describe("when multiple targets share the same dataset index", () => {
152
+ it("keeps evaluator rows target-scoped", async () => {
153
+ mockMakeRequest.mockResolvedValueOnce({
154
+ ...sample,
155
+ dataset: [
156
+ { index: 0, targetId: "a", entry: { input: "target a" } },
157
+ { index: 0, targetId: "b", entry: { input: "target b" } },
158
+ ],
159
+ evaluations: [
160
+ {
161
+ evaluator: "quality",
162
+ index: 0,
163
+ targetId: "a",
164
+ status: "processed",
165
+ score: 0.9,
166
+ passed: true,
167
+ },
168
+ {
169
+ evaluator: "quality",
170
+ index: 0,
171
+ targetId: "b",
172
+ status: "processed",
173
+ score: 0.1,
174
+ passed: false,
175
+ details: "target-b-only",
176
+ },
177
+ ],
178
+ });
179
+
180
+ const out = await handleExperimentResults({
181
+ runId: "run_1",
182
+ filter: "failed",
183
+ });
184
+
185
+ expect(out).toContain("target b");
186
+ expect(out).toContain("target-b-only");
187
+ expect(out).not.toContain("target a");
188
+ });
189
+ });
190
+ });
191
+
192
+ describe("given the run is missing", () => {
193
+ describe("when the API throws a 404-shaped error", () => {
194
+ it("returns a graceful 'not found' markdown", async () => {
195
+ mockMakeRequest.mockRejectedValueOnce(
196
+ new LangWatchApiError("missing", 404, "Run not found"),
197
+ );
198
+ const out = await handleExperimentResults({ runId: "missing" });
199
+ expect(out).toContain("not found");
200
+ expect(out).toContain("experimentSlug");
201
+ });
202
+ });
203
+ });
204
+
205
+ describe("given the run is still in progress", () => {
206
+ describe("when results are requested", () => {
207
+ it("serves the partial rows logged so far with an in-progress note", async () => {
208
+ mockMakeRequest.mockResolvedValueOnce({
209
+ ...sample,
210
+ progress: 2,
211
+ total: 3,
212
+ timestamps: {
213
+ createdAt: Date.now(),
214
+ updatedAt: Date.now(),
215
+ finishedAt: null,
216
+ stoppedAt: null,
217
+ },
218
+ });
219
+
220
+ const out = await handleExperimentResults({ runId: "unfinished" });
221
+
222
+ expect(out).toContain("**Status**: running");
223
+ expect(out).toContain("still in progress");
224
+ expect(out).toContain("Row #0");
225
+ expect(out).toContain("Progress**: 2/3");
226
+ });
227
+ });
228
+ });
229
+
230
+ describe("given the run was interrupted before finishing", () => {
231
+ describe("when results are requested", () => {
232
+ it("serves the partial rows with an interrupted note", async () => {
233
+ mockMakeRequest.mockResolvedValueOnce({
234
+ ...sample,
235
+ timestamps: {
236
+ createdAt: Date.now() - 60 * 60 * 1000,
237
+ updatedAt: Date.now() - 30 * 60 * 1000,
238
+ finishedAt: null,
239
+ stoppedAt: null,
240
+ },
241
+ });
242
+
243
+ const out = await handleExperimentResults({ runId: "interrupted" });
244
+
245
+ expect(out).toContain("**Status**: interrupted");
246
+ expect(out).toContain("interrupted");
247
+ expect(out).toContain("Row #0");
248
+ });
249
+ });
250
+ });
251
+
252
+ describe("given a non-terminal run with zero rows", () => {
253
+ describe("when the run is still running", () => {
254
+ it("says the run is in progress", async () => {
255
+ mockMakeRequest.mockResolvedValueOnce({
256
+ ...sample,
257
+ dataset: [],
258
+ evaluations: [],
259
+ timestamps: {
260
+ createdAt: Date.now(),
261
+ updatedAt: Date.now(),
262
+ finishedAt: null,
263
+ stoppedAt: null,
264
+ },
265
+ });
266
+ const out = await handleExperimentResults({ runId: "empty-running" });
267
+ expect(out).toContain("still in progress");
268
+ expect(out).not.toContain("interrupted");
269
+ });
270
+ });
271
+
272
+ describe("when the run was interrupted", () => {
273
+ it("says no rows were recorded before it was interrupted, not to wait", async () => {
274
+ mockMakeRequest.mockResolvedValueOnce({
275
+ ...sample,
276
+ dataset: [],
277
+ evaluations: [],
278
+ timestamps: {
279
+ createdAt: Date.now() - 60 * 60 * 1000,
280
+ updatedAt: Date.now() - 30 * 60 * 1000,
281
+ finishedAt: null,
282
+ stoppedAt: null,
283
+ },
284
+ });
285
+ const out = await handleExperimentResults({ runId: "empty-interrupted" });
286
+ expect(out).toContain("interrupted");
287
+ expect(out).not.toContain("still in progress");
288
+ expect(out).not.toContain("Call again shortly");
289
+ });
290
+ });
291
+ });
292
+
293
+ describe("given no run state but rows exist (SDK / >24h run)", () => {
294
+ describe("when results are requested with a stopped run", () => {
295
+ it("renders results for a stopped run", async () => {
296
+ mockMakeRequest.mockResolvedValueOnce({
297
+ ...sample,
298
+ timestamps: {
299
+ createdAt: 0,
300
+ updatedAt: 10,
301
+ finishedAt: null,
302
+ stoppedAt: 20,
303
+ },
304
+ });
305
+
306
+ const out = await handleExperimentResults({ runId: "stopped" });
307
+
308
+ expect(out).toContain("**Status**: stopped");
309
+ expect(out).toContain("Row #0");
310
+ });
311
+ });
312
+ });
313
+ });