@vellumai/cli 0.8.3 → 0.8.4

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/AGENTS.md CHANGED
@@ -55,14 +55,26 @@ For example, the signing key used for JWT auth between the daemon and gateway is
55
55
 
56
56
  ## Docker Volume Management
57
57
 
58
- The CLI creates and manages Docker volumes for containerized instances. See the root `AGENTS.md` § Docker Volume Architecture for the full volume layout.
58
+ The CLI creates and manages six per-instance Docker volumes with strict per-service access boundaries (least-privilege at the container level).
59
59
 
60
- **Volume creation** (`hatch`): Creates six volumes per instance — workspace, gateway-security, ces-security, socket, assistant-ipc, and gateway-ipc. The legacy data volume is no longer created.
60
+ | Volume | Mount path | Access | Contents |
61
+ | ------------------------------------------- | -------------------- | ----------------------------------- | -------------------------------------------------------------------------------- |
62
+ | **Workspace** (`<name>-workspace`) | `/workspace` | Assistant: rw, Gateway: rw, CES: ro | `config.json`, conversations, apps, skills, db, logs, `.backups/`, `.backup.key` |
63
+ | **Gateway security** (`<name>-gateway-sec`) | `/gateway-security` | Gateway only | `trust.json`, `actor-token-signing-key`, capability-token secrets |
64
+ | **CES security** (`<name>-ces-sec`) | `/ces-security` | CES only | `keys.enc`, `store.key` |
65
+ | **Socket** (`<name>-socket`) | `/run/ces-bootstrap` | Assistant + CES | CES bootstrap socket for initial handshake |
66
+ | **Gateway IPC** (`<name>-gateway-ipc`) | `/run/gateway-ipc` | Assistant + Gateway | `gateway.sock` (assistant → gateway) |
67
+ | **Assistant IPC** (`<name>-assistant-ipc`) | `/run/assistant-ipc` | Assistant + Gateway | `assistant.sock` (gateway → assistant) |
61
68
 
62
- **Volume migration** (`wake`/`hatch`): On startup, existing instances that still have a legacy data volume are migrated. `migrateGatewaySecurityFiles()` and `migrateCesSecurityFiles()` in `lib/docker.ts` copy security files from the data volume to their respective security volumes. Migrations are idempotent and non-fatal.
69
+ The assistant container's root (`/`) holds per-container ephemeral and persistent state: package installs (`~/.bun`), `device.json`, embed-worker PID files.
63
70
 
64
- **Volume cleanup** (`retire`): All volumes (including the legacy data volume if it exists) are removed when an instance is retired.
71
+ **Lifecycle**:
65
72
 
66
- **Volume mount rules**: Each service container receives only the volumes it needs. The assistant never mounts `gateway-security` or `ces-security`. The gateway never mounts `ces-security`. The CES mounts the workspace volume as read-only.
73
+ - `hatch` creates the six volumes.
74
+ - `retire` removes all of them.
67
75
 
68
- **Container security posture**: The assistant container runs as a non-root user (UID 1001) with no elevated capabilities. `--privileged`, `--cap-add`, and `--security-opt` overrides are NOT used. The host Docker socket is NOT bind-mounted. Do NOT re-add elevated capabilities without a concrete runtime requirement — the Docker Engine packages and inner `dockerd` supervisor were reverted (PR #26028) and the capabilities they required are no longer needed.
76
+ **Mount rules**: each container receives only the volumes it needs. The assistant never mounts `gateway-security` or `ces-security`. The gateway never mounts `ces-security`. The CES mounts the workspace volume as read-only.
77
+
78
+ **Container security posture**: the assistant container runs as a non-root user (UID 1001) with no elevated capabilities — `--privileged`, `--cap-add`, and `--security-opt` overrides are not used; the host Docker socket is not bind-mounted; default Docker seccomp and AppArmor profiles remain active. Do not add elevated capabilities without a concrete runtime requirement.
79
+
80
+ **Backup paths in Docker mode**: backups land on the workspace volume (`VELLUM_BACKUP_DIR` defaults to `/workspace/.backups/`, key at `VELLUM_BACKUP_KEY_PATH` defaults to `/workspace/.backup.key`), so workspace-volume destruction loses both data and backups.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vellumai/cli",
3
- "version": "0.8.3",
3
+ "version": "0.8.4",
4
4
  "description": "CLI tools for vellum-assistant",
5
5
  "type": "module",
6
6
  "exports": {
@@ -10,6 +10,10 @@ process.env.VELLUM_LOCKFILE_DIR = testDir;
10
10
  import {
11
11
  loadLatestAssistant,
12
12
  findAssistantByName,
13
+ formatAssistantLookupError,
14
+ formatAssistantReference,
15
+ getAssistantDisplayName,
16
+ lookupAssistantByIdentifier,
13
17
  removeAssistantEntry,
14
18
  loadAllAssistants,
15
19
  saveAssistantEntry,
@@ -87,6 +91,110 @@ describe("assistant-config", () => {
87
91
  expect(result!.assistantId).toBe("beta");
88
92
  });
89
93
 
94
+ test("getAssistantDisplayName prefers platform and legacy display names", () => {
95
+ expect(
96
+ getAssistantDisplayName(
97
+ makeEntry("assistant-1", undefined, { name: "Alice" }),
98
+ ),
99
+ ).toBe("Alice");
100
+ expect(
101
+ getAssistantDisplayName(
102
+ makeEntry("assistant-2", undefined, { assistantName: "Legacy Alice" }),
103
+ ),
104
+ ).toBe("Legacy Alice");
105
+ expect(getAssistantDisplayName(makeEntry("assistant-3"))).toBe(
106
+ "assistant-3",
107
+ );
108
+ });
109
+
110
+ test("findAssistantByName only resolves assistant IDs", () => {
111
+ writeLockfile({
112
+ assistants: [
113
+ makeEntry("assistant-1", "http://localhost:7821", { name: "Alice" }),
114
+ makeEntry("assistant-2", "http://localhost:7822", { name: "Bob" }),
115
+ ],
116
+ });
117
+
118
+ expect(findAssistantByName("Alice")).toBeNull();
119
+ expect(findAssistantByName("assistant-1")?.assistantId).toBe("assistant-1");
120
+ });
121
+
122
+ test("lookupAssistantByIdentifier resolves a unique display name", () => {
123
+ writeLockfile({
124
+ assistants: [
125
+ makeEntry("assistant-1", "http://localhost:7821", { name: "Alice" }),
126
+ makeEntry("assistant-2", "http://localhost:7822", { name: "Bob" }),
127
+ ],
128
+ });
129
+
130
+ const result = lookupAssistantByIdentifier("Alice");
131
+ expect(result.status).toBe("found");
132
+ expect(result.status === "found" ? result.entry.assistantId : null).toBe(
133
+ "assistant-1",
134
+ );
135
+ });
136
+
137
+ test("lookupAssistantByIdentifier resolves a unique legacy assistantName", () => {
138
+ writeLockfile({
139
+ assistants: [
140
+ makeEntry("assistant-1", "http://localhost:7821", {
141
+ assistantName: "Legacy Alice",
142
+ }),
143
+ ],
144
+ });
145
+
146
+ const result = lookupAssistantByIdentifier("Legacy Alice");
147
+ expect(result.status).toBe("found");
148
+ expect(result.status === "found" ? result.entry.assistantId : null).toBe(
149
+ "assistant-1",
150
+ );
151
+ });
152
+
153
+ test("assistant ID lookup wins over a display name match", () => {
154
+ writeLockfile({
155
+ assistants: [
156
+ makeEntry("Alice", "http://localhost:7821", { name: "Primary" }),
157
+ makeEntry("assistant-2", "http://localhost:7822", { name: "Alice" }),
158
+ ],
159
+ });
160
+
161
+ const result = lookupAssistantByIdentifier("Alice");
162
+ expect(result.status).toBe("found");
163
+ expect(result.status === "found" ? result.entry.assistantId : null).toBe(
164
+ "Alice",
165
+ );
166
+ });
167
+
168
+ test("ambiguous display name lookup is explicit", () => {
169
+ writeLockfile({
170
+ assistants: [
171
+ makeEntry("assistant-1", "http://localhost:7821", { name: "Alice" }),
172
+ makeEntry("assistant-2", "http://localhost:7822", { name: "Alice" }),
173
+ ],
174
+ });
175
+
176
+ const result = lookupAssistantByIdentifier("Alice");
177
+ expect(result.status).toBe("ambiguous");
178
+ expect(findAssistantByName("Alice")).toBeNull();
179
+ expect(formatAssistantLookupError("Alice", result)).toContain(
180
+ "assistant-1",
181
+ );
182
+ expect(formatAssistantLookupError("Alice", result)).toContain(
183
+ "assistant-2",
184
+ );
185
+ });
186
+
187
+ test("formatAssistantReference includes distinct display name and id", () => {
188
+ expect(
189
+ formatAssistantReference(
190
+ makeEntry("assistant-1", undefined, { name: "Alice" }),
191
+ ),
192
+ ).toBe("Alice (assistant-1)");
193
+ expect(formatAssistantReference(makeEntry("assistant-2"))).toBe(
194
+ "assistant-2",
195
+ );
196
+ });
197
+
90
198
  test("findAssistantByName returns null for non-existent name", () => {
91
199
  writeLockfile({ assistants: [makeEntry("alpha")] });
92
200
  expect(findAssistantByName("missing")).toBeNull();
@@ -0,0 +1,30 @@
1
+ import { describe, expect, test } from "bun:test";
2
+
3
+ import { parseAssistantTargetArg } from "../lib/assistant-target-args.js";
4
+
5
+ describe("parseAssistantTargetArg", () => {
6
+ test("joins unquoted display-name words into one assistant target", () => {
7
+ expect(parseAssistantTargetArg(["Example", "Assistant"])).toBe(
8
+ "Example Assistant",
9
+ );
10
+ });
11
+
12
+ test("skips boolean flags", () => {
13
+ expect(parseAssistantTargetArg(["Example", "Assistant", "--verbose"])).toBe(
14
+ "Example Assistant",
15
+ );
16
+ });
17
+
18
+ test("skips configured flags and their values", () => {
19
+ expect(
20
+ parseAssistantTargetArg(
21
+ ["--url", "http://localhost:7830", "Example", "Assistant"],
22
+ ["--url"],
23
+ ),
24
+ ).toBe("Example Assistant");
25
+ });
26
+
27
+ test("returns undefined when no target is present", () => {
28
+ expect(parseAssistantTargetArg(["--verbose"])).toBeUndefined();
29
+ });
30
+ });
@@ -0,0 +1,206 @@
1
+ import { describe, expect, test } from "bun:test";
2
+
3
+ import {
4
+ type FetchLike,
5
+ HOST_IMAGE_LOADER_URL,
6
+ HostImageLoaderError,
7
+ isLocalBuildRef,
8
+ loadImageViaHost,
9
+ } from "../lib/host-image-loader.js";
10
+
11
+ describe("HOST_IMAGE_LOADER_URL", () => {
12
+ test("resolves to the well-known image-loader port/path", () => {
13
+ expect(HOST_IMAGE_LOADER_URL).toBe("http://127.0.0.1:5500/v1/images/load");
14
+ });
15
+ });
16
+
17
+ describe("isLocalBuildRef", () => {
18
+ test("recognizes the `vellum-local/` prefix as a local build", () => {
19
+ expect(isLocalBuildRef("vellum-local/assistant-server:sha-abc123")).toBe(
20
+ true,
21
+ );
22
+ expect(isLocalBuildRef("vellum-local/gateway:sha-def")).toBe(true);
23
+ });
24
+
25
+ test("treats external registry refs as pullable", () => {
26
+ expect(isLocalBuildRef("docker.io/example/image:v0.8.2")).toBe(false);
27
+ expect(
28
+ isLocalBuildRef("us-east1-docker.pkg.dev/example/image@sha256:deadbeef"),
29
+ ).toBe(false);
30
+ expect(isLocalBuildRef("postgres:17")).toBe(false);
31
+ });
32
+ });
33
+
34
+ function silentLog(_msg: string): void {
35
+ // intentionally swallow logs in test
36
+ }
37
+
38
+ function makeFetch(
39
+ responses: Array<{ url: string; body: unknown; status: number }>,
40
+ recordedRequests: Array<{ url: string; body: unknown }>,
41
+ ): FetchLike {
42
+ return async (input, init) => {
43
+ const url = typeof input === "string" ? input : input.toString();
44
+ const body = init?.body ? JSON.parse(init.body) : null;
45
+ recordedRequests.push({ url, body });
46
+ const planned = responses.shift();
47
+ if (!planned) throw new Error(`unexpected request to ${url}`);
48
+ return new Response(JSON.stringify(planned.body), {
49
+ status: planned.status,
50
+ headers: { "Content-Type": "application/json" },
51
+ });
52
+ };
53
+ }
54
+
55
+ describe("loadImageViaHost", () => {
56
+ test("POSTs {ref} to the URL and resolves on 200", async () => {
57
+ const recorded: Array<{ url: string; body: unknown }> = [];
58
+ const fetchImpl = makeFetch(
59
+ [
60
+ {
61
+ url: "http://127.0.0.1:5500/v1/images/load",
62
+ body: {
63
+ loaded: true,
64
+ ref: "vellum-local/assistant:sha-abc",
65
+ },
66
+ status: 200,
67
+ },
68
+ ],
69
+ recorded,
70
+ );
71
+
72
+ await loadImageViaHost(
73
+ "http://127.0.0.1:5500/v1/images/load",
74
+ "vellum-local/assistant:sha-abc",
75
+ silentLog,
76
+ { fetchImpl },
77
+ );
78
+
79
+ expect(recorded).toHaveLength(1);
80
+ expect(recorded[0].url).toBe("http://127.0.0.1:5500/v1/images/load");
81
+ expect(recorded[0].body).toEqual({
82
+ ref: "vellum-local/assistant:sha-abc",
83
+ });
84
+ });
85
+
86
+ test("throws HostImageLoaderError with status when server returns non-2xx", async () => {
87
+ const recorded: Array<{ url: string; body: unknown }> = [];
88
+ const fetchImpl = makeFetch(
89
+ [
90
+ {
91
+ url: "http://127.0.0.1:5500/v1/images/load",
92
+ body: { loaded: false, error: "docker save failed: image not found" },
93
+ status: 502,
94
+ },
95
+ ],
96
+ recorded,
97
+ );
98
+
99
+ await expect(
100
+ loadImageViaHost(
101
+ "http://127.0.0.1:5500/v1/images/load",
102
+ "vellum-local/nope:abc",
103
+ silentLog,
104
+ { fetchImpl },
105
+ ),
106
+ ).rejects.toBeInstanceOf(HostImageLoaderError);
107
+
108
+ // Re-run to inspect fields (one-shot fetchImpl, so build a new one)
109
+ const recorded2: Array<{ url: string; body: unknown }> = [];
110
+ const fetchImpl2 = makeFetch(
111
+ [
112
+ {
113
+ url: "http://127.0.0.1:5500/v1/images/load",
114
+ body: { loaded: false, error: "docker save failed: image not found" },
115
+ status: 502,
116
+ },
117
+ ],
118
+ recorded2,
119
+ );
120
+
121
+ let caught: HostImageLoaderError | null = null;
122
+ try {
123
+ await loadImageViaHost(
124
+ "http://127.0.0.1:5500/v1/images/load",
125
+ "vellum-local/nope:abc",
126
+ silentLog,
127
+ { fetchImpl: fetchImpl2 },
128
+ );
129
+ } catch (err) {
130
+ caught = err as HostImageLoaderError;
131
+ }
132
+ expect(caught).not.toBeNull();
133
+ expect(caught?.status).toBe(502);
134
+ expect(caught?.ref).toBe("vellum-local/nope:abc");
135
+ expect(caught?.message).toContain("502");
136
+ expect(caught?.message).toContain("docker save failed");
137
+ });
138
+
139
+ test("provides helpful guidance when the loader is unreachable", async () => {
140
+ const fetchImpl: FetchLike = async () => {
141
+ const err = new TypeError("fetch failed") as TypeError & {
142
+ cause?: { code?: string };
143
+ };
144
+ err.cause = { code: "ECONNREFUSED" };
145
+ throw err;
146
+ };
147
+
148
+ let caught: HostImageLoaderError | null = null;
149
+ try {
150
+ await loadImageViaHost(
151
+ "http://127.0.0.1:5500/v1/images/load",
152
+ "vellum-local/anything:xyz",
153
+ silentLog,
154
+ { fetchImpl },
155
+ );
156
+ } catch (err) {
157
+ caught = err as HostImageLoaderError;
158
+ }
159
+ expect(caught).not.toBeNull();
160
+ expect(caught?.message).toContain("loader running");
161
+ expect(caught?.message).toContain("VELLUM_ASSISTANT_IMAGE");
162
+ });
163
+
164
+ test("wraps generic fetch errors", async () => {
165
+ const fetchImpl: FetchLike = async () => {
166
+ throw new Error("ETIMEDOUT");
167
+ };
168
+
169
+ let caught: HostImageLoaderError | null = null;
170
+ try {
171
+ await loadImageViaHost(
172
+ "http://127.0.0.1:5500/v1/images/load",
173
+ "x",
174
+ silentLog,
175
+ { fetchImpl },
176
+ );
177
+ } catch (err) {
178
+ caught = err as HostImageLoaderError;
179
+ }
180
+ expect(caught).not.toBeNull();
181
+ expect(caught?.message).toContain("ETIMEDOUT");
182
+ });
183
+
184
+ test("handles non-JSON error bodies", async () => {
185
+ const fetchImpl: FetchLike = async () =>
186
+ new Response("<html>500 internal</html>", {
187
+ status: 500,
188
+ headers: { "Content-Type": "text/html" },
189
+ });
190
+
191
+ let caught: HostImageLoaderError | null = null;
192
+ try {
193
+ await loadImageViaHost(
194
+ "http://127.0.0.1:5500/v1/images/load",
195
+ "x",
196
+ silentLog,
197
+ { fetchImpl },
198
+ );
199
+ } catch (err) {
200
+ caught = err as HostImageLoaderError;
201
+ }
202
+ expect(caught).not.toBeNull();
203
+ expect(caught?.status).toBe(500);
204
+ expect(caught?.message).toContain("HTTP 500");
205
+ });
206
+ });
@@ -1,4 +1,12 @@
1
- import { afterAll, afterEach, beforeEach, describe, expect, spyOn, test } from "bun:test";
1
+ import {
2
+ afterAll,
3
+ afterEach,
4
+ beforeEach,
5
+ describe,
6
+ expect,
7
+ spyOn,
8
+ test,
9
+ } from "bun:test";
2
10
  import { mkdtempSync, rmSync } from "node:fs";
3
11
  import { tmpdir } from "node:os";
4
12
  import { join } from "node:path";
@@ -16,6 +24,7 @@ process.env.VELLUM_LOCKFILE_DIR = testDir;
16
24
  // ---------------------------------------------------------------------------
17
25
 
18
26
  import * as assistantConfig from "../lib/assistant-config.js";
27
+ import * as healthCheck from "../lib/health-check.js";
19
28
  import * as orphanDetection from "../lib/orphan-detection.js";
20
29
  import * as platformClient from "../lib/platform-client.js";
21
30
 
@@ -57,6 +66,10 @@ const fetchPlatformAssistantsMock = spyOn(
57
66
  platformClient,
58
67
  "fetchPlatformAssistants",
59
68
  ).mockResolvedValue([]);
69
+ const checkManagedHealthMock = spyOn(
70
+ healthCheck,
71
+ "checkManagedHealth",
72
+ ).mockResolvedValue({ status: "healthy", detail: null });
60
73
 
61
74
  // ---------------------------------------------------------------------------
62
75
  // stdout / stderr capture
@@ -66,23 +79,32 @@ let stdout: string[];
66
79
  let stderr: string[];
67
80
  let originalLog: typeof console.log;
68
81
  let originalError: typeof console.error;
82
+ let originalStdoutWrite: typeof process.stdout.write;
69
83
 
70
84
  beforeEach(() => {
71
85
  stdout = [];
72
86
  stderr = [];
73
87
  originalLog = console.log;
74
88
  originalError = console.error;
89
+ originalStdoutWrite = process.stdout.write;
75
90
  console.log = ((...args: unknown[]) => {
76
91
  stdout.push(args.map((a) => String(a)).join(" "));
77
92
  }) as typeof console.log;
78
93
  console.error = ((...args: unknown[]) => {
79
94
  stderr.push(args.map((a) => String(a)).join(" "));
80
95
  }) as typeof console.error;
96
+ process.stdout.write = ((chunk: string | Uint8Array) => {
97
+ stdout.push(String(chunk));
98
+ return true;
99
+ }) as typeof process.stdout.write;
81
100
  });
82
101
 
83
102
  afterEach(() => {
84
103
  console.log = originalLog;
85
104
  console.error = originalError;
105
+ process.stdout.write = originalStdoutWrite;
106
+ loadAllAssistantsMock.mockReturnValue([]);
107
+ getActiveAssistantMock.mockReturnValue(null);
86
108
  readPlatformTokenMock.mockReturnValue(null);
87
109
  fetchCurrentUserMock.mockReset();
88
110
  fetchCurrentUserMock.mockResolvedValue({
@@ -92,6 +114,8 @@ afterEach(() => {
92
114
  });
93
115
  fetchPlatformAssistantsMock.mockReset();
94
116
  fetchPlatformAssistantsMock.mockResolvedValue([]);
117
+ checkManagedHealthMock.mockReset();
118
+ checkManagedHealthMock.mockResolvedValue({ status: "healthy", detail: null });
95
119
  });
96
120
 
97
121
  afterAll(() => {
@@ -102,6 +126,7 @@ afterAll(() => {
102
126
  readPlatformTokenMock.mockRestore();
103
127
  fetchCurrentUserMock.mockRestore();
104
128
  fetchPlatformAssistantsMock.mockRestore();
129
+ checkManagedHealthMock.mockRestore();
105
130
  rmSync(testDir, { recursive: true, force: true });
106
131
  });
107
132
 
@@ -125,12 +150,12 @@ describe("vellum ps — platform status line", () => {
125
150
  expect(stdout.filter((l) => l.startsWith("Platform:"))).toEqual([
126
151
  "Platform: not logged in",
127
152
  ]);
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);
153
+ expect(stderr.some((l) => l.includes("Failed to fetch organization"))).toBe(
154
+ false,
155
+ );
156
+ expect(stdout.some((l) => l.includes("Failed to fetch organization"))).toBe(
157
+ false,
158
+ );
134
159
 
135
160
  // Structural guarantee: we never even tried to talk to the platform.
136
161
  expect(fetchCurrentUserMock).not.toHaveBeenCalled();
@@ -152,31 +177,84 @@ describe("vellum ps — platform status line", () => {
152
177
  expect(stdout.filter((l) => l.startsWith("Platform:"))).toEqual([
153
178
  "Platform: not logged in",
154
179
  ]);
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);
180
+ expect(stderr.some((l) => l.includes("Failed to fetch organization"))).toBe(
181
+ false,
182
+ );
183
+ expect(stdout.some((l) => l.includes("Failed to fetch organization"))).toBe(
184
+ false,
185
+ );
186
+ expect(stderr.some((l) => l.includes("Unable to connect"))).toBe(false);
164
187
  });
165
188
 
166
189
  test("local token present and platform reachable: prints 'Platform: logged in as <email>'", async () => {
167
190
  readPlatformTokenMock.mockReturnValue("session_abc123");
168
191
  fetchCurrentUserMock.mockResolvedValue({
169
192
  id: "u1",
170
- email: "vargas@vellum.ai",
171
- display: "Vargas",
193
+ email: "user@example.com",
194
+ display: "Example User",
172
195
  });
173
196
  fetchPlatformAssistantsMock.mockResolvedValue([]);
174
197
 
175
198
  await listAllAssistants(false);
176
199
 
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);
200
+ expect(stdout).toContain("Platform: logged in as user@example.com");
201
+ expect(stderr.some((l) => l.includes("Failed to fetch organization"))).toBe(
202
+ false,
203
+ );
204
+ });
205
+
206
+ test("assistant rows use display name as primary label and keep id visible", async () => {
207
+ loadAllAssistantsMock.mockReturnValue([
208
+ {
209
+ assistantId: "assistant-123",
210
+ name: "Alice",
211
+ runtimeUrl: "https://platform.example",
212
+ cloud: "vellum",
213
+ species: "vellum",
214
+ },
215
+ ]);
216
+ getActiveAssistantMock.mockReturnValue("assistant-123");
217
+
218
+ await listAllAssistants(false);
219
+
220
+ const output = stdout.join("\n");
221
+ expect(output).toContain("* Alice");
222
+ expect(output).toContain("id: assistant-123");
223
+ expect(output).toContain("https://platform.example");
224
+ expect(output).not.toContain("* assistant-123");
225
+ });
226
+
227
+ test("assistant list renders one stable final row per assistant", async () => {
228
+ loadAllAssistantsMock.mockReturnValue([
229
+ {
230
+ assistantId: "assistant-123",
231
+ name: "Alice",
232
+ runtimeUrl: "https://platform.example/a",
233
+ cloud: "vellum",
234
+ species: "vellum",
235
+ },
236
+ {
237
+ assistantId: "assistant-456",
238
+ name: "Bob",
239
+ runtimeUrl: "https://platform.example/b",
240
+ cloud: "vellum",
241
+ species: "vellum",
242
+ },
243
+ ]);
244
+ getActiveAssistantMock.mockReturnValue("assistant-123");
245
+ checkManagedHealthMock.mockImplementation(async (_runtimeUrl, id) => ({
246
+ status: id === "assistant-123" ? "healthy" : "sleeping",
247
+ detail: null,
248
+ }));
249
+
250
+ await listAllAssistants(false);
251
+
252
+ const output = stdout.join("\n");
253
+ expect(output).not.toContain("\x1b[");
254
+ expect(output).not.toContain("checking...");
255
+ expect(output.match(/Alice/g)).toHaveLength(1);
256
+ expect(output.match(/Bob/g)).toHaveLength(1);
257
+ expect(output).toContain("healthy");
258
+ expect(output).toContain("sleeping");
181
259
  });
182
260
  });