@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.
- package/openapi.yaml +507 -0
- package/package.json +1 -1
- package/src/__tests__/llm-catalog-parity.test.ts +16 -0
- package/src/__tests__/log-export-workspace.test.ts +468 -3
- package/src/__tests__/secret-fixtures.ts +20 -0
- package/src/__tests__/tool-approval-handler.test.ts +85 -0
- package/src/__tests__/tool-audit-listener.test.ts +86 -0
- package/src/__tests__/workspace-migration-100-upgrade-quality-profile-to-fable-5.test.ts +174 -0
- package/src/__tests__/workspace-migration-101-upgrade-balanced-economy-to-minimax-m3.test.ts +162 -0
- package/src/acp/__tests__/agent-process.test.ts +315 -2
- package/src/acp/__tests__/prepare-agent-env.test.ts +79 -5
- package/src/acp/agent-process.ts +163 -34
- package/src/acp/prepare-agent-env.ts +55 -15
- package/src/bundler/app-compiler.ts +8 -0
- package/src/cli/lib/__tests__/upgrade-plugin.test.ts +10 -4
- package/src/cli/lib/upgrade-plugin.ts +13 -7
- package/src/config/seed-inference-profiles.ts +4 -8
- package/src/events/tool-audit-listener.ts +40 -9
- package/src/providers/__tests__/unparseable-tool-args.test.ts +53 -0
- package/src/providers/model-catalog.ts +28 -0
- package/src/providers/model-intents.ts +1 -1
- package/src/providers/openai/chat-completions-provider.ts +2 -1
- package/src/providers/openai/responses-provider.ts +2 -1
- package/src/providers/unparseable-tool-args.ts +56 -0
- package/src/runtime/routes/__tests__/conversation-query-routes.test.ts +132 -0
- package/src/runtime/routes/__tests__/plugins-routes.test.ts +347 -0
- package/src/runtime/routes/conversation-query-routes.ts +79 -4
- package/src/runtime/routes/log-export-routes.ts +143 -96
- package/src/runtime/routes/plugins-routes.ts +359 -0
- package/src/runtime/routes/redact-staged-export.ts +259 -0
- package/src/security/redact-json.ts +61 -0
- package/src/tools/tool-approval-handler.ts +31 -0
- package/src/workspace/migrations/100-upgrade-quality-profile-to-fable-5.ts +86 -0
- package/src/workspace/migrations/101-upgrade-balanced-economy-to-minimax-m3.ts +70 -0
- 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/
|
|
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 =
|
|
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 =
|
|
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
|
+
});
|