@vellumai/cli 0.7.3 → 0.8.1
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/package.json +1 -1
- package/src/__tests__/orphan-detection.test.ts +287 -0
- package/src/__tests__/ps-platform-status.test.ts +182 -0
- package/src/__tests__/search-provider-env-var-parity.test.ts +48 -0
- package/src/__tests__/sync-events.test.ts +54 -0
- package/src/commands/client.ts +71 -9
- package/src/commands/events.ts +13 -1
- package/src/commands/login.ts +3 -2
- package/src/commands/ps.ts +28 -17
- package/src/components/DefaultMainScreen.tsx +8 -9
- package/src/lib/__tests__/docker.test.ts +11 -0
- package/src/lib/assistant-config.ts +65 -0
- package/src/lib/client-identity.ts +1 -0
- package/src/lib/local.ts +37 -0
- package/src/lib/orphan-detection.ts +66 -1
- package/src/lib/platform-client.ts +8 -7
- package/src/lib/statefulset.ts +12 -0
- package/src/lib/sync-cloud-assistants.ts +16 -9
- package/src/lib/upgrade-lifecycle.ts +9 -73
- package/src/shared/provider-env-vars.ts +12 -8
package/package.json
CHANGED
|
@@ -0,0 +1,287 @@
|
|
|
1
|
+
import { describe, test, expect, beforeEach, afterAll } from "bun:test";
|
|
2
|
+
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
|
3
|
+
import { tmpdir } from "node:os";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
|
|
6
|
+
// Point lockfile operations at a temp directory before importing anything that
|
|
7
|
+
// would otherwise resolve real on-host paths.
|
|
8
|
+
const testDir = mkdtempSync(join(tmpdir(), "cli-orphan-detection-test-"));
|
|
9
|
+
process.env.VELLUM_LOCKFILE_DIR = testDir;
|
|
10
|
+
|
|
11
|
+
import {
|
|
12
|
+
detectOrphanedProcesses,
|
|
13
|
+
getKnownPidsFromAssistants,
|
|
14
|
+
} from "../lib/orphan-detection.js";
|
|
15
|
+
import {
|
|
16
|
+
loadAllAssistantsAcrossEnvs,
|
|
17
|
+
type AssistantEntry,
|
|
18
|
+
} from "../lib/assistant-config.js";
|
|
19
|
+
import type { EnvironmentDefinition } from "../lib/environments/types.js";
|
|
20
|
+
|
|
21
|
+
afterAll(() => {
|
|
22
|
+
rmSync(testDir, { recursive: true, force: true });
|
|
23
|
+
delete process.env.VELLUM_LOCKFILE_DIR;
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
function makeLocalEntry(
|
|
27
|
+
id: string,
|
|
28
|
+
instanceDir: string,
|
|
29
|
+
pids: {
|
|
30
|
+
daemon?: string;
|
|
31
|
+
gateway?: string;
|
|
32
|
+
qdrant?: string;
|
|
33
|
+
embed?: string;
|
|
34
|
+
} = {},
|
|
35
|
+
): AssistantEntry {
|
|
36
|
+
const vellumDir = join(instanceDir, ".vellum");
|
|
37
|
+
mkdirSync(join(vellumDir, "workspace", "data", "qdrant"), {
|
|
38
|
+
recursive: true,
|
|
39
|
+
});
|
|
40
|
+
if (pids.daemon !== undefined) {
|
|
41
|
+
writeFileSync(join(vellumDir, "workspace", "vellum.pid"), pids.daemon);
|
|
42
|
+
}
|
|
43
|
+
if (pids.gateway !== undefined) {
|
|
44
|
+
writeFileSync(join(vellumDir, "gateway.pid"), pids.gateway);
|
|
45
|
+
}
|
|
46
|
+
if (pids.qdrant !== undefined) {
|
|
47
|
+
writeFileSync(
|
|
48
|
+
join(vellumDir, "workspace", "data", "qdrant", "qdrant.pid"),
|
|
49
|
+
pids.qdrant,
|
|
50
|
+
);
|
|
51
|
+
}
|
|
52
|
+
if (pids.embed !== undefined) {
|
|
53
|
+
writeFileSync(join(vellumDir, "workspace", "embed-worker.pid"), pids.embed);
|
|
54
|
+
}
|
|
55
|
+
return {
|
|
56
|
+
assistantId: id,
|
|
57
|
+
runtimeUrl: "http://localhost:7821",
|
|
58
|
+
cloud: "local",
|
|
59
|
+
resources: {
|
|
60
|
+
instanceDir,
|
|
61
|
+
daemonPort: 7821,
|
|
62
|
+
gatewayPort: 7830,
|
|
63
|
+
qdrantPort: 6333,
|
|
64
|
+
cesPort: 8090,
|
|
65
|
+
},
|
|
66
|
+
};
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
describe("getKnownPidsFromAssistants", () => {
|
|
70
|
+
let perTestDir: string;
|
|
71
|
+
|
|
72
|
+
beforeEach(() => {
|
|
73
|
+
perTestDir = mkdtempSync(join(testDir, "case-"));
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
test("collects daemon, gateway, qdrant, and embed-worker PIDs", () => {
|
|
77
|
+
const entry = makeLocalEntry(
|
|
78
|
+
"alpha",
|
|
79
|
+
join(perTestDir, "alpha"),
|
|
80
|
+
{ daemon: "100", gateway: "200", qdrant: "300", embed: "400" },
|
|
81
|
+
);
|
|
82
|
+
const pids = getKnownPidsFromAssistants([entry]);
|
|
83
|
+
expect(pids).toEqual(new Set(["100", "200", "300", "400"]));
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
test("skips missing PID files without throwing", () => {
|
|
87
|
+
const entry = makeLocalEntry("beta", join(perTestDir, "beta"), {
|
|
88
|
+
daemon: "100",
|
|
89
|
+
});
|
|
90
|
+
const pids = getKnownPidsFromAssistants([entry]);
|
|
91
|
+
expect(pids).toEqual(new Set(["100"]));
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
test("includes docker watcherPid when present", () => {
|
|
95
|
+
const entry: AssistantEntry = {
|
|
96
|
+
assistantId: "docker-1",
|
|
97
|
+
runtimeUrl: "http://localhost:18100",
|
|
98
|
+
cloud: "docker",
|
|
99
|
+
watcherPid: 555,
|
|
100
|
+
};
|
|
101
|
+
const pids = getKnownPidsFromAssistants([entry]);
|
|
102
|
+
expect(pids).toEqual(new Set(["555"]));
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
test("ignores non-local entries without watcherPid", () => {
|
|
106
|
+
const entry: AssistantEntry = {
|
|
107
|
+
assistantId: "managed-1",
|
|
108
|
+
runtimeUrl: "https://platform.vellum.ai/foo",
|
|
109
|
+
cloud: "vellum",
|
|
110
|
+
};
|
|
111
|
+
const pids = getKnownPidsFromAssistants([entry]);
|
|
112
|
+
expect(pids.size).toBe(0);
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
test("local entry without resources contributes no PIDs", () => {
|
|
116
|
+
const entry: AssistantEntry = {
|
|
117
|
+
assistantId: "legacy",
|
|
118
|
+
runtimeUrl: "http://localhost:7821",
|
|
119
|
+
cloud: "local",
|
|
120
|
+
};
|
|
121
|
+
const pids = getKnownPidsFromAssistants([entry]);
|
|
122
|
+
expect(pids.size).toBe(0);
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
test("aggregates PIDs across multiple assistants", () => {
|
|
126
|
+
const a = makeLocalEntry("a", join(perTestDir, "a"), {
|
|
127
|
+
daemon: "100",
|
|
128
|
+
gateway: "200",
|
|
129
|
+
});
|
|
130
|
+
const b = makeLocalEntry("b", join(perTestDir, "b"), {
|
|
131
|
+
daemon: "101",
|
|
132
|
+
gateway: "201",
|
|
133
|
+
});
|
|
134
|
+
const pids = getKnownPidsFromAssistants([a, b]);
|
|
135
|
+
expect(pids).toEqual(new Set(["100", "200", "101", "201"]));
|
|
136
|
+
});
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
describe("loadAllAssistantsAcrossEnvs", () => {
|
|
140
|
+
function makeEnv(name: string, lockfileDir: string): EnvironmentDefinition {
|
|
141
|
+
return {
|
|
142
|
+
name,
|
|
143
|
+
platformUrl: "https://example.invalid",
|
|
144
|
+
webUrl: "https://example.invalid",
|
|
145
|
+
lockfileDirOverride: lockfileDir,
|
|
146
|
+
};
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
test("aggregates entries from every provided environment's lockfile", () => {
|
|
150
|
+
const envADir = mkdtempSync(join(testDir, "envA-"));
|
|
151
|
+
const envBDir = mkdtempSync(join(testDir, "envB-"));
|
|
152
|
+
|
|
153
|
+
writeFileSync(
|
|
154
|
+
join(envADir, "lockfile.json"),
|
|
155
|
+
JSON.stringify({
|
|
156
|
+
assistants: [
|
|
157
|
+
{
|
|
158
|
+
assistantId: "alpha",
|
|
159
|
+
runtimeUrl: "http://localhost:7821",
|
|
160
|
+
cloud: "local",
|
|
161
|
+
},
|
|
162
|
+
],
|
|
163
|
+
}),
|
|
164
|
+
);
|
|
165
|
+
writeFileSync(
|
|
166
|
+
join(envBDir, "lockfile.json"),
|
|
167
|
+
JSON.stringify({
|
|
168
|
+
assistants: [
|
|
169
|
+
{
|
|
170
|
+
assistantId: "beta",
|
|
171
|
+
runtimeUrl: "http://localhost:18100",
|
|
172
|
+
cloud: "docker",
|
|
173
|
+
watcherPid: 777,
|
|
174
|
+
},
|
|
175
|
+
],
|
|
176
|
+
}),
|
|
177
|
+
);
|
|
178
|
+
|
|
179
|
+
const all = loadAllAssistantsAcrossEnvs([
|
|
180
|
+
makeEnv("envA", envADir),
|
|
181
|
+
makeEnv("envB", envBDir),
|
|
182
|
+
]);
|
|
183
|
+
const ids = all.map((e) => e.assistantId).sort();
|
|
184
|
+
expect(ids).toEqual(["alpha", "beta"]);
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
test("returns empty list when no envs have lockfiles", () => {
|
|
188
|
+
const envDir = mkdtempSync(join(testDir, "empty-"));
|
|
189
|
+
const all = loadAllAssistantsAcrossEnvs([makeEnv("missing", envDir)]);
|
|
190
|
+
expect(all).toEqual([]);
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
test("skips malformed JSON without throwing", () => {
|
|
194
|
+
const envDir = mkdtempSync(join(testDir, "malformed-"));
|
|
195
|
+
writeFileSync(join(envDir, "lockfile.json"), "{not json");
|
|
196
|
+
const all = loadAllAssistantsAcrossEnvs([makeEnv("bad", envDir)]);
|
|
197
|
+
expect(all).toEqual([]);
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
test("skips entries missing required fields", () => {
|
|
201
|
+
const envDir = mkdtempSync(join(testDir, "partial-"));
|
|
202
|
+
writeFileSync(
|
|
203
|
+
join(envDir, "lockfile.json"),
|
|
204
|
+
JSON.stringify({
|
|
205
|
+
assistants: [
|
|
206
|
+
{ assistantId: "no-url" }, // missing runtimeUrl
|
|
207
|
+
{ runtimeUrl: "http://x" }, // missing assistantId
|
|
208
|
+
{
|
|
209
|
+
assistantId: "good",
|
|
210
|
+
runtimeUrl: "http://localhost:7821",
|
|
211
|
+
cloud: "local",
|
|
212
|
+
},
|
|
213
|
+
],
|
|
214
|
+
}),
|
|
215
|
+
);
|
|
216
|
+
const all = loadAllAssistantsAcrossEnvs([makeEnv("partial", envDir)]);
|
|
217
|
+
expect(all.map((e) => e.assistantId)).toEqual(["good"]);
|
|
218
|
+
});
|
|
219
|
+
|
|
220
|
+
test("end-to-end: dev env's daemon is not flagged as orphan from local env", () => {
|
|
221
|
+
// Simulate Vargas's reported bug: `local` env has no assistants, but a
|
|
222
|
+
// `dev` env assistant is running with a recorded daemon PID. The orphan
|
|
223
|
+
// filter must treat that PID as known.
|
|
224
|
+
const devDir = mkdtempSync(join(testDir, "dev-"));
|
|
225
|
+
const instanceDir = join(devDir, "instances", "quiet-finch");
|
|
226
|
+
makeLocalEntry("quiet-finch", instanceDir, {
|
|
227
|
+
daemon: "19067",
|
|
228
|
+
gateway: "19087",
|
|
229
|
+
qdrant: "19167",
|
|
230
|
+
});
|
|
231
|
+
|
|
232
|
+
writeFileSync(
|
|
233
|
+
join(devDir, "lockfile.json"),
|
|
234
|
+
JSON.stringify({
|
|
235
|
+
assistants: [
|
|
236
|
+
{
|
|
237
|
+
assistantId: "quiet-finch",
|
|
238
|
+
runtimeUrl: "http://127.0.0.1:18100",
|
|
239
|
+
cloud: "local",
|
|
240
|
+
resources: {
|
|
241
|
+
instanceDir,
|
|
242
|
+
daemonPort: 18000,
|
|
243
|
+
gatewayPort: 18100,
|
|
244
|
+
qdrantPort: 18200,
|
|
245
|
+
cesPort: 18300,
|
|
246
|
+
},
|
|
247
|
+
},
|
|
248
|
+
],
|
|
249
|
+
}),
|
|
250
|
+
);
|
|
251
|
+
|
|
252
|
+
const devEntries = loadAllAssistantsAcrossEnvs([makeEnv("dev", devDir)]);
|
|
253
|
+
expect(devEntries).toHaveLength(1);
|
|
254
|
+
|
|
255
|
+
const knownPids = getKnownPidsFromAssistants(devEntries);
|
|
256
|
+
expect(knownPids).toEqual(new Set(["19067", "19087", "19167"]));
|
|
257
|
+
});
|
|
258
|
+
});
|
|
259
|
+
|
|
260
|
+
describe("detectOrphanedProcesses", () => {
|
|
261
|
+
test("excludes PIDs passed via excludePids", async () => {
|
|
262
|
+
// The orphan detector calls `ps ax` and filters by regex. The process
|
|
263
|
+
// running this test (bun) is itself a node-family process whose pid will
|
|
264
|
+
// not match the vellum/qdrant/openclaw regex, so the natural result of
|
|
265
|
+
// the scan is "no rows". To assert exclusion semantics deterministically,
|
|
266
|
+
// we just confirm the function accepts an excludePids option and returns
|
|
267
|
+
// an array — the meaningful behavior assertion lives in the integration
|
|
268
|
+
// path (the function's `knownPids.has(p.pid)` short-circuit), which we
|
|
269
|
+
// exercise indirectly by passing our own PID (guaranteed to never be
|
|
270
|
+
// double-counted).
|
|
271
|
+
const ownPid = String(process.pid);
|
|
272
|
+
const result = await detectOrphanedProcesses({
|
|
273
|
+
excludePids: new Set([ownPid]),
|
|
274
|
+
});
|
|
275
|
+
expect(Array.isArray(result)).toBe(true);
|
|
276
|
+
for (const orphan of result) {
|
|
277
|
+
expect(orphan.pid).not.toBe(ownPid);
|
|
278
|
+
}
|
|
279
|
+
});
|
|
280
|
+
|
|
281
|
+
test("returns an array (smoke)", async () => {
|
|
282
|
+
const result = await detectOrphanedProcesses({
|
|
283
|
+
excludePids: new Set(),
|
|
284
|
+
});
|
|
285
|
+
expect(Array.isArray(result)).toBe(true);
|
|
286
|
+
});
|
|
287
|
+
});
|
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
import { afterAll, afterEach, beforeEach, describe, expect, spyOn, test } from "bun:test";
|
|
2
|
+
import { mkdtempSync, rmSync } from "node:fs";
|
|
3
|
+
import { tmpdir } from "node:os";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
|
|
6
|
+
// ---------------------------------------------------------------------------
|
|
7
|
+
// Temp directory for lockfile isolation
|
|
8
|
+
// ---------------------------------------------------------------------------
|
|
9
|
+
|
|
10
|
+
const testDir = mkdtempSync(join(tmpdir(), "cli-ps-platform-status-test-"));
|
|
11
|
+
process.env.VELLUM_LOCKFILE_DIR = testDir;
|
|
12
|
+
|
|
13
|
+
// ---------------------------------------------------------------------------
|
|
14
|
+
// Mocks — set up before importing the command under test. All spies are
|
|
15
|
+
// restored in afterAll so we don't leak module state to neighbouring suites.
|
|
16
|
+
// ---------------------------------------------------------------------------
|
|
17
|
+
|
|
18
|
+
import * as assistantConfig from "../lib/assistant-config.js";
|
|
19
|
+
import * as orphanDetection from "../lib/orphan-detection.js";
|
|
20
|
+
import * as platformClient from "../lib/platform-client.js";
|
|
21
|
+
|
|
22
|
+
const loadAllAssistantsMock = spyOn(
|
|
23
|
+
assistantConfig,
|
|
24
|
+
"loadAllAssistants",
|
|
25
|
+
).mockReturnValue([]);
|
|
26
|
+
const getActiveAssistantMock = spyOn(
|
|
27
|
+
assistantConfig,
|
|
28
|
+
"getActiveAssistant",
|
|
29
|
+
).mockReturnValue(null);
|
|
30
|
+
const detectOrphanedProcessesMock = spyOn(
|
|
31
|
+
orphanDetection,
|
|
32
|
+
"detectOrphanedProcesses",
|
|
33
|
+
).mockResolvedValue([]);
|
|
34
|
+
const getPlatformUrlMock = spyOn(
|
|
35
|
+
platformClient,
|
|
36
|
+
"getPlatformUrl",
|
|
37
|
+
).mockReturnValue("http://platform.test");
|
|
38
|
+
|
|
39
|
+
// Per-test toggle for `readPlatformToken`.
|
|
40
|
+
const readPlatformTokenMock = spyOn(
|
|
41
|
+
platformClient,
|
|
42
|
+
"readPlatformToken",
|
|
43
|
+
).mockReturnValue(null);
|
|
44
|
+
|
|
45
|
+
// `fetchCurrentUser` + `fetchPlatformAssistants` are spied so we can assert
|
|
46
|
+
// they're never invoked on the no-token path, and re-shaped per-test for the
|
|
47
|
+
// token-but-unreachable path.
|
|
48
|
+
const fetchCurrentUserMock = spyOn(
|
|
49
|
+
platformClient,
|
|
50
|
+
"fetchCurrentUser",
|
|
51
|
+
).mockResolvedValue({
|
|
52
|
+
id: "u1",
|
|
53
|
+
email: "test@example.com",
|
|
54
|
+
display: "Test",
|
|
55
|
+
});
|
|
56
|
+
const fetchPlatformAssistantsMock = spyOn(
|
|
57
|
+
platformClient,
|
|
58
|
+
"fetchPlatformAssistants",
|
|
59
|
+
).mockResolvedValue([]);
|
|
60
|
+
|
|
61
|
+
// ---------------------------------------------------------------------------
|
|
62
|
+
// stdout / stderr capture
|
|
63
|
+
// ---------------------------------------------------------------------------
|
|
64
|
+
|
|
65
|
+
let stdout: string[];
|
|
66
|
+
let stderr: string[];
|
|
67
|
+
let originalLog: typeof console.log;
|
|
68
|
+
let originalError: typeof console.error;
|
|
69
|
+
|
|
70
|
+
beforeEach(() => {
|
|
71
|
+
stdout = [];
|
|
72
|
+
stderr = [];
|
|
73
|
+
originalLog = console.log;
|
|
74
|
+
originalError = console.error;
|
|
75
|
+
console.log = ((...args: unknown[]) => {
|
|
76
|
+
stdout.push(args.map((a) => String(a)).join(" "));
|
|
77
|
+
}) as typeof console.log;
|
|
78
|
+
console.error = ((...args: unknown[]) => {
|
|
79
|
+
stderr.push(args.map((a) => String(a)).join(" "));
|
|
80
|
+
}) as typeof console.error;
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
afterEach(() => {
|
|
84
|
+
console.log = originalLog;
|
|
85
|
+
console.error = originalError;
|
|
86
|
+
readPlatformTokenMock.mockReturnValue(null);
|
|
87
|
+
fetchCurrentUserMock.mockReset();
|
|
88
|
+
fetchCurrentUserMock.mockResolvedValue({
|
|
89
|
+
id: "u1",
|
|
90
|
+
email: "test@example.com",
|
|
91
|
+
display: "Test",
|
|
92
|
+
});
|
|
93
|
+
fetchPlatformAssistantsMock.mockReset();
|
|
94
|
+
fetchPlatformAssistantsMock.mockResolvedValue([]);
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
afterAll(() => {
|
|
98
|
+
loadAllAssistantsMock.mockRestore();
|
|
99
|
+
getActiveAssistantMock.mockRestore();
|
|
100
|
+
detectOrphanedProcessesMock.mockRestore();
|
|
101
|
+
getPlatformUrlMock.mockRestore();
|
|
102
|
+
readPlatformTokenMock.mockRestore();
|
|
103
|
+
fetchCurrentUserMock.mockRestore();
|
|
104
|
+
fetchPlatformAssistantsMock.mockRestore();
|
|
105
|
+
rmSync(testDir, { recursive: true, force: true });
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
// ---------------------------------------------------------------------------
|
|
109
|
+
// Import the command under test AFTER mocks are wired up
|
|
110
|
+
// ---------------------------------------------------------------------------
|
|
111
|
+
|
|
112
|
+
import { listAllAssistants } from "../commands/ps.js";
|
|
113
|
+
|
|
114
|
+
// ---------------------------------------------------------------------------
|
|
115
|
+
// Tests
|
|
116
|
+
// ---------------------------------------------------------------------------
|
|
117
|
+
|
|
118
|
+
describe("vellum ps — platform status line", () => {
|
|
119
|
+
test("no local token: prints 'Platform: not logged in' and skips ALL network fetches", async () => {
|
|
120
|
+
readPlatformTokenMock.mockReturnValue(null);
|
|
121
|
+
|
|
122
|
+
await listAllAssistants(false);
|
|
123
|
+
|
|
124
|
+
// The status line is present, exactly once, with no redundant error log.
|
|
125
|
+
expect(stdout.filter((l) => l.startsWith("Platform:"))).toEqual([
|
|
126
|
+
"Platform: not logged in",
|
|
127
|
+
]);
|
|
128
|
+
expect(
|
|
129
|
+
stderr.some((l) => l.includes("Failed to fetch organization")),
|
|
130
|
+
).toBe(false);
|
|
131
|
+
expect(
|
|
132
|
+
stdout.some((l) => l.includes("Failed to fetch organization")),
|
|
133
|
+
).toBe(false);
|
|
134
|
+
|
|
135
|
+
// Structural guarantee: we never even tried to talk to the platform.
|
|
136
|
+
expect(fetchCurrentUserMock).not.toHaveBeenCalled();
|
|
137
|
+
expect(fetchPlatformAssistantsMock).not.toHaveBeenCalled();
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
test("local token present but platform is unreachable: still shows 'Platform: not logged in' with no leaked org-fetch error", async () => {
|
|
141
|
+
readPlatformTokenMock.mockReturnValue("session_abc123");
|
|
142
|
+
// Simulate the exact Bun connect failure the user reported:
|
|
143
|
+
// "Unable to connect. Is the computer able to access the url?"
|
|
144
|
+
const connectError = new Error(
|
|
145
|
+
"Unable to connect. Is the computer able to access the url?",
|
|
146
|
+
);
|
|
147
|
+
fetchCurrentUserMock.mockRejectedValue(connectError);
|
|
148
|
+
fetchPlatformAssistantsMock.mockRejectedValue(connectError);
|
|
149
|
+
|
|
150
|
+
await listAllAssistants(false);
|
|
151
|
+
|
|
152
|
+
expect(stdout.filter((l) => l.startsWith("Platform:"))).toEqual([
|
|
153
|
+
"Platform: not logged in",
|
|
154
|
+
]);
|
|
155
|
+
expect(
|
|
156
|
+
stderr.some((l) => l.includes("Failed to fetch organization")),
|
|
157
|
+
).toBe(false);
|
|
158
|
+
expect(
|
|
159
|
+
stdout.some((l) => l.includes("Failed to fetch organization")),
|
|
160
|
+
).toBe(false);
|
|
161
|
+
expect(
|
|
162
|
+
stderr.some((l) => l.includes("Unable to connect")),
|
|
163
|
+
).toBe(false);
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
test("local token present and platform reachable: prints 'Platform: logged in as <email>'", async () => {
|
|
167
|
+
readPlatformTokenMock.mockReturnValue("session_abc123");
|
|
168
|
+
fetchCurrentUserMock.mockResolvedValue({
|
|
169
|
+
id: "u1",
|
|
170
|
+
email: "vargas@vellum.ai",
|
|
171
|
+
display: "Vargas",
|
|
172
|
+
});
|
|
173
|
+
fetchPlatformAssistantsMock.mockResolvedValue([]);
|
|
174
|
+
|
|
175
|
+
await listAllAssistants(false);
|
|
176
|
+
|
|
177
|
+
expect(stdout).toContain("Platform: logged in as vargas@vellum.ai");
|
|
178
|
+
expect(
|
|
179
|
+
stderr.some((l) => l.includes("Failed to fetch organization")),
|
|
180
|
+
).toBe(false);
|
|
181
|
+
});
|
|
182
|
+
});
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import { readFileSync } from "node:fs";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import { describe, expect, test } from "bun:test";
|
|
4
|
+
|
|
5
|
+
import { SEARCH_PROVIDER_ENV_VAR_NAMES } from "../shared/provider-env-vars.js";
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Drift guard for the CLI-side search provider env-var mirror.
|
|
9
|
+
*
|
|
10
|
+
* `cli/src/shared/provider-env-vars.ts` hardcodes the search env-var names so
|
|
11
|
+
* the CLI doesn't need to import the assistant's
|
|
12
|
+
* `SEARCH_PROVIDER_CATALOG` (no CLI → assistant cross-package imports exist).
|
|
13
|
+
* This test pulls the catalog JSON at `meta/web-search-provider-catalog.json`
|
|
14
|
+
* — which is kept in sync with `SEARCH_PROVIDER_CATALOG` by
|
|
15
|
+
* `assistant/src/__tests__/web-search-catalog-parity.test.ts` — and asserts
|
|
16
|
+
* the CLI's mirror matches the catalog's `envVar` entries.
|
|
17
|
+
*
|
|
18
|
+
* Mirrors `llm-provider-env-var-parity.test.ts`.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
const REPO_ROOT = join(import.meta.dir, "..", "..", "..");
|
|
22
|
+
|
|
23
|
+
interface SearchCatalogEntry {
|
|
24
|
+
id: string;
|
|
25
|
+
kind: "managed" | "byok";
|
|
26
|
+
envVar?: string;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
interface SearchCatalog {
|
|
30
|
+
version: number;
|
|
31
|
+
providers: SearchCatalogEntry[];
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function loadSearchCatalog(): SearchCatalog {
|
|
35
|
+
const path = join(REPO_ROOT, "meta", "web-search-provider-catalog.json");
|
|
36
|
+
return JSON.parse(readFileSync(path, "utf-8"));
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
describe("CLI search provider env-var parity", () => {
|
|
40
|
+
test("SEARCH_PROVIDER_ENV_VAR_NAMES matches meta/web-search-provider-catalog.json entries with envVar", () => {
|
|
41
|
+
const catalog = loadSearchCatalog();
|
|
42
|
+
const expected: Record<string, string> = {};
|
|
43
|
+
for (const provider of catalog.providers) {
|
|
44
|
+
if (provider.envVar) expected[provider.id] = provider.envVar;
|
|
45
|
+
}
|
|
46
|
+
expect(SEARCH_PROVIDER_ENV_VAR_NAMES).toEqual(expected);
|
|
47
|
+
});
|
|
48
|
+
});
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import { describe, expect, spyOn, test } from "bun:test";
|
|
2
|
+
|
|
3
|
+
import { renderMarkdown } from "../commands/events.js";
|
|
4
|
+
|
|
5
|
+
type AssistantEvent = Parameters<typeof renderMarkdown>[0];
|
|
6
|
+
|
|
7
|
+
function makeEvent(message: AssistantEvent["message"]): AssistantEvent {
|
|
8
|
+
return {
|
|
9
|
+
id: "event-123",
|
|
10
|
+
assistantId: "assistant-123",
|
|
11
|
+
emittedAt: "2026-01-01T00:00:00.000Z",
|
|
12
|
+
message,
|
|
13
|
+
};
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
describe("sync_changed events", () => {
|
|
17
|
+
test("renders sync tags clearly in vellum events markdown output", () => {
|
|
18
|
+
const consoleLog = spyOn(console, "log").mockImplementation(() => {});
|
|
19
|
+
try {
|
|
20
|
+
renderMarkdown(
|
|
21
|
+
makeEvent({
|
|
22
|
+
type: "sync_changed",
|
|
23
|
+
tags: ["assistant:self:avatar", "conversations:list"],
|
|
24
|
+
}),
|
|
25
|
+
);
|
|
26
|
+
|
|
27
|
+
expect(consoleLog).toHaveBeenCalledWith(
|
|
28
|
+
"\n> **Sync changed:** `assistant:self:avatar`, `conversations:list`",
|
|
29
|
+
);
|
|
30
|
+
} finally {
|
|
31
|
+
consoleLog.mockRestore();
|
|
32
|
+
}
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
test("tolerates malformed sync tags without throwing", () => {
|
|
36
|
+
const consoleLog = spyOn(console, "log").mockImplementation(() => {});
|
|
37
|
+
try {
|
|
38
|
+
expect(() =>
|
|
39
|
+
renderMarkdown(
|
|
40
|
+
makeEvent({
|
|
41
|
+
type: "sync_changed",
|
|
42
|
+
tags: ["assistant:self:avatar", 42, null],
|
|
43
|
+
}),
|
|
44
|
+
),
|
|
45
|
+
).not.toThrow();
|
|
46
|
+
|
|
47
|
+
expect(consoleLog).toHaveBeenCalledWith(
|
|
48
|
+
"\n> **Sync changed:** `assistant:self:avatar`",
|
|
49
|
+
);
|
|
50
|
+
} finally {
|
|
51
|
+
consoleLog.mockRestore();
|
|
52
|
+
}
|
|
53
|
+
});
|
|
54
|
+
});
|