@vellumai/cli 0.7.0 → 0.7.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.
Files changed (40) hide show
  1. package/README.md +49 -0
  2. package/package.json +1 -1
  3. package/src/__tests__/backup.test.ts +475 -0
  4. package/src/__tests__/config-utils.test.ts +35 -48
  5. package/src/__tests__/teleport.test.ts +86 -28
  6. package/src/commands/backup.ts +117 -71
  7. package/src/commands/client.ts +10 -9
  8. package/src/commands/exec.ts +21 -8
  9. package/src/commands/hatch.ts +2 -6
  10. package/src/commands/login.ts +15 -33
  11. package/src/commands/logs.ts +2 -7
  12. package/src/commands/ps.ts +41 -6
  13. package/src/commands/restore.ts +26 -47
  14. package/src/commands/ssh.ts +2 -5
  15. package/src/commands/teleport.ts +38 -24
  16. package/src/commands/tunnel.ts +2 -7
  17. package/src/commands/upgrade.ts +108 -7
  18. package/src/components/DefaultMainScreen.tsx +25 -3
  19. package/src/index.ts +2 -7
  20. package/src/lib/__tests__/local-runtime-client.test.ts +122 -25
  21. package/src/lib/__tests__/platform-client-signed-url.test.ts +2 -2
  22. package/src/lib/__tests__/runtime-url.test.ts +87 -0
  23. package/src/lib/__tests__/terminal-session.test.ts +202 -0
  24. package/src/lib/assistant-client.ts +5 -21
  25. package/src/lib/assistant-config.ts +34 -16
  26. package/src/lib/cli-error.ts +1 -0
  27. package/src/lib/client-identity.ts +1 -1
  28. package/src/lib/config-utils.ts +1 -97
  29. package/src/lib/docker.ts +2 -2
  30. package/src/lib/job-polling.ts +1 -1
  31. package/src/lib/local-runtime-client.ts +81 -28
  32. package/src/lib/local.ts +27 -58
  33. package/src/lib/platform-client.ts +1 -220
  34. package/src/lib/platform-releases.ts +23 -0
  35. package/src/lib/runtime-url.ts +30 -0
  36. package/src/lib/sync-cloud-assistants.ts +126 -0
  37. package/src/lib/terminal-client.ts +6 -1
  38. package/src/lib/terminal-session.ts +127 -48
  39. package/src/lib/tui-log.ts +60 -0
  40. package/src/lib/xdg-log.ts +10 -4
@@ -19,6 +19,7 @@ import { SPECIES_CONFIG, type Species } from "../lib/constants";
19
19
  import { callDoctorDaemon, type ChatLogEntry } from "../lib/doctor-client";
20
20
  import { checkHealth } from "../lib/health-check";
21
21
  import { appendHistory, loadHistory } from "../lib/input-history";
22
+ import { tuiLog } from "../lib/tui-log";
22
23
  import { statusEmoji, withStatusEmoji } from "../lib/status-emoji";
23
24
  import {
24
25
  getTerminalCapabilities,
@@ -354,22 +355,35 @@ async function* streamEvents(
354
355
  ): AsyncGenerator<SseEvent> {
355
356
  const params = new URLSearchParams({ conversationKey });
356
357
  const url = `${baseUrl}/v1/assistants/${assistantId}/events?${params.toString()}`;
358
+ const clientHeaders = getClientRegistrationHeaders();
359
+ tuiLog.info("sse connect", { url, clientHeaders });
357
360
  const response = await fetch(url, {
358
361
  headers: {
359
362
  Accept: "text/event-stream",
360
363
  ...(bearerToken ? { Authorization: `Bearer ${bearerToken}` } : {}),
361
- ...getClientRegistrationHeaders(),
364
+ ...clientHeaders,
362
365
  },
363
366
  signal,
364
367
  });
365
368
 
369
+ tuiLog.info("sse response", {
370
+ status: response.status,
371
+ statusText: response.statusText,
372
+ contentType: response.headers.get("content-type"),
373
+ });
374
+
366
375
  if (!response.ok) {
367
376
  const body = await response.text().catch(() => "");
377
+ tuiLog.error("sse connection failed", {
378
+ status: response.status,
379
+ body: body.slice(0, 500),
380
+ });
368
381
  throw new Error(
369
382
  `SSE connection failed (${response.status}): ${body || response.statusText}`,
370
383
  );
371
384
  }
372
385
  if (!response.body) {
386
+ tuiLog.error("sse response has no body");
373
387
  throw new Error("No response body from SSE endpoint");
374
388
  }
375
389
 
@@ -1653,6 +1667,10 @@ function ChatApp({
1653
1667
 
1654
1668
  try {
1655
1669
  const health = await checkHealthRuntime(runtimeUrl);
1670
+ tuiLog.info("health check", {
1671
+ status: health.status,
1672
+ message: health.message,
1673
+ });
1656
1674
  h.hideSpinner();
1657
1675
  h.updateHealthStatus(health.status);
1658
1676
  if (health.status === "healthy" || health.status === "ok") {
@@ -1850,9 +1868,12 @@ function ChatApp({
1850
1868
  break;
1851
1869
  }
1852
1870
  }
1853
- } catch {
1871
+ } catch (sseErr) {
1854
1872
  // Stream ended — only report if not intentionally aborted
1855
1873
  if (!sseAc.signal.aborted) {
1874
+ tuiLog.warn("sse stream disconnected", {
1875
+ error: String(sseErr),
1876
+ });
1856
1877
  handleRef_.current?.addStatus(
1857
1878
  "SSE stream disconnected — will reconnect on next message",
1858
1879
  "yellow",
@@ -1869,10 +1890,11 @@ function ChatApp({
1869
1890
  setConnectionState("connected");
1870
1891
  return true;
1871
1892
  } catch (err) {
1893
+ const msg = err instanceof Error ? err.message : String(err);
1894
+ tuiLog.error("connection failed", { error: msg });
1872
1895
  h.hideSpinner();
1873
1896
  connectingRef.current = false;
1874
1897
  h.updateHealthStatus("unreachable");
1875
- const msg = err instanceof Error ? err.message : String(err);
1876
1898
  setConnectionState("error");
1877
1899
  setConnectionError(msg);
1878
1900
  h.addStatus(
package/src/index.ts CHANGED
@@ -27,9 +27,7 @@ import { upgrade } from "./commands/upgrade";
27
27
  import { use } from "./commands/use";
28
28
  import { wake } from "./commands/wake";
29
29
  import {
30
- getActiveAssistant,
31
- findAssistantByName,
32
- loadLatestAssistant,
30
+ resolveAssistant,
33
31
  setActiveAssistant,
34
32
  } from "./lib/assistant-config";
35
33
  import { loadGuardianToken } from "./lib/guardian-token";
@@ -129,10 +127,7 @@ function applyNoColorFlags(argv: string[]): void {
129
127
  * Otherwise return false so the caller can fall back to help text.
130
128
  */
131
129
  async function tryLaunchClient(): Promise<boolean> {
132
- const activeName = getActiveAssistant();
133
- const entry = activeName
134
- ? findAssistantByName(activeName)
135
- : loadLatestAssistant();
130
+ const entry = resolveAssistant();
136
131
 
137
132
  if (!entry) return false;
138
133
 
@@ -1,5 +1,6 @@
1
1
  import { afterEach, beforeEach, describe, expect, mock, test } from "bun:test";
2
2
 
3
+ import type { AssistantEntry } from "../assistant-config.js";
3
4
  import {
4
5
  MigrationInProgressError,
5
6
  localRuntimeExportToGcs,
@@ -10,6 +11,17 @@ import {
10
11
  const RUNTIME_URL = "http://127.0.0.1:8765";
11
12
  const TOKEN = "local-bearer-token";
12
13
 
14
+ // All tests in this file exercise the local/docker code path (cloud="local"),
15
+ // which builds `{runtimeUrl}/v1/migrations/<subpath>` URLs and uses
16
+ // guardian-token bearer auth. The platform path (cloud="vellum") is covered
17
+ // by `runtime-url.test.ts` (URL construction) and the teleport tests
18
+ // (call-site wiring).
19
+ const ENTRY: Pick<AssistantEntry, "cloud" | "runtimeUrl" | "assistantId"> = {
20
+ cloud: "local",
21
+ runtimeUrl: RUNTIME_URL,
22
+ assistantId: "ast-test-1",
23
+ };
24
+
13
25
  interface CapturedCall {
14
26
  url: string;
15
27
  method: string;
@@ -82,7 +94,7 @@ describe("localRuntimeExportToGcs", () => {
82
94
  });
83
95
  globalThis.fetch = fetchMock;
84
96
 
85
- const result = await localRuntimeExportToGcs(RUNTIME_URL, TOKEN, {
97
+ const result = await localRuntimeExportToGcs(ENTRY, TOKEN, {
86
98
  uploadUrl: "https://storage.example/signed/abc",
87
99
  description: "teleport export",
88
100
  });
@@ -108,7 +120,7 @@ describe("localRuntimeExportToGcs", () => {
108
120
  });
109
121
  globalThis.fetch = fetchMock;
110
122
 
111
- await localRuntimeExportToGcs(RUNTIME_URL, TOKEN, {
123
+ await localRuntimeExportToGcs(ENTRY, TOKEN, {
112
124
  uploadUrl: "https://storage.example/signed/abc",
113
125
  });
114
126
 
@@ -132,7 +144,7 @@ describe("localRuntimeExportToGcs", () => {
132
144
  globalThis.fetch = fetchMock;
133
145
 
134
146
  try {
135
- await localRuntimeExportToGcs(RUNTIME_URL, TOKEN, {
147
+ await localRuntimeExportToGcs(ENTRY, TOKEN, {
136
148
  uploadUrl: "https://storage.example/signed/abc",
137
149
  });
138
150
  throw new Error("expected to throw");
@@ -156,7 +168,7 @@ describe("localRuntimeExportToGcs", () => {
156
168
  globalThis.fetch = fetchMock;
157
169
 
158
170
  try {
159
- await localRuntimeExportToGcs(RUNTIME_URL, TOKEN, {
171
+ await localRuntimeExportToGcs(ENTRY, TOKEN, {
160
172
  uploadUrl: "https://storage.example/signed/abc",
161
173
  });
162
174
  throw new Error("expected to throw");
@@ -182,7 +194,7 @@ describe("localRuntimeExportToGcs", () => {
182
194
  globalThis.fetch = fetchMock;
183
195
 
184
196
  try {
185
- await localRuntimeExportToGcs(RUNTIME_URL, TOKEN, {
197
+ await localRuntimeExportToGcs(ENTRY, TOKEN, {
186
198
  uploadUrl: "https://storage.example/signed/abc",
187
199
  });
188
200
  throw new Error("expected to throw");
@@ -201,7 +213,7 @@ describe("localRuntimeExportToGcs", () => {
201
213
  globalThis.fetch = fetchMock;
202
214
 
203
215
  await expect(
204
- localRuntimeExportToGcs(RUNTIME_URL, TOKEN, {
216
+ localRuntimeExportToGcs(ENTRY, TOKEN, {
205
217
  uploadUrl: "https://storage.example/signed/abc",
206
218
  }),
207
219
  ).rejects.toThrow(/500/);
@@ -222,7 +234,7 @@ describe("localRuntimeImportFromGcs", () => {
222
234
  });
223
235
  globalThis.fetch = fetchMock;
224
236
 
225
- const result = await localRuntimeImportFromGcs(RUNTIME_URL, TOKEN, {
237
+ const result = await localRuntimeImportFromGcs(ENTRY, TOKEN, {
226
238
  bundleUrl: "https://storage.example/signed/dl-xyz",
227
239
  });
228
240
 
@@ -250,7 +262,7 @@ describe("localRuntimeImportFromGcs", () => {
250
262
  globalThis.fetch = fetchMock;
251
263
 
252
264
  try {
253
- await localRuntimeImportFromGcs(RUNTIME_URL, TOKEN, {
265
+ await localRuntimeImportFromGcs(ENTRY, TOKEN, {
254
266
  bundleUrl: "https://storage.example/signed/dl-xyz",
255
267
  });
256
268
  throw new Error("expected to throw");
@@ -275,7 +287,7 @@ describe("localRuntimeImportFromGcs", () => {
275
287
  globalThis.fetch = fetchMock;
276
288
 
277
289
  try {
278
- await localRuntimeImportFromGcs(RUNTIME_URL, TOKEN, {
290
+ await localRuntimeImportFromGcs(ENTRY, TOKEN, {
279
291
  bundleUrl: "https://storage.example/signed/dl-xyz",
280
292
  });
281
293
  throw new Error("expected to throw");
@@ -302,11 +314,7 @@ describe("localRuntimePollJobStatus", () => {
302
314
  });
303
315
  globalThis.fetch = fetchMock;
304
316
 
305
- const status = await localRuntimePollJobStatus(
306
- RUNTIME_URL,
307
- TOKEN,
308
- "poll-1",
309
- );
317
+ const status = await localRuntimePollJobStatus(ENTRY, TOKEN, "poll-1");
310
318
 
311
319
  expect(status).toEqual({
312
320
  jobId: "poll-1",
@@ -332,11 +340,7 @@ describe("localRuntimePollJobStatus", () => {
332
340
  });
333
341
  globalThis.fetch = fetchMock;
334
342
 
335
- const status = await localRuntimePollJobStatus(
336
- RUNTIME_URL,
337
- TOKEN,
338
- "poll-2",
339
- );
343
+ const status = await localRuntimePollJobStatus(ENTRY, TOKEN, "poll-2");
340
344
 
341
345
  expect(status.status).toBe("complete");
342
346
  if (status.status === "complete") {
@@ -358,11 +362,7 @@ describe("localRuntimePollJobStatus", () => {
358
362
  });
359
363
  globalThis.fetch = fetchMock;
360
364
 
361
- const status = await localRuntimePollJobStatus(
362
- RUNTIME_URL,
363
- TOKEN,
364
- "poll-3",
365
- );
365
+ const status = await localRuntimePollJobStatus(ENTRY, TOKEN, "poll-3");
366
366
 
367
367
  expect(status.status).toBe("failed");
368
368
  if (status.status === "failed") {
@@ -377,7 +377,104 @@ describe("localRuntimePollJobStatus", () => {
377
377
  globalThis.fetch = fetchMock;
378
378
 
379
379
  await expect(
380
- localRuntimePollJobStatus(RUNTIME_URL, TOKEN, "missing"),
380
+ localRuntimePollJobStatus(ENTRY, TOKEN, "missing"),
381
381
  ).rejects.toThrow(/Migration job not found/);
382
382
  });
383
383
  });
384
+
385
+ // ---------------------------------------------------------------------------
386
+ // Platform-managed assistants (cloud="vellum") route through the platform's
387
+ // wildcard runtime proxy at `/v1/assistants/<id>/migrations/...` with
388
+ // platform-token auth (NOT guardian-token bearer). This block asserts the
389
+ // actual URL and headers built by the helpers — not mocked, not abstracted.
390
+ // Regression guard for the routing bug fixed in this PR.
391
+ // ---------------------------------------------------------------------------
392
+ const VELLUM_ENTRY: Pick<
393
+ AssistantEntry,
394
+ "cloud" | "runtimeUrl" | "assistantId"
395
+ > = {
396
+ cloud: "vellum",
397
+ runtimeUrl: "https://platform.vellum.ai",
398
+ assistantId: "11111111-2222-3333-4444-555555555555",
399
+ };
400
+ // `vak_` prefix bypasses `fetchOrganizationId` (org-scoped API keys); the
401
+ // auth header collapses to a single `Authorization: Bearer vak_...` so this
402
+ // test stays free of network mocks.
403
+ const VAK_TOKEN = "vak_platform-token";
404
+
405
+ describe("vellum-cloud routing through wildcard proxy", () => {
406
+ test("export-to-gcs URL has /v1/assistants/<id>/migrations/ prefix and uses platform-token bearer (no guardian)", async () => {
407
+ const { calls, fetchMock } = captureFetch(() => {
408
+ return new Response(
409
+ JSON.stringify({ job_id: "wp-export-1", status: "pending" }),
410
+ { status: 202, headers: { "Content-Type": "application/json" } },
411
+ );
412
+ });
413
+ globalThis.fetch = fetchMock;
414
+
415
+ const result = await localRuntimeExportToGcs(VELLUM_ENTRY, VAK_TOKEN, {
416
+ uploadUrl: "https://storage.example/signed/x",
417
+ description: "teleport export",
418
+ });
419
+
420
+ expect(result.jobId).toBe("wp-export-1");
421
+ expect(calls[0]!.url).toBe(
422
+ `https://platform.vellum.ai/v1/assistants/11111111-2222-3333-4444-555555555555/migrations/export-to-gcs`,
423
+ );
424
+ expect(calls[0]!.method).toBe("POST");
425
+ expect(calls[0]!.headers.Authorization).toBe(`Bearer ${VAK_TOKEN}`);
426
+ expect(calls[0]!.body).toEqual({
427
+ upload_url: "https://storage.example/signed/x",
428
+ description: "teleport export",
429
+ });
430
+ });
431
+
432
+ test("import-from-gcs URL has /v1/assistants/<id>/migrations/ prefix", async () => {
433
+ const { calls, fetchMock } = captureFetch(() => {
434
+ return new Response(
435
+ JSON.stringify({ job_id: "wp-import-1", status: "pending" }),
436
+ { status: 202 },
437
+ );
438
+ });
439
+ globalThis.fetch = fetchMock;
440
+
441
+ await localRuntimeImportFromGcs(VELLUM_ENTRY, VAK_TOKEN, {
442
+ bundleUrl: "https://storage.example/download/y",
443
+ });
444
+
445
+ expect(calls[0]!.url).toBe(
446
+ `https://platform.vellum.ai/v1/assistants/11111111-2222-3333-4444-555555555555/migrations/import-from-gcs`,
447
+ );
448
+ expect(calls[0]!.headers.Authorization).toBe(`Bearer ${VAK_TOKEN}`);
449
+ });
450
+
451
+ test("jobs/<id> URL has /v1/assistants/<id>/migrations/ prefix (NOT the dedicated platform endpoint)", async () => {
452
+ const { calls, fetchMock } = captureFetch(() => {
453
+ return new Response(
454
+ JSON.stringify({
455
+ job_id: "wp-export-1",
456
+ status: "complete",
457
+ type: "export",
458
+ bundle_key: "exports/org-1/x.vbundle",
459
+ }),
460
+ { status: 200 },
461
+ );
462
+ });
463
+ globalThis.fetch = fetchMock;
464
+
465
+ const status = await localRuntimePollJobStatus(
466
+ VELLUM_ENTRY,
467
+ VAK_TOKEN,
468
+ "wp-export-1",
469
+ );
470
+
471
+ expect(calls[0]!.url).toBe(
472
+ `https://platform.vellum.ai/v1/assistants/11111111-2222-3333-4444-555555555555/migrations/jobs/wp-export-1`,
473
+ );
474
+ expect(calls[0]!.headers.Authorization).toBe(`Bearer ${VAK_TOKEN}`);
475
+ expect(status.status).toBe("complete");
476
+ if (status.status === "complete") {
477
+ expect(status.bundleKey).toBe("exports/org-1/x.vbundle");
478
+ }
479
+ });
480
+ });
@@ -291,7 +291,7 @@ describe("platformRequestSignedUrl", () => {
291
291
  expect(signedUrlCalls[0]!.headers.Authorization).toBeUndefined();
292
292
  });
293
293
 
294
- test("503 throws so callers can fall back to legacy inline upload", async () => {
294
+ test("5xx error response surfaces platform detail message", async () => {
295
295
  const { fetchMock } = captureFetch(() => {
296
296
  return new Response(JSON.stringify({ detail: "temporarily down" }), {
297
297
  status: 503,
@@ -305,7 +305,7 @@ describe("platformRequestSignedUrl", () => {
305
305
  VAK_TOKEN,
306
306
  PLATFORM_URL,
307
307
  ),
308
- ).rejects.toThrow(/503/);
308
+ ).rejects.toThrow(/temporarily down/);
309
309
  });
310
310
  });
311
311
 
@@ -0,0 +1,87 @@
1
+ import { describe, expect, test } from "bun:test";
2
+
3
+ import type { AssistantEntry } from "../assistant-config.js";
4
+ import { resolveRuntimeMigrationUrl } from "../runtime-url.js";
5
+
6
+ function makeEntry(
7
+ overrides: Partial<AssistantEntry> & {
8
+ cloud: string;
9
+ runtimeUrl: string;
10
+ assistantId: string;
11
+ },
12
+ ): Pick<AssistantEntry, "cloud" | "runtimeUrl" | "assistantId"> {
13
+ return {
14
+ cloud: overrides.cloud,
15
+ runtimeUrl: overrides.runtimeUrl,
16
+ assistantId: overrides.assistantId,
17
+ };
18
+ }
19
+
20
+ describe("resolveRuntimeMigrationUrl", () => {
21
+ test("local cloud uses gateway-loopback /v1/migrations/<subpath>", () => {
22
+ const entry = makeEntry({
23
+ cloud: "local",
24
+ runtimeUrl: "http://localhost:7821",
25
+ assistantId: "ast-local-1",
26
+ });
27
+ expect(resolveRuntimeMigrationUrl(entry, "export-to-gcs")).toBe(
28
+ "http://localhost:7821/v1/migrations/export-to-gcs",
29
+ );
30
+ expect(resolveRuntimeMigrationUrl(entry, "import-from-gcs")).toBe(
31
+ "http://localhost:7821/v1/migrations/import-from-gcs",
32
+ );
33
+ expect(resolveRuntimeMigrationUrl(entry, "jobs/job-abc")).toBe(
34
+ "http://localhost:7821/v1/migrations/jobs/job-abc",
35
+ );
36
+ });
37
+
38
+ test("docker cloud uses gateway-loopback /v1/migrations/<subpath>", () => {
39
+ const entry = makeEntry({
40
+ cloud: "docker",
41
+ runtimeUrl: "http://localhost:7831",
42
+ assistantId: "ast-docker-1",
43
+ });
44
+ expect(resolveRuntimeMigrationUrl(entry, "export-to-gcs")).toBe(
45
+ "http://localhost:7831/v1/migrations/export-to-gcs",
46
+ );
47
+ });
48
+
49
+ test("vellum (platform-managed) cloud uses wildcard-proxy /v1/assistants/<id>/migrations/<subpath>", () => {
50
+ const entry = makeEntry({
51
+ cloud: "vellum",
52
+ runtimeUrl: "https://platform.vellum.ai",
53
+ assistantId: "11111111-2222-3333-4444-555555555555",
54
+ });
55
+ expect(resolveRuntimeMigrationUrl(entry, "export-to-gcs")).toBe(
56
+ "https://platform.vellum.ai/v1/assistants/11111111-2222-3333-4444-555555555555/migrations/export-to-gcs",
57
+ );
58
+ expect(resolveRuntimeMigrationUrl(entry, "import-from-gcs")).toBe(
59
+ "https://platform.vellum.ai/v1/assistants/11111111-2222-3333-4444-555555555555/migrations/import-from-gcs",
60
+ );
61
+ expect(resolveRuntimeMigrationUrl(entry, "jobs/job-xyz")).toBe(
62
+ "https://platform.vellum.ai/v1/assistants/11111111-2222-3333-4444-555555555555/migrations/jobs/job-xyz",
63
+ );
64
+ });
65
+
66
+ test("dev platform URL still routes through the wildcard prefix", () => {
67
+ const entry = makeEntry({
68
+ cloud: "vellum",
69
+ runtimeUrl: "https://dev-platform.vellum.ai",
70
+ assistantId: "ast-dev-1",
71
+ });
72
+ expect(resolveRuntimeMigrationUrl(entry, "export-to-gcs")).toBe(
73
+ "https://dev-platform.vellum.ai/v1/assistants/ast-dev-1/migrations/export-to-gcs",
74
+ );
75
+ });
76
+
77
+ test("a non-vellum, non-local cloud (e.g. gcp) uses the local-shape URL", () => {
78
+ const entry = makeEntry({
79
+ cloud: "gcp",
80
+ runtimeUrl: "http://10.0.0.5:7821",
81
+ assistantId: "ast-gcp-1",
82
+ });
83
+ expect(resolveRuntimeMigrationUrl(entry, "export-to-gcs")).toBe(
84
+ "http://10.0.0.5:7821/v1/migrations/export-to-gcs",
85
+ );
86
+ });
87
+ });
@@ -0,0 +1,202 @@
1
+ import { describe, expect, test } from "bun:test";
2
+
3
+ import {
4
+ parseSentinelOutput,
5
+ stripAnsi,
6
+ } from "../terminal-session.js";
7
+
8
+ const START = "__VELLUM_EXEC_START_1234__";
9
+ const END = "__VELLUM_EXEC_END_1234__";
10
+
11
+ // ---------------------------------------------------------------------------
12
+ // stripAnsi
13
+ // ---------------------------------------------------------------------------
14
+
15
+ describe("stripAnsi", () => {
16
+ test("removes SGR color codes", () => {
17
+ expect(stripAnsi("\x1b[32mINFO\x1b[39m hello")).toBe("INFO hello");
18
+ });
19
+
20
+ test("removes OSC title sequences", () => {
21
+ expect(stripAnsi("\x1b]0;title\x07prompt$ ")).toBe("prompt$ ");
22
+ });
23
+
24
+ test("removes carriage returns", () => {
25
+ expect(stripAnsi("line1\r\nline2\r\n")).toBe("line1\nline2\n");
26
+ });
27
+
28
+ test("removes bracket-paste mode escapes", () => {
29
+ expect(stripAnsi("\x1b[?2004hroot$ ")).toBe("root$ ");
30
+ });
31
+
32
+ test("removes charset designator sequences", () => {
33
+ expect(stripAnsi("\x1b(Bhello")).toBe("hello");
34
+ });
35
+
36
+ test("passes through plain text unchanged", () => {
37
+ expect(stripAnsi("just plain text")).toBe("just plain text");
38
+ });
39
+
40
+ test("handles mixed ANSI sequences", () => {
41
+ const raw =
42
+ "\x1b[?2004hroot:/workspace$ \r\x1b[K\rroot:/workspace$ echo hello\r\nhello\r\n";
43
+ const clean = stripAnsi(raw);
44
+ expect(clean).not.toContain("\x1b");
45
+ expect(clean).not.toContain("\r");
46
+ expect(clean).toContain("hello");
47
+ });
48
+ });
49
+
50
+ // ---------------------------------------------------------------------------
51
+ // parseSentinelOutput
52
+ // ---------------------------------------------------------------------------
53
+
54
+ describe("parseSentinelOutput", () => {
55
+ test("extracts output between sentinels", () => {
56
+ const cleaned = [
57
+ `echo '${START}'; ls; echo '${END}'; echo '__VELLUM_EXIT_'$__ec`,
58
+ START,
59
+ "file1.txt",
60
+ "file2.txt",
61
+ END,
62
+ "__VELLUM_EXIT_0",
63
+ ].join("\n");
64
+
65
+ const result = parseSentinelOutput(cleaned, START, END);
66
+ expect(result.output).toBe("file1.txt\nfile2.txt");
67
+ expect(result.exitCode).toBe(0);
68
+ });
69
+
70
+ test("extracts non-zero exit code", () => {
71
+ const cleaned = [
72
+ `echo '${START}'; false; echo '${END}'; echo '__VELLUM_EXIT_'$__ec`,
73
+ START,
74
+ END,
75
+ "__VELLUM_EXIT_1",
76
+ ].join("\n");
77
+
78
+ const result = parseSentinelOutput(cleaned, START, END);
79
+ expect(result.output).toBe("");
80
+ expect(result.exitCode).toBe(1);
81
+ });
82
+
83
+ test("handles exit code 127 (command not found)", () => {
84
+ const cleaned = [
85
+ START,
86
+ "bash: nosuchcmd: command not found",
87
+ END,
88
+ "__VELLUM_EXIT_127",
89
+ ].join("\n");
90
+
91
+ const result = parseSentinelOutput(cleaned, START, END);
92
+ expect(result.output).toBe("bash: nosuchcmd: command not found");
93
+ expect(result.exitCode).toBe(127);
94
+ });
95
+
96
+ test("uses last start sentinel (skips command echo)", () => {
97
+ // The command echo contains the sentinel text, then the actual output
98
+ // sentinel comes later. Parser must pick the last START, not the echo.
99
+ const cleaned = [
100
+ `root$ echo '${START}'; mycommand; echo '${END}'; echo '__VELLUM_EXIT_'$__ec`,
101
+ START,
102
+ "real output here",
103
+ END,
104
+ "__VELLUM_EXIT_0",
105
+ ].join("\n");
106
+
107
+ const result = parseSentinelOutput(cleaned, START, END);
108
+ expect(result.output).toBe("real output here");
109
+ expect(result.exitCode).toBe(0);
110
+ });
111
+
112
+ test("regression: end sentinel in echo before start sentinel in output", () => {
113
+ // This was the original bug: backward search found END in the echo
114
+ // (line 0) before START in the output (line 1), giving endIdx < startIdx.
115
+ const cleaned = [
116
+ `echo '${START}'; cmd; echo '${END}'; echo '__VELLUM_EXIT_'$__ec; exit $__ec`,
117
+ START,
118
+ "[INFO] Running clawhub command",
119
+ ' args: ["search"]',
120
+ ' cwd: "/workspace"',
121
+ ].join("\n");
122
+
123
+ // No end sentinel in actual output yet (stream was cut short in old code)
124
+ const result = parseSentinelOutput(cleaned, START, END);
125
+ // Should still return the partial output (no end sentinel → take everything)
126
+ expect(result.output).toContain("[INFO] Running clawhub command");
127
+ expect(result.output).toContain('cwd: "/workspace"');
128
+ });
129
+
130
+ test("handles multiline output with special characters", () => {
131
+ const cleaned = [
132
+ START,
133
+ "📤 Resend Email Setup [installed]",
134
+ " ID: resend-setup",
135
+ ' Set up and send emails via a user-provided Resend account (BYO email provider)',
136
+ "",
137
+ "Community registry (1):",
138
+ "",
139
+ " resend-setup [installed]",
140
+ END,
141
+ "__VELLUM_EXIT_0",
142
+ ].join("\n");
143
+
144
+ const result = parseSentinelOutput(cleaned, START, END);
145
+ expect(result.output).toContain("📤 Resend Email Setup");
146
+ expect(result.output).toContain("Community registry (1):");
147
+ expect(result.exitCode).toBe(0);
148
+ });
149
+
150
+ test("returns empty output and exit code 0 when no sentinels found", () => {
151
+ const cleaned = "just some random output\nwith no sentinels\n";
152
+ const result = parseSentinelOutput(cleaned, START, END);
153
+ // Falls back to entire output (trimmed)
154
+ expect(result.output).toBe(
155
+ "just some random output\nwith no sentinels",
156
+ );
157
+ expect(result.exitCode).toBe(0);
158
+ });
159
+
160
+ test("handles output with only start sentinel (no end)", () => {
161
+ const cleaned = [
162
+ START,
163
+ "partial output",
164
+ "more output",
165
+ ].join("\n");
166
+
167
+ const result = parseSentinelOutput(cleaned, START, END);
168
+ expect(result.output).toBe("partial output\nmore output");
169
+ expect(result.exitCode).toBe(0);
170
+ });
171
+
172
+ test("handles real-world verbose trace structure", () => {
173
+ // Simulates the full cleaned output from a real exec session
174
+ const cleaned = [
175
+ "root:/workspace$ root:/workspace$ " +
176
+ `echo '${START}'; 'assistant' 'skills' 'search' 'resend-setup'; __ec=$?; echo ` +
177
+ ` '${END}'; echo '__VELLUM_EXIT_'$__ec; exit $__ec`,
178
+ START,
179
+ "[13:06:38.851] INFO (761 on pod-0): [clawhub] Running clawhub command",
180
+ ' args: [',
181
+ ' "search",',
182
+ ' "resend-setup",',
183
+ ' "--limit",',
184
+ ' "10"',
185
+ " ]",
186
+ ' cwd: "/workspace"',
187
+ "Bundled & installed skills (1):",
188
+ "",
189
+ " 📤 Resend Email Setup [installed]",
190
+ " ID: resend-setup",
191
+ "",
192
+ END,
193
+ "__VELLUM_EXIT_0",
194
+ ].join("\n");
195
+
196
+ const result = parseSentinelOutput(cleaned, START, END);
197
+ expect(result.output).toContain("Bundled & installed skills (1):");
198
+ expect(result.output).toContain("📤 Resend Email Setup [installed]");
199
+ expect(result.output).toContain("[clawhub] Running clawhub command");
200
+ expect(result.exitCode).toBe(0);
201
+ });
202
+ });