@vellumai/assistant 0.8.12-staging.1 → 0.8.12

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 (35) hide show
  1. package/openapi.yaml +507 -0
  2. package/package.json +1 -1
  3. package/src/__tests__/llm-catalog-parity.test.ts +16 -0
  4. package/src/__tests__/log-export-workspace.test.ts +468 -3
  5. package/src/__tests__/secret-fixtures.ts +20 -0
  6. package/src/__tests__/tool-approval-handler.test.ts +85 -0
  7. package/src/__tests__/tool-audit-listener.test.ts +86 -0
  8. package/src/__tests__/workspace-migration-100-upgrade-quality-profile-to-fable-5.test.ts +174 -0
  9. package/src/__tests__/workspace-migration-101-upgrade-balanced-economy-to-minimax-m3.test.ts +162 -0
  10. package/src/acp/__tests__/agent-process.test.ts +315 -2
  11. package/src/acp/__tests__/prepare-agent-env.test.ts +79 -5
  12. package/src/acp/agent-process.ts +163 -34
  13. package/src/acp/prepare-agent-env.ts +55 -15
  14. package/src/bundler/app-compiler.ts +8 -0
  15. package/src/cli/lib/__tests__/upgrade-plugin.test.ts +10 -4
  16. package/src/cli/lib/upgrade-plugin.ts +13 -7
  17. package/src/config/seed-inference-profiles.ts +4 -8
  18. package/src/events/tool-audit-listener.ts +40 -9
  19. package/src/providers/__tests__/unparseable-tool-args.test.ts +53 -0
  20. package/src/providers/model-catalog.ts +28 -0
  21. package/src/providers/model-intents.ts +1 -1
  22. package/src/providers/openai/chat-completions-provider.ts +2 -1
  23. package/src/providers/openai/responses-provider.ts +2 -1
  24. package/src/providers/unparseable-tool-args.ts +56 -0
  25. package/src/runtime/routes/__tests__/conversation-query-routes.test.ts +132 -0
  26. package/src/runtime/routes/__tests__/plugins-routes.test.ts +347 -0
  27. package/src/runtime/routes/conversation-query-routes.ts +79 -4
  28. package/src/runtime/routes/log-export-routes.ts +143 -96
  29. package/src/runtime/routes/plugins-routes.ts +359 -0
  30. package/src/runtime/routes/redact-staged-export.ts +259 -0
  31. package/src/security/redact-json.ts +61 -0
  32. package/src/tools/tool-approval-handler.ts +31 -0
  33. package/src/workspace/migrations/100-upgrade-quality-profile-to-fable-5.ts +86 -0
  34. package/src/workspace/migrations/101-upgrade-balanced-economy-to-minimax-m3.ts +70 -0
  35. package/src/workspace/migrations/registry.ts +4 -0
@@ -691,6 +691,24 @@ const RAW_PROVIDER_CATALOG: ProviderCatalogEntry[] = [
691
691
  outputPer1mTokens: 2.5,
692
692
  },
693
693
  },
694
+ {
695
+ id: "accounts/fireworks/models/minimax-m3",
696
+ displayName: "MiniMax M3",
697
+ // The model supports 1M context, but Fireworks serves it with a
698
+ // 512K (524,288-token) window; advertise the served limit.
699
+ contextWindowTokens: 524288,
700
+ maxOutputTokens: 512000,
701
+ supportsThinking: true,
702
+ supportsCaching: true,
703
+ supportsVision: true,
704
+ supportsToolUse: true,
705
+ maxEffort: "high",
706
+ pricing: {
707
+ inputPer1mTokens: 0.3,
708
+ outputPer1mTokens: 1.2,
709
+ cacheReadPer1mTokens: 0.06,
710
+ },
711
+ },
694
712
  {
695
713
  id: "accounts/fireworks/models/minimax-m2p7",
696
714
  displayName: "MiniMax M2.7",
@@ -1241,6 +1259,16 @@ const RAW_PROVIDER_CATALOG: ProviderCatalogEntry[] = [
1241
1259
  linkLabel: "Open MiniMax Dashboard",
1242
1260
  },
1243
1261
  models: [
1262
+ {
1263
+ id: "MiniMax-M3",
1264
+ displayName: "MiniMax M3",
1265
+ contextWindowTokens: 1000000,
1266
+ maxOutputTokens: 512000,
1267
+ supportsThinking: true,
1268
+ supportsCaching: true,
1269
+ supportsVision: true,
1270
+ supportsToolUse: true,
1271
+ },
1244
1272
  {
1245
1273
  id: "MiniMax-M2.7",
1246
1274
  displayName: "MiniMax M2.7",
@@ -35,7 +35,7 @@ const PROVIDER_MODEL_INTENTS: Record<string, Record<ModelIntent, string>> = {
35
35
  "vision-optimized": "llama3.2",
36
36
  },
37
37
  fireworks: {
38
- balanced: "accounts/fireworks/models/kimi-k2p6",
38
+ balanced: "accounts/fireworks/models/minimax-m3",
39
39
  "latency-optimized": "accounts/fireworks/models/kimi-k2p5",
40
40
  "quality-optimized": "accounts/fireworks/models/kimi-k2p6",
41
41
  "vision-optimized": "accounts/fireworks/models/kimi-k2p6",
@@ -18,6 +18,7 @@ import {
18
18
  ContextOverflowError,
19
19
  extractOverflowTokensFromMessage,
20
20
  } from "../types.js";
21
+ import { wrapUnparseableToolArgs } from "../unparseable-tool-args.js";
21
22
 
22
23
  /**
23
24
  * Detect OpenAI-compatible context-overflow signals on an `OpenAI.APIError`.
@@ -635,7 +636,7 @@ export class OpenAIChatCompletionsProvider implements Provider {
635
636
  try {
636
637
  input = JSON.parse(tc.args);
637
638
  } catch {
638
- input = { _raw: tc.args };
639
+ input = wrapUnparseableToolArgs(tc.args);
639
640
  }
640
641
  content.push({
641
642
  type: "tool_use",
@@ -15,6 +15,7 @@ import type {
15
15
  SendMessageOptions,
16
16
  } from "../types.js";
17
17
  import { ContextOverflowError } from "../types.js";
18
+ import { wrapUnparseableToolArgs } from "../unparseable-tool-args.js";
18
19
  import { detectOpenAICompatibleContextOverflow } from "./chat-completions-provider.js";
19
20
 
20
21
  const log = getLogger("openai-responses");
@@ -465,7 +466,7 @@ export class OpenAIResponsesProvider implements Provider {
465
466
  try {
466
467
  input = JSON.parse(tc.args);
467
468
  } catch {
468
- input = { _raw: tc.args };
469
+ input = wrapUnparseableToolArgs(tc.args);
469
470
  }
470
471
  content.push({
471
472
  type: "tool_use",
@@ -0,0 +1,56 @@
1
+ /**
2
+ * Marker shape for tool-call arguments that failed JSON parsing.
3
+ *
4
+ * Streaming providers accumulate tool-call argument deltas as a string and
5
+ * parse the result once the stream completes. Some models/gateways emit
6
+ * truncated or malformed argument JSON (e.g. MiniMax M3 via OpenRouter cutting
7
+ * the stream mid-object). The provider cannot drop the tool_use block — the
8
+ * protocol requires a paired tool_result for every tool_use id — so it wraps
9
+ * the raw string under this marker key instead.
10
+ *
11
+ * The tool execution layer detects the marker and rejects the invocation with
12
+ * an error tool result, so the model sees the failure and can retry, instead
13
+ * of a tool executing with garbage input.
14
+ */
15
+ const UNPARSEABLE_TOOL_ARGS_KEY = "_raw";
16
+
17
+ /** Wrap raw, unparseable tool-call argument text in the marker shape. */
18
+ export function wrapUnparseableToolArgs(raw: string): Record<string, unknown> {
19
+ return { [UNPARSEABLE_TOOL_ARGS_KEY]: raw };
20
+ }
21
+
22
+ /**
23
+ * Detect input produced by {@link wrapUnparseableToolArgs}: exactly one key,
24
+ * the marker key, holding the raw argument string. The exact-shape check
25
+ * avoids false positives on hypothetical legitimate inputs that merely
26
+ * contain a `_raw` field among others.
27
+ */
28
+ export function isUnparseableToolArgs(
29
+ input: Record<string, unknown>,
30
+ ): input is { _raw: string } {
31
+ const keys = Object.keys(input);
32
+ return (
33
+ keys.length === 1 &&
34
+ keys[0] === UNPARSEABLE_TOOL_ARGS_KEY &&
35
+ typeof input[UNPARSEABLE_TOOL_ARGS_KEY] === "string"
36
+ );
37
+ }
38
+
39
+ /**
40
+ * Build the error message returned to the model when it sends unparseable
41
+ * tool arguments. Includes a bounded prefix of what was received so the model
42
+ * can see where its output was cut off or malformed.
43
+ */
44
+ export function unparseableToolArgsMessage(
45
+ toolName: string,
46
+ raw: string,
47
+ ): string {
48
+ const PREVIEW_LIMIT = 200;
49
+ const preview =
50
+ raw.length > PREVIEW_LIMIT ? `${raw.slice(0, PREVIEW_LIMIT)}…` : raw;
51
+ return (
52
+ `Error: the arguments for "${toolName}" were not valid JSON — the argument stream was malformed or truncated, so the tool was NOT executed. ` +
53
+ `Received: ${preview || "(empty)"}\n` +
54
+ `Retry the call with complete, valid JSON arguments.`
55
+ );
56
+ }
@@ -175,6 +175,26 @@ function seedRequestLog(
175
175
  .run();
176
176
  }
177
177
 
178
+ function seedRequestLogWithSections(messageId: string, id: string): void {
179
+ getDb()
180
+ .insert(llmRequestLogs)
181
+ .values({
182
+ id,
183
+ conversationId: "conv-1",
184
+ messageId,
185
+ provider: "openai",
186
+ requestPayload: JSON.stringify({
187
+ model: "gpt-4.1",
188
+ messages: [{ role: "user", content: "hello" }],
189
+ }),
190
+ responsePayload: JSON.stringify({
191
+ choices: [{ message: { content: "hi" } }],
192
+ }),
193
+ createdAt: 1_700_000_000_000,
194
+ })
195
+ .run();
196
+ }
197
+
178
198
  function seedConversationKey(
179
199
  conversationKey: string,
180
200
  conversationId: string,
@@ -274,6 +294,118 @@ describe("GET /v1/conversations/llm-context", () => {
274
294
  });
275
295
  });
276
296
 
297
+ describe("llm-context view=summary", () => {
298
+ beforeEach(() => {
299
+ clearTables();
300
+ });
301
+
302
+ function seedConversationWithLog(): void {
303
+ seedConversationAndMessage({
304
+ conversationId: "conv-1",
305
+ messageId: "msg-1",
306
+ source: "user",
307
+ conversationType: "standard",
308
+ });
309
+ seedConversationKey("conv-key", "conv-1");
310
+ seedRequestLogWithSections("msg-1", "log-a");
311
+ }
312
+
313
+ test("conversation endpoint omits sections in summary view but keeps summary fields", async () => {
314
+ seedConversationWithLog();
315
+
316
+ const body = (await dispatchConversationLlmContext({
317
+ conversationKey: "conv-key",
318
+ view: "summary",
319
+ })) as {
320
+ logs: Array<Record<string, unknown>>;
321
+ };
322
+
323
+ expect(body.logs).toHaveLength(1);
324
+ const log = body.logs[0]!;
325
+ expect(log.requestSections).toBeUndefined();
326
+ expect(log.responseSections).toBeUndefined();
327
+ expect(log.id).toBe("log-a");
328
+ expect(log.summary).toBeDefined();
329
+ });
330
+
331
+ test("conversation endpoint includes sections by default", async () => {
332
+ seedConversationWithLog();
333
+
334
+ const body = (await dispatchConversationLlmContext({
335
+ conversationKey: "conv-key",
336
+ })) as {
337
+ logs: Array<Record<string, unknown>>;
338
+ };
339
+
340
+ expect(Array.isArray(body.logs[0]!.requestSections)).toBe(true);
341
+ expect(Array.isArray(body.logs[0]!.responseSections)).toBe(true);
342
+ });
343
+
344
+ test("message endpoint omits sections in summary view", async () => {
345
+ seedConversationWithLog();
346
+
347
+ const body = (await llmContextRoute.handler({
348
+ pathParams: { id: "msg-1" },
349
+ queryParams: { view: "summary" },
350
+ })) as {
351
+ logs: Array<Record<string, unknown>>;
352
+ };
353
+
354
+ expect(body.logs).toHaveLength(1);
355
+ expect(body.logs[0]!.requestSections).toBeUndefined();
356
+ expect(body.logs[0]!.responseSections).toBeUndefined();
357
+ expect(body.logs[0]!.summary).toBeDefined();
358
+ });
359
+
360
+ test("rejects unknown view values", async () => {
361
+ seedConversationWithLog();
362
+
363
+ expect(
364
+ dispatchConversationLlmContext({
365
+ conversationKey: "conv-key",
366
+ view: "compact",
367
+ }),
368
+ ).rejects.toThrow("Invalid view parameter");
369
+ });
370
+ });
371
+
372
+ describe("GET /v1/llm-request-logs/:id/context", () => {
373
+ const logContextRoute = ROUTES.find(
374
+ (r) => r.operationId === "llm_request_logs_context_get",
375
+ )!;
376
+
377
+ beforeEach(() => {
378
+ clearTables();
379
+ });
380
+
381
+ test("returns the normalized entry with sections for a single log", async () => {
382
+ seedConversationAndMessage({
383
+ conversationId: "conv-1",
384
+ messageId: "msg-1",
385
+ source: "user",
386
+ conversationType: "standard",
387
+ });
388
+ seedRequestLogWithSections("msg-1", "log-detail");
389
+
390
+ const body = (await logContextRoute.handler({
391
+ pathParams: { id: "log-detail" },
392
+ })) as Record<string, unknown>;
393
+
394
+ expect(body.id).toBe("log-detail");
395
+ expect(body.requestPayload).toBeNull();
396
+ expect(body.responsePayload).toBeNull();
397
+ expect(body.summary).toBeDefined();
398
+ expect(Array.isArray(body.requestSections)).toBe(true);
399
+ expect(Array.isArray(body.responseSections)).toBe(true);
400
+ });
401
+
402
+ test("throws NotFound for a missing log id", async () => {
403
+ expect(
404
+ logContextRoute.handler({ pathParams: { id: "log-missing" } }),
405
+ ).rejects.toThrow("log not found");
406
+ });
407
+ });
408
+
277
409
  describe("GET /v1/messages/:id/llm-context — memoryV2Activation", () => {
278
410
  beforeEach(() => {
279
411
  clearTables();
@@ -34,6 +34,12 @@
34
34
 
35
35
  import { beforeEach, describe, expect, mock, test } from "bun:test";
36
36
 
37
+ import {
38
+ type InspectPluginDeps,
39
+ type InspectPluginOptions,
40
+ type PluginInspection,
41
+ PluginInspectNotFoundError,
42
+ } from "../../../cli/lib/inspect-plugin.js";
37
43
  import {
38
44
  type InstallPluginOptions,
39
45
  type InstallPluginResult,
@@ -59,6 +65,12 @@ import {
59
65
  type UninstallPluginOptions,
60
66
  type UninstallPluginResult,
61
67
  } from "../../../cli/lib/uninstall-plugin.js";
68
+ import {
69
+ PluginNotUpgradableError,
70
+ type PluginUpgradeResult,
71
+ type UpgradePluginDeps,
72
+ type UpgradePluginOptions,
73
+ } from "../../../cli/lib/upgrade-plugin.js";
62
74
 
63
75
  // Mutable list returned by the mocked library function. Tests reassign
64
76
  // `installedFixture` before invoking the handler.
@@ -133,6 +145,38 @@ mock.module("../../../cli/lib/plugin-details.js", () => ({
133
145
  getPluginDetails: detailsSpy,
134
146
  }));
135
147
 
148
+ // Mock inspectPlugin: the lib computes the local-vs-remote drift (covered by
149
+ // inspect-plugin.test.ts); the route only forwards the name and maps errors.
150
+ const inspectSpy = mock(
151
+ async (
152
+ _opts: InspectPluginOptions,
153
+ _deps: InspectPluginDeps,
154
+ ): Promise<PluginInspection> => {
155
+ throw new Error("inspectSpy default impl not configured");
156
+ },
157
+ );
158
+
159
+ mock.module("../../../cli/lib/inspect-plugin.js", () => ({
160
+ PluginInspectNotFoundError,
161
+ inspectPlugin: inspectSpy,
162
+ }));
163
+
164
+ // Mock upgradePlugin: the lib performs the re-pin (covered by
165
+ // upgrade-plugin.test.ts); the route projects its result and maps errors.
166
+ const upgradeSpy = mock(
167
+ async (
168
+ _opts: UpgradePluginOptions,
169
+ _deps: UpgradePluginDeps,
170
+ ): Promise<PluginUpgradeResult> => {
171
+ throw new Error("upgradeSpy default impl not configured");
172
+ },
173
+ );
174
+
175
+ mock.module("../../../cli/lib/upgrade-plugin.js", () => ({
176
+ PluginNotUpgradableError,
177
+ upgradePlugin: upgradeSpy,
178
+ }));
179
+
136
180
  import {
137
181
  BadRequestError,
138
182
  ConflictError,
@@ -154,6 +198,8 @@ const searchHandler = findHandler("plugins_search");
154
198
  const uninstallHandler = findHandler("plugins_uninstall");
155
199
  const getHandler = findHandler("plugins_get");
156
200
  const installHandler = findHandler("plugins_install");
201
+ const inspectHandler = findHandler("plugins_inspect");
202
+ const upgradeHandler = findHandler("plugins_upgrade");
157
203
 
158
204
  function invoke(args: RouteHandlerArgs = {}): {
159
205
  plugins: Array<Record<string, unknown>>;
@@ -881,3 +927,304 @@ describe("POST /v1/plugins/install", () => {
881
927
  expect((caught as Error).message).toContain("ECONNRESET");
882
928
  });
883
929
  });
930
+
931
+ function inspection(
932
+ overrides: Partial<PluginInspection> = {},
933
+ ): PluginInspection {
934
+ return {
935
+ name: overrides.name ?? "level-up",
936
+ installed: overrides.installed ?? true,
937
+ status: overrides.status ?? "update-available",
938
+ local:
939
+ overrides.local === undefined
940
+ ? {
941
+ target: "/workspace/.vellum/plugins/level-up",
942
+ commit: "60a392b0000000000000000000000000000000aa",
943
+ version: "0.1.0",
944
+ description: "Surfaces a Level Up diff card.",
945
+ installedAt: "2026-06-08T00:00:00.000Z",
946
+ source: {
947
+ kind: "github",
948
+ owner: "vellum-ai",
949
+ repo: "level-up",
950
+ ref: "60a392b0000000000000000000000000000000aa",
951
+ },
952
+ localChanges: {
953
+ modified: [],
954
+ added: [],
955
+ removed: [],
956
+ clean: true,
957
+ },
958
+ issues: [],
959
+ }
960
+ : overrides.local,
961
+ remote:
962
+ overrides.remote === undefined
963
+ ? {
964
+ repo: "vellum-ai/level-up",
965
+ path: "",
966
+ commit: "3eae1820000000000000000000000000000000bb",
967
+ description: "Surfaces a Level Up diff card.",
968
+ homepage: "https://github.com/vellum-ai/level-up",
969
+ license: "MIT",
970
+ category: null,
971
+ marketplaceRef: "main",
972
+ }
973
+ : overrides.remote,
974
+ remoteError: overrides.remoteError ?? null,
975
+ };
976
+ }
977
+
978
+ async function invokeInspect(
979
+ args: RouteHandlerArgs = {},
980
+ ): Promise<PluginInspection> {
981
+ return (await inspectHandler(args)) as PluginInspection;
982
+ }
983
+
984
+ describe("GET /v1/plugins/:name/inspect", () => {
985
+ beforeEach(() => {
986
+ inspectSpy.mockReset();
987
+ });
988
+
989
+ test("forwards the name to inspectPlugin and returns the drift verbatim", async () => {
990
+ // GIVEN inspectPlugin reports an available update for an installed plugin
991
+ const view = inspection({ status: "update-available" });
992
+ inspectSpy.mockImplementation(async () => view);
993
+
994
+ // WHEN the route handler is invoked with the path name
995
+ const result = await invokeInspect({ pathParams: { name: "level-up" } });
996
+
997
+ // THEN the inspection is returned unchanged
998
+ expect(result).toEqual(view);
999
+ // AND the name is forwarded to the lib (ref is never caller-supplied)
1000
+ expect(inspectSpy.mock.calls[0]?.[0]).toEqual({ name: "level-up" });
1001
+ });
1002
+
1003
+ test("a captured marketplace error is returned as 200 with remote-unavailable, not thrown", async () => {
1004
+ // GIVEN the catalog was unreachable but a local copy exists, so the lib
1005
+ // returns a status rather than throwing
1006
+ const view = inspection({
1007
+ status: "remote-unavailable",
1008
+ remote: null,
1009
+ remoteError: "ENOTFOUND raw.githubusercontent.com",
1010
+ });
1011
+ inspectSpy.mockImplementation(async () => view);
1012
+
1013
+ // WHEN the handler runs
1014
+ const result = await invokeInspect({ pathParams: { name: "level-up" } });
1015
+
1016
+ // THEN it resolves (does not throw) and surfaces the captured error
1017
+ expect(result.status).toBe("remote-unavailable");
1018
+ expect(result.remoteError).toContain("ENOTFOUND");
1019
+ });
1020
+
1021
+ test("InvalidPluginNameError → BadRequestError (400)", async () => {
1022
+ inspectSpy.mockImplementation(async () => {
1023
+ throw new InvalidPluginNameError("../escape");
1024
+ });
1025
+
1026
+ await expect(
1027
+ invokeInspect({ pathParams: { name: "../escape" } }),
1028
+ ).rejects.toBeInstanceOf(BadRequestError);
1029
+ });
1030
+
1031
+ test("PluginInspectNotFoundError → NotFoundError (404)", async () => {
1032
+ inspectSpy.mockImplementation(async () => {
1033
+ throw new PluginInspectNotFoundError("ghost");
1034
+ });
1035
+
1036
+ await expect(
1037
+ invokeInspect({ pathParams: { name: "ghost" } }),
1038
+ ).rejects.toBeInstanceOf(NotFoundError);
1039
+ });
1040
+
1041
+ test("unknown errors → InternalError with original message preserved", async () => {
1042
+ inspectSpy.mockImplementation(async () => {
1043
+ throw new Error("EUNEXPECTED");
1044
+ });
1045
+
1046
+ let caught: unknown;
1047
+ try {
1048
+ await invokeInspect({ pathParams: { name: "level-up" } });
1049
+ } catch (err) {
1050
+ caught = err;
1051
+ }
1052
+ expect(caught).toBeInstanceOf(InternalError);
1053
+ expect((caught as Error).message).toContain("EUNEXPECTED");
1054
+ });
1055
+ });
1056
+
1057
+ function upgradeResult(
1058
+ overrides: Partial<PluginUpgradeResult> = {},
1059
+ ): PluginUpgradeResult {
1060
+ return {
1061
+ name: overrides.name ?? "level-up",
1062
+ outcome: overrides.outcome ?? "upgraded",
1063
+ fromCommit:
1064
+ overrides.fromCommit === undefined
1065
+ ? "60a392b0000000000000000000000000000000aa"
1066
+ : overrides.fromCommit,
1067
+ toCommit: overrides.toCommit ?? "3eae1820000000000000000000000000000000bb",
1068
+ target: overrides.target ?? "/workspace/.vellum/plugins/level-up",
1069
+ fileCount: overrides.fileCount === undefined ? 12 : overrides.fileCount,
1070
+ dryRun: overrides.dryRun ?? false,
1071
+ provenanceWasUnknown: overrides.provenanceWasUnknown ?? false,
1072
+ };
1073
+ }
1074
+
1075
+ async function invokeUpgrade(args: RouteHandlerArgs = {}): Promise<{
1076
+ name: string;
1077
+ outcome: string;
1078
+ fromCommit: string | null;
1079
+ toCommit: string;
1080
+ target: string;
1081
+ fileCount: number | null;
1082
+ dryRun: boolean;
1083
+ provenanceWasUnknown: boolean;
1084
+ }> {
1085
+ return (await upgradeHandler(args)) as {
1086
+ name: string;
1087
+ outcome: string;
1088
+ fromCommit: string | null;
1089
+ toCommit: string;
1090
+ target: string;
1091
+ fileCount: number | null;
1092
+ dryRun: boolean;
1093
+ provenanceWasUnknown: boolean;
1094
+ };
1095
+ }
1096
+
1097
+ describe("POST /v1/plugins/:name/upgrade", () => {
1098
+ beforeEach(() => {
1099
+ upgradeSpy.mockReset();
1100
+ });
1101
+
1102
+ test("forwards name + dryRun and projects the upgrade result", async () => {
1103
+ // GIVEN upgradePlugin reports a successful re-pin
1104
+ upgradeSpy.mockImplementation(async () => upgradeResult());
1105
+
1106
+ // WHEN the handler runs with an explicit dryRun flag
1107
+ const result = await invokeUpgrade({
1108
+ pathParams: { name: "level-up" },
1109
+ body: { dryRun: false },
1110
+ });
1111
+
1112
+ // THEN the result is projected onto the wire shape
1113
+ expect(result).toEqual({
1114
+ name: "level-up",
1115
+ outcome: "upgraded",
1116
+ fromCommit: "60a392b0000000000000000000000000000000aa",
1117
+ toCommit: "3eae1820000000000000000000000000000000bb",
1118
+ target: "/workspace/.vellum/plugins/level-up",
1119
+ fileCount: 12,
1120
+ dryRun: false,
1121
+ provenanceWasUnknown: false,
1122
+ });
1123
+ // AND the name + dryRun are forwarded to the lib
1124
+ expect(upgradeSpy.mock.calls[0]?.[0]).toEqual({
1125
+ name: "level-up",
1126
+ dryRun: false,
1127
+ });
1128
+ });
1129
+
1130
+ test("omits dryRun (passes undefined) when the body flag is absent", async () => {
1131
+ // GIVEN a no-op upgrade where the install already matches the pin
1132
+ upgradeSpy.mockImplementation(async () =>
1133
+ upgradeResult({
1134
+ outcome: "already-up-to-date",
1135
+ toCommit: "60a392b0000000000000000000000000000000aa",
1136
+ fileCount: null,
1137
+ }),
1138
+ );
1139
+
1140
+ // WHEN invoked without a body
1141
+ const result = await invokeUpgrade({ pathParams: { name: "level-up" } });
1142
+
1143
+ // THEN the no-op outcome is surfaced
1144
+ expect(result.outcome).toBe("already-up-to-date");
1145
+ expect(result.fileCount).toBeNull();
1146
+ // AND dryRun is passed through as undefined (not coerced to false)
1147
+ expect(upgradeSpy.mock.calls[0]?.[0]).toEqual({
1148
+ name: "level-up",
1149
+ dryRun: undefined,
1150
+ });
1151
+ });
1152
+
1153
+ test("InvalidPluginNameError → BadRequestError (400)", async () => {
1154
+ upgradeSpy.mockImplementation(async () => {
1155
+ throw new InvalidPluginNameError("../escape");
1156
+ });
1157
+
1158
+ await expect(
1159
+ invokeUpgrade({ pathParams: { name: "../escape" } }),
1160
+ ).rejects.toBeInstanceOf(BadRequestError);
1161
+ });
1162
+
1163
+ test("PluginNotInstalledError → NotFoundError (404)", async () => {
1164
+ upgradeSpy.mockImplementation(async () => {
1165
+ throw new PluginNotInstalledError(
1166
+ "ghost",
1167
+ "/workspace/.vellum/plugins/ghost",
1168
+ );
1169
+ });
1170
+
1171
+ await expect(
1172
+ invokeUpgrade({ pathParams: { name: "ghost" } }),
1173
+ ).rejects.toBeInstanceOf(NotFoundError);
1174
+ });
1175
+
1176
+ test("PluginNotUpgradableError → ConflictError (409)", async () => {
1177
+ // The install exists but has no marketplace entry to advance to: a
1178
+ // well-formed request that isn't actionable in the current state.
1179
+ upgradeSpy.mockImplementation(async () => {
1180
+ throw new PluginNotUpgradableError(
1181
+ "level-up",
1182
+ "it has no marketplace entry to upgrade from",
1183
+ );
1184
+ });
1185
+
1186
+ await expect(
1187
+ invokeUpgrade({ pathParams: { name: "level-up" } }),
1188
+ ).rejects.toBeInstanceOf(ConflictError);
1189
+ });
1190
+
1191
+ test("PluginNotFoundError → NotFoundError (404)", async () => {
1192
+ upgradeSpy.mockImplementation(async () => {
1193
+ throw new PluginNotFoundError("level-up", "main", "vellum-ai/level-up");
1194
+ });
1195
+
1196
+ await expect(
1197
+ invokeUpgrade({ pathParams: { name: "level-up" } }),
1198
+ ).rejects.toBeInstanceOf(NotFoundError);
1199
+ });
1200
+
1201
+ test("PluginSourceUnavailableError → ServiceUnavailableError (503)", async () => {
1202
+ // The re-install fetch can hit a rate-limited GitHub; that is retryable,
1203
+ // so surface 503 rather than a misleading 500.
1204
+ upgradeSpy.mockImplementation(async () => {
1205
+ throw new PluginSourceUnavailableError(
1206
+ "GitHub tree listing for vellum-ai/level-up: HTTP 403",
1207
+ 403,
1208
+ );
1209
+ });
1210
+
1211
+ await expect(
1212
+ invokeUpgrade({ pathParams: { name: "level-up" } }),
1213
+ ).rejects.toBeInstanceOf(ServiceUnavailableError);
1214
+ });
1215
+
1216
+ test("unknown errors → InternalError with original message preserved", async () => {
1217
+ upgradeSpy.mockImplementation(async () => {
1218
+ throw new Error("ECONNRESET");
1219
+ });
1220
+
1221
+ let caught: unknown;
1222
+ try {
1223
+ await invokeUpgrade({ pathParams: { name: "level-up" } });
1224
+ } catch (err) {
1225
+ caught = err;
1226
+ }
1227
+ expect(caught).toBeInstanceOf(InternalError);
1228
+ expect((caught as Error).message).toContain("ECONNRESET");
1229
+ });
1230
+ });