@vellumai/cli 0.6.6 → 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 (61) hide show
  1. package/AGENTS.md +8 -2
  2. package/README.md +49 -0
  3. package/package.json +1 -1
  4. package/src/__tests__/assistant-config.test.ts +1 -7
  5. package/src/__tests__/backup.test.ts +475 -0
  6. package/src/__tests__/config-utils.test.ts +146 -0
  7. package/src/__tests__/env-drift.test.ts +10 -32
  8. package/src/__tests__/llm-provider-env-var-parity.test.ts +1 -21
  9. package/src/__tests__/multi-local.test.ts +0 -5
  10. package/src/__tests__/sleep.test.ts +1 -2
  11. package/src/__tests__/teleport.test.ts +988 -1266
  12. package/src/commands/backup.ts +117 -71
  13. package/src/commands/client.ts +10 -9
  14. package/src/commands/env.ts +93 -0
  15. package/src/commands/events.ts +2 -0
  16. package/src/commands/exec.ts +58 -13
  17. package/src/commands/login.ts +77 -12
  18. package/src/commands/logs.ts +2 -7
  19. package/src/commands/ps.ts +144 -25
  20. package/src/commands/restore.ts +26 -47
  21. package/src/commands/sleep.ts +5 -2
  22. package/src/commands/ssh.ts +17 -7
  23. package/src/commands/teleport.ts +462 -584
  24. package/src/commands/terminal.ts +9 -221
  25. package/src/commands/tunnel.ts +2 -7
  26. package/src/commands/upgrade.ts +108 -7
  27. package/src/commands/wake.ts +2 -1
  28. package/src/components/DefaultMainScreen.tsx +328 -154
  29. package/src/index.ts +5 -7
  30. package/src/lib/__tests__/docker.test.ts +50 -74
  31. package/src/lib/__tests__/job-polling.test.ts +278 -0
  32. package/src/lib/__tests__/local-runtime-client.test.ts +480 -0
  33. package/src/lib/__tests__/platform-client-signed-url.test.ts +405 -0
  34. package/src/lib/__tests__/runtime-url.test.ts +87 -0
  35. package/src/lib/__tests__/terminal-session.test.ts +202 -0
  36. package/src/lib/assistant-client.ts +5 -21
  37. package/src/lib/assistant-config.ts +46 -24
  38. package/src/lib/cli-error.ts +1 -0
  39. package/src/lib/client-identity.ts +67 -0
  40. package/src/lib/docker.ts +75 -77
  41. package/src/lib/environments/__tests__/paths.test.ts +2 -0
  42. package/src/lib/environments/resolve.ts +89 -7
  43. package/src/lib/environments/seeds.ts +8 -5
  44. package/src/lib/environments/types.ts +10 -0
  45. package/src/lib/hatch-local.ts +15 -120
  46. package/src/lib/health-check.ts +98 -0
  47. package/src/lib/job-polling.ts +195 -0
  48. package/src/lib/local-runtime-client.ts +231 -0
  49. package/src/lib/local.ts +165 -72
  50. package/src/lib/orphan-detection.ts +2 -35
  51. package/src/lib/platform-client.ts +190 -194
  52. package/src/lib/platform-releases.ts +23 -0
  53. package/src/lib/retire-local.ts +6 -2
  54. package/src/lib/runtime-url.ts +30 -0
  55. package/src/lib/sync-cloud-assistants.ts +126 -0
  56. package/src/lib/terminal-client.ts +6 -1
  57. package/src/lib/terminal-session.ts +536 -0
  58. package/src/lib/tui-log.ts +60 -0
  59. package/src/lib/xdg-log.ts +10 -4
  60. package/src/shared/provider-env-vars.ts +2 -3
  61. package/src/__tests__/orphan-detection.test.ts +0 -214
@@ -0,0 +1,405 @@
1
+ import { afterEach, beforeEach, describe, expect, mock, test } from "bun:test";
2
+
3
+ import {
4
+ platformPollJobStatus,
5
+ platformRequestSignedUrl,
6
+ type UnifiedJobStatus,
7
+ } from "../platform-client.js";
8
+
9
+ const PLATFORM_URL = "https://platform.example.test";
10
+ const VAK_TOKEN = "vak_test_1234567890"; // API-key path skips org-ID fetch.
11
+ const SESSION_TOKEN = "session_test_1234567890"; // non-vak_ triggers org-ID lookup.
12
+
13
+ interface CapturedCall {
14
+ url: string;
15
+ method: string;
16
+ headers: Record<string, string>;
17
+ body: unknown;
18
+ }
19
+
20
+ function captureFetch(
21
+ responder: (call: CapturedCall) => Response | Promise<Response>,
22
+ ): {
23
+ calls: CapturedCall[];
24
+ fetchMock: typeof globalThis.fetch;
25
+ } {
26
+ const calls: CapturedCall[] = [];
27
+ const fetchMock = mock(
28
+ async (url: string | URL | Request, init?: RequestInit) => {
29
+ const urlStr = typeof url === "string" ? url : url.toString();
30
+ const rawHeaders = (init?.headers ?? {}) as
31
+ | Record<string, string>
32
+ | Headers;
33
+ const headers: Record<string, string> = {};
34
+ if (rawHeaders instanceof Headers) {
35
+ rawHeaders.forEach((v, k) => {
36
+ headers[k] = v;
37
+ });
38
+ } else {
39
+ Object.assign(headers, rawHeaders);
40
+ }
41
+ let parsedBody: unknown = undefined;
42
+ const b = init?.body;
43
+ if (typeof b === "string") {
44
+ try {
45
+ parsedBody = JSON.parse(b);
46
+ } catch {
47
+ parsedBody = b;
48
+ }
49
+ }
50
+ const call: CapturedCall = {
51
+ url: urlStr,
52
+ method: init?.method ?? "GET",
53
+ headers,
54
+ body: parsedBody,
55
+ };
56
+ calls.push(call);
57
+ return responder(call);
58
+ },
59
+ );
60
+ return { calls, fetchMock: fetchMock as unknown as typeof globalThis.fetch };
61
+ }
62
+
63
+ let originalFetch: typeof globalThis.fetch;
64
+ beforeEach(() => {
65
+ originalFetch = globalThis.fetch;
66
+ });
67
+ afterEach(() => {
68
+ globalThis.fetch = originalFetch;
69
+ });
70
+
71
+ describe("platformRequestSignedUrl", () => {
72
+ test("upload operation with just operation → posts correct body and parses response", async () => {
73
+ const { calls, fetchMock } = captureFetch(() => {
74
+ return new Response(
75
+ JSON.stringify({
76
+ url: "https://storage.example/signed/abc",
77
+ bundle_key: "bundles/abc.tar.gz",
78
+ expires_at: "2026-04-22T00:00:00Z",
79
+ }),
80
+ { status: 201, headers: { "Content-Type": "application/json" } },
81
+ );
82
+ });
83
+ globalThis.fetch = fetchMock;
84
+
85
+ const result = await platformRequestSignedUrl(
86
+ { operation: "upload" },
87
+ VAK_TOKEN,
88
+ PLATFORM_URL,
89
+ );
90
+
91
+ expect(result).toEqual({
92
+ url: "https://storage.example/signed/abc",
93
+ bundleKey: "bundles/abc.tar.gz",
94
+ expiresAt: "2026-04-22T00:00:00Z",
95
+ maxContentLength: undefined,
96
+ });
97
+ expect(calls).toHaveLength(1);
98
+ expect(calls[0]!.url).toBe(`${PLATFORM_URL}/v1/migrations/signed-url/`);
99
+ expect(calls[0]!.method).toBe("POST");
100
+ expect(calls[0]!.headers.Authorization).toBe(`Bearer ${VAK_TOKEN}`);
101
+ expect(calls[0]!.headers["Content-Type"]).toBe("application/json");
102
+ expect(calls[0]!.body).toEqual({ operation: "upload" });
103
+ });
104
+
105
+ test("upload operation with content_length + content_type passes them through", async () => {
106
+ const { calls, fetchMock } = captureFetch(() => {
107
+ return new Response(
108
+ JSON.stringify({
109
+ url: "https://storage.example/signed/xyz",
110
+ bundle_key: "bundles/xyz.tar.gz",
111
+ expires_at: "2026-04-22T01:00:00Z",
112
+ max_content_length: 10_000_000,
113
+ }),
114
+ { status: 201 },
115
+ );
116
+ });
117
+ globalThis.fetch = fetchMock;
118
+
119
+ const result = await platformRequestSignedUrl(
120
+ {
121
+ operation: "upload",
122
+ contentType: "application/octet-stream",
123
+ contentLength: 12345,
124
+ },
125
+ VAK_TOKEN,
126
+ PLATFORM_URL,
127
+ );
128
+
129
+ expect(result.maxContentLength).toBe(10_000_000);
130
+ expect(calls[0]!.body).toEqual({
131
+ operation: "upload",
132
+ content_type: "application/octet-stream",
133
+ content_length: 12345,
134
+ });
135
+ });
136
+
137
+ test("download operation with bundleKey → posts bundle_key and parses response", async () => {
138
+ const { calls, fetchMock } = captureFetch(() => {
139
+ return new Response(
140
+ JSON.stringify({
141
+ url: "https://storage.example/signed/dl-xyz",
142
+ bundle_key: "bundles/xyz.tar.gz",
143
+ expires_at: "2026-04-22T02:00:00Z",
144
+ }),
145
+ { status: 201 },
146
+ );
147
+ });
148
+ globalThis.fetch = fetchMock;
149
+
150
+ const result = await platformRequestSignedUrl(
151
+ { operation: "download", bundleKey: "bundles/xyz.tar.gz" },
152
+ VAK_TOKEN,
153
+ PLATFORM_URL,
154
+ );
155
+
156
+ expect(result.url).toBe("https://storage.example/signed/dl-xyz");
157
+ expect(result.bundleKey).toBe("bundles/xyz.tar.gz");
158
+ expect(calls[0]!.body).toEqual({
159
+ operation: "download",
160
+ bundle_key: "bundles/xyz.tar.gz",
161
+ });
162
+ });
163
+
164
+ test("401 → retries once and returns success on the retry", async () => {
165
+ let callCount = 0;
166
+ const { calls, fetchMock } = captureFetch(() => {
167
+ callCount += 1;
168
+ if (callCount === 1) {
169
+ return new Response(JSON.stringify({ detail: "unauthorized" }), {
170
+ status: 401,
171
+ });
172
+ }
173
+ return new Response(
174
+ JSON.stringify({
175
+ url: "https://storage.example/signed/after-retry",
176
+ bundle_key: "bundles/r.tar.gz",
177
+ expires_at: "2026-04-22T03:00:00Z",
178
+ }),
179
+ { status: 201 },
180
+ );
181
+ });
182
+ globalThis.fetch = fetchMock;
183
+
184
+ const result = await platformRequestSignedUrl(
185
+ { operation: "upload" },
186
+ VAK_TOKEN,
187
+ PLATFORM_URL,
188
+ );
189
+
190
+ expect(result.url).toBe("https://storage.example/signed/after-retry");
191
+ expect(calls).toHaveLength(2);
192
+ });
193
+
194
+ test("401 with session token → invalidates org-ID cache and re-fetches on retry", async () => {
195
+ // Session tokens (non-vak_) take the org-ID-fetch path. A 401 here
196
+ // frequently means the cached org ID is stale, so the retry must
197
+ // clear the cache and re-fetch before the second signed-url POST.
198
+ const orgIdCalls: string[] = [];
199
+ const signedUrlCalls: CapturedCall[] = [];
200
+ let orgIdFetchCount = 0;
201
+ let signedUrlCount = 0;
202
+
203
+ const fetchMock = mock(
204
+ async (url: string | URL | Request, init?: RequestInit) => {
205
+ const urlStr = typeof url === "string" ? url : url.toString();
206
+ const rawHeaders = (init?.headers ?? {}) as
207
+ | Record<string, string>
208
+ | Headers;
209
+ const headers: Record<string, string> = {};
210
+ if (rawHeaders instanceof Headers) {
211
+ rawHeaders.forEach((v, k) => {
212
+ headers[k] = v;
213
+ });
214
+ } else {
215
+ Object.assign(headers, rawHeaders);
216
+ }
217
+
218
+ if (urlStr.endsWith("/v1/organizations/")) {
219
+ orgIdFetchCount += 1;
220
+ orgIdCalls.push(urlStr);
221
+ const orgId = orgIdFetchCount === 1 ? "org-stale" : "org-fresh";
222
+ return new Response(
223
+ JSON.stringify({ results: [{ id: orgId, name: "Test Org" }] }),
224
+ { status: 200 },
225
+ );
226
+ }
227
+
228
+ if (urlStr.endsWith("/v1/migrations/signed-url/")) {
229
+ signedUrlCount += 1;
230
+ let parsedBody: unknown = undefined;
231
+ const b = init?.body;
232
+ if (typeof b === "string") {
233
+ try {
234
+ parsedBody = JSON.parse(b);
235
+ } catch {
236
+ parsedBody = b;
237
+ }
238
+ }
239
+ signedUrlCalls.push({
240
+ url: urlStr,
241
+ method: init?.method ?? "GET",
242
+ headers,
243
+ body: parsedBody,
244
+ });
245
+ if (signedUrlCount === 1) {
246
+ return new Response(JSON.stringify({ detail: "stale org" }), {
247
+ status: 401,
248
+ });
249
+ }
250
+ return new Response(
251
+ JSON.stringify({
252
+ url: "https://storage.example/signed/fresh",
253
+ bundle_key: "bundles/fresh.tar.gz",
254
+ expires_at: "2026-04-22T04:00:00Z",
255
+ }),
256
+ { status: 201 },
257
+ );
258
+ }
259
+
260
+ throw new Error(`Unexpected URL: ${urlStr}`);
261
+ },
262
+ );
263
+ globalThis.fetch = fetchMock as unknown as typeof globalThis.fetch;
264
+
265
+ const result = await platformRequestSignedUrl(
266
+ { operation: "upload" },
267
+ SESSION_TOKEN,
268
+ PLATFORM_URL,
269
+ );
270
+
271
+ // Both signed-url attempts were made and the retry succeeded.
272
+ expect(result.url).toBe("https://storage.example/signed/fresh");
273
+ expect(signedUrlCalls).toHaveLength(2);
274
+
275
+ // The cache was cleared after the 401, so the org-ID endpoint was
276
+ // hit a second time to fetch a fresh ID before the retry.
277
+ expect(orgIdFetchCount).toBe(2);
278
+ expect(orgIdCalls).toHaveLength(2);
279
+
280
+ // The first signed-url POST used the stale org ID, the second used
281
+ // the fresh one.
282
+ expect(signedUrlCalls[0]!.headers["Vellum-Organization-Id"]).toBe(
283
+ "org-stale",
284
+ );
285
+ expect(signedUrlCalls[1]!.headers["Vellum-Organization-Id"]).toBe(
286
+ "org-fresh",
287
+ );
288
+
289
+ // Session tokens use X-Session-Token, not Bearer.
290
+ expect(signedUrlCalls[0]!.headers["X-Session-Token"]).toBe(SESSION_TOKEN);
291
+ expect(signedUrlCalls[0]!.headers.Authorization).toBeUndefined();
292
+ });
293
+
294
+ test("5xx error response → surfaces platform detail message", async () => {
295
+ const { fetchMock } = captureFetch(() => {
296
+ return new Response(JSON.stringify({ detail: "temporarily down" }), {
297
+ status: 503,
298
+ });
299
+ });
300
+ globalThis.fetch = fetchMock;
301
+
302
+ await expect(
303
+ platformRequestSignedUrl(
304
+ { operation: "upload" },
305
+ VAK_TOKEN,
306
+ PLATFORM_URL,
307
+ ),
308
+ ).rejects.toThrow(/temporarily down/);
309
+ });
310
+ });
311
+
312
+ describe("platformPollJobStatus", () => {
313
+ test("GET /v1/migrations/jobs/{jobId}/ parses processing", async () => {
314
+ const { calls, fetchMock } = captureFetch(() => {
315
+ return new Response(
316
+ JSON.stringify({
317
+ job_id: "job-1",
318
+ type: "export",
319
+ status: "processing",
320
+ }),
321
+ { status: 200 },
322
+ );
323
+ });
324
+ globalThis.fetch = fetchMock;
325
+
326
+ const status = await platformPollJobStatus(
327
+ "job-1",
328
+ VAK_TOKEN,
329
+ PLATFORM_URL,
330
+ );
331
+
332
+ expect(status).toEqual({
333
+ jobId: "job-1",
334
+ type: "export",
335
+ status: "processing",
336
+ } satisfies UnifiedJobStatus);
337
+ expect(calls[0]!.url).toBe(`${PLATFORM_URL}/v1/migrations/jobs/job-1/`);
338
+ expect(calls[0]!.method).toBe("GET");
339
+ });
340
+
341
+ test("parses complete with bundle_key + result", async () => {
342
+ const { fetchMock } = captureFetch(() => {
343
+ return new Response(
344
+ JSON.stringify({
345
+ job_id: "job-2",
346
+ type: "export",
347
+ status: "complete",
348
+ bundle_key: "bundles/done.tar.gz",
349
+ result: { files: 42 },
350
+ }),
351
+ { status: 200 },
352
+ );
353
+ });
354
+ globalThis.fetch = fetchMock;
355
+
356
+ const status = await platformPollJobStatus(
357
+ "job-2",
358
+ VAK_TOKEN,
359
+ PLATFORM_URL,
360
+ );
361
+
362
+ expect(status.status).toBe("complete");
363
+ if (status.status === "complete") {
364
+ expect(status.bundleKey).toBe("bundles/done.tar.gz");
365
+ expect(status.result).toEqual({ files: 42 });
366
+ }
367
+ });
368
+
369
+ test("parses failed with error", async () => {
370
+ const { fetchMock } = captureFetch(() => {
371
+ return new Response(
372
+ JSON.stringify({
373
+ job_id: "job-3",
374
+ type: "import",
375
+ status: "failed",
376
+ error: "bundle corrupt",
377
+ }),
378
+ { status: 200 },
379
+ );
380
+ });
381
+ globalThis.fetch = fetchMock;
382
+
383
+ const status = await platformPollJobStatus(
384
+ "job-3",
385
+ VAK_TOKEN,
386
+ PLATFORM_URL,
387
+ );
388
+
389
+ expect(status.status).toBe("failed");
390
+ if (status.status === "failed") {
391
+ expect(status.error).toBe("bundle corrupt");
392
+ }
393
+ });
394
+
395
+ test("404 → throws 'Migration job not found'", async () => {
396
+ const { fetchMock } = captureFetch(() => {
397
+ return new Response("{}", { status: 404 });
398
+ });
399
+ globalThis.fetch = fetchMock;
400
+
401
+ await expect(
402
+ platformPollJobStatus("missing", VAK_TOKEN, PLATFORM_URL),
403
+ ).rejects.toThrow(/Migration job not found/);
404
+ });
405
+ });
@@ -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
+ });