@posthog/agent 2.0.0 → 2.0.2

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 (131) hide show
  1. package/LICENSE +1 -1
  2. package/README.md +221 -219
  3. package/dist/adapters/claude/conversion/tool-use-to-acp.d.ts +21 -0
  4. package/dist/adapters/claude/conversion/tool-use-to-acp.js +547 -0
  5. package/dist/adapters/claude/conversion/tool-use-to-acp.js.map +1 -0
  6. package/dist/adapters/claude/permissions/permission-options.d.ts +13 -0
  7. package/dist/adapters/claude/permissions/permission-options.js +117 -0
  8. package/dist/adapters/claude/permissions/permission-options.js.map +1 -0
  9. package/dist/adapters/claude/questions/utils.d.ts +132 -0
  10. package/dist/adapters/claude/questions/utils.js +63 -0
  11. package/dist/adapters/claude/questions/utils.js.map +1 -0
  12. package/dist/adapters/claude/tools.d.ts +18 -0
  13. package/dist/adapters/claude/tools.js +95 -0
  14. package/dist/adapters/claude/tools.js.map +1 -0
  15. package/dist/agent-DBQY1BfC.d.ts +123 -0
  16. package/dist/agent.d.ts +5 -0
  17. package/dist/agent.js +3656 -0
  18. package/dist/agent.js.map +1 -0
  19. package/dist/claude-cli/cli.js +3695 -2746
  20. package/dist/claude-cli/vendor/ripgrep/COPYING +3 -0
  21. package/dist/claude-cli/vendor/ripgrep/arm64-darwin/rg +0 -0
  22. package/dist/claude-cli/vendor/ripgrep/arm64-darwin/ripgrep.node +0 -0
  23. package/dist/claude-cli/vendor/ripgrep/arm64-linux/rg +0 -0
  24. package/dist/claude-cli/vendor/ripgrep/arm64-linux/ripgrep.node +0 -0
  25. package/dist/claude-cli/vendor/ripgrep/x64-darwin/rg +0 -0
  26. package/dist/claude-cli/vendor/ripgrep/x64-darwin/ripgrep.node +0 -0
  27. package/dist/claude-cli/vendor/ripgrep/x64-linux/rg +0 -0
  28. package/dist/claude-cli/vendor/ripgrep/x64-linux/ripgrep.node +0 -0
  29. package/dist/claude-cli/vendor/ripgrep/x64-win32/rg.exe +0 -0
  30. package/dist/claude-cli/vendor/ripgrep/x64-win32/ripgrep.node +0 -0
  31. package/dist/gateway-models.d.ts +24 -0
  32. package/dist/gateway-models.js +93 -0
  33. package/dist/gateway-models.js.map +1 -0
  34. package/dist/index.d.ts +170 -1157
  35. package/dist/index.js +9373 -5135
  36. package/dist/index.js.map +1 -1
  37. package/dist/logger-DDBiMOOD.d.ts +24 -0
  38. package/dist/posthog-api.d.ts +40 -0
  39. package/dist/posthog-api.js +175 -0
  40. package/dist/posthog-api.js.map +1 -0
  41. package/dist/server/agent-server.d.ts +41 -0
  42. package/dist/server/agent-server.js +10503 -0
  43. package/dist/server/agent-server.js.map +1 -0
  44. package/dist/server/bin.d.ts +1 -0
  45. package/dist/server/bin.js +10558 -0
  46. package/dist/server/bin.js.map +1 -0
  47. package/dist/types.d.ts +129 -0
  48. package/dist/types.js +1 -0
  49. package/dist/types.js.map +1 -0
  50. package/package.json +65 -13
  51. package/src/acp-extensions.ts +98 -16
  52. package/src/adapters/acp-connection.ts +494 -0
  53. package/src/adapters/base-acp-agent.ts +150 -0
  54. package/src/adapters/claude/claude-agent.ts +596 -0
  55. package/src/adapters/claude/conversion/acp-to-sdk.ts +102 -0
  56. package/src/adapters/claude/conversion/sdk-to-acp.ts +571 -0
  57. package/src/adapters/claude/conversion/tool-use-to-acp.ts +618 -0
  58. package/src/adapters/claude/hooks.ts +64 -0
  59. package/src/adapters/claude/mcp/tool-metadata.ts +102 -0
  60. package/src/adapters/claude/permissions/permission-handlers.ts +433 -0
  61. package/src/adapters/claude/permissions/permission-options.ts +103 -0
  62. package/src/adapters/claude/plan/utils.ts +56 -0
  63. package/src/adapters/claude/questions/utils.ts +92 -0
  64. package/src/adapters/claude/session/commands.ts +38 -0
  65. package/src/adapters/claude/session/mcp-config.ts +37 -0
  66. package/src/adapters/claude/session/models.ts +12 -0
  67. package/src/adapters/claude/session/options.ts +236 -0
  68. package/src/adapters/claude/tool-meta.ts +143 -0
  69. package/src/adapters/claude/tools.ts +53 -688
  70. package/src/adapters/claude/types.ts +61 -0
  71. package/src/adapters/codex/spawn.ts +130 -0
  72. package/src/agent.ts +96 -587
  73. package/src/execution-mode.ts +43 -0
  74. package/src/gateway-models.ts +135 -0
  75. package/src/index.ts +79 -0
  76. package/src/otel-log-writer.test.ts +105 -0
  77. package/src/otel-log-writer.ts +94 -0
  78. package/src/posthog-api.ts +75 -235
  79. package/src/resume.ts +115 -0
  80. package/src/sagas/apply-snapshot-saga.test.ts +690 -0
  81. package/src/sagas/apply-snapshot-saga.ts +88 -0
  82. package/src/sagas/capture-tree-saga.test.ts +892 -0
  83. package/src/sagas/capture-tree-saga.ts +141 -0
  84. package/src/sagas/resume-saga.test.ts +558 -0
  85. package/src/sagas/resume-saga.ts +332 -0
  86. package/src/sagas/test-fixtures.ts +250 -0
  87. package/src/server/agent-server.test.ts +220 -0
  88. package/src/server/agent-server.ts +748 -0
  89. package/src/server/bin.ts +88 -0
  90. package/src/server/jwt.ts +65 -0
  91. package/src/server/schemas.ts +47 -0
  92. package/src/server/types.ts +13 -0
  93. package/src/server/utils/retry.test.ts +122 -0
  94. package/src/server/utils/retry.ts +61 -0
  95. package/src/server/utils/sse-parser.test.ts +93 -0
  96. package/src/server/utils/sse-parser.ts +46 -0
  97. package/src/session-log-writer.test.ts +140 -0
  98. package/src/session-log-writer.ts +137 -0
  99. package/src/test/assertions.ts +114 -0
  100. package/src/test/controllers/sse-controller.ts +107 -0
  101. package/src/test/fixtures/api.ts +111 -0
  102. package/src/test/fixtures/config.ts +33 -0
  103. package/src/test/fixtures/notifications.ts +92 -0
  104. package/src/test/mocks/claude-sdk.ts +251 -0
  105. package/src/test/mocks/msw-handlers.ts +48 -0
  106. package/src/test/setup.ts +114 -0
  107. package/src/test/wait.ts +41 -0
  108. package/src/tree-tracker.ts +173 -0
  109. package/src/types.ts +54 -137
  110. package/src/utils/acp-content.ts +58 -0
  111. package/src/utils/async-mutex.test.ts +104 -0
  112. package/src/utils/async-mutex.ts +31 -0
  113. package/src/utils/common.ts +15 -0
  114. package/src/utils/gateway.ts +9 -6
  115. package/src/utils/logger.ts +0 -30
  116. package/src/utils/streams.ts +220 -0
  117. package/CLAUDE.md +0 -331
  118. package/src/adapters/claude/claude.ts +0 -1947
  119. package/src/adapters/claude/mcp-server.ts +0 -810
  120. package/src/adapters/claude/utils.ts +0 -267
  121. package/src/adapters/connection.ts +0 -95
  122. package/src/file-manager.ts +0 -273
  123. package/src/git-manager.ts +0 -577
  124. package/src/schemas.ts +0 -241
  125. package/src/session-store.ts +0 -259
  126. package/src/task-manager.ts +0 -163
  127. package/src/todo-manager.ts +0 -180
  128. package/src/tools/registry.ts +0 -134
  129. package/src/tools/types.ts +0 -133
  130. package/src/utils/tapped-stream.ts +0 -60
  131. package/src/worktree-manager.ts +0 -974
@@ -0,0 +1,892 @@
1
+ import { join } from "node:path";
2
+ import type { SagaLogger } from "@posthog/shared";
3
+ import { afterEach, beforeEach, describe, expect, it, type vi } from "vitest";
4
+ import { isCommitOnRemote, validateForCloudHandoff } from "../tree-tracker.js";
5
+ import { CaptureTreeSaga } from "./capture-tree-saga.js";
6
+ import {
7
+ createMockApiClient,
8
+ createMockLogger,
9
+ createSnapshot,
10
+ createTestRepo,
11
+ type TestRepo,
12
+ } from "./test-fixtures.js";
13
+
14
+ describe("CaptureTreeSaga", () => {
15
+ let repo: TestRepo;
16
+ let mockLogger: SagaLogger;
17
+
18
+ beforeEach(async () => {
19
+ repo = await createTestRepo("capture-tree");
20
+ mockLogger = createMockLogger();
21
+ });
22
+
23
+ afterEach(async () => {
24
+ await repo.cleanup();
25
+ });
26
+
27
+ describe("no changes", () => {
28
+ it("returns null snapshot when tree hash matches last capture", async () => {
29
+ const saga = new CaptureTreeSaga(mockLogger);
30
+
31
+ const firstResult = await saga.run({
32
+ repositoryPath: repo.path,
33
+ taskId: "task-1",
34
+ runId: "run-1",
35
+ lastTreeHash: null,
36
+ });
37
+
38
+ expect(firstResult.success).toBe(true);
39
+ if (!firstResult.success) return;
40
+
41
+ const saga2 = new CaptureTreeSaga(mockLogger);
42
+ const secondResult = await saga2.run({
43
+ repositoryPath: repo.path,
44
+ taskId: "task-1",
45
+ runId: "run-1",
46
+ lastTreeHash: firstResult.data.newTreeHash,
47
+ });
48
+
49
+ expect(secondResult.success).toBe(true);
50
+ if (secondResult.success) {
51
+ expect(secondResult.data.snapshot).toBeNull();
52
+ expect(secondResult.data.newTreeHash).toBe(
53
+ firstResult.data.newTreeHash,
54
+ );
55
+ }
56
+ });
57
+ });
58
+
59
+ describe("capturing changes", () => {
60
+ it("captures added files", async () => {
61
+ await repo.writeFile("new-file.ts", "console.log('hello')");
62
+
63
+ const saga = new CaptureTreeSaga(mockLogger);
64
+ const result = await saga.run({
65
+ repositoryPath: repo.path,
66
+ taskId: "task-1",
67
+ runId: "run-1",
68
+ lastTreeHash: null,
69
+ });
70
+
71
+ expect(result.success).toBe(true);
72
+ if (!result.success) return;
73
+
74
+ expect(result.data.snapshot).not.toBeNull();
75
+ expect(result.data.snapshot?.changes).toContainEqual({
76
+ path: "new-file.ts",
77
+ status: "A",
78
+ });
79
+ });
80
+
81
+ it("captures modified files", async () => {
82
+ const saga = new CaptureTreeSaga(mockLogger);
83
+
84
+ const firstResult = await saga.run({
85
+ repositoryPath: repo.path,
86
+ taskId: "task-1",
87
+ runId: "run-1",
88
+ lastTreeHash: null,
89
+ });
90
+ expect(firstResult.success).toBe(true);
91
+ if (!firstResult.success) return;
92
+
93
+ await repo.writeFile("README.md", "# Modified");
94
+
95
+ const saga2 = new CaptureTreeSaga(mockLogger);
96
+ const secondResult = await saga2.run({
97
+ repositoryPath: repo.path,
98
+ taskId: "task-1",
99
+ runId: "run-2",
100
+ lastTreeHash: firstResult.data.newTreeHash,
101
+ });
102
+
103
+ expect(secondResult.success).toBe(true);
104
+ if (!secondResult.success) return;
105
+
106
+ expect(secondResult.data.snapshot?.changes).toContainEqual({
107
+ path: "README.md",
108
+ status: "M",
109
+ });
110
+ });
111
+
112
+ it("captures deleted files", async () => {
113
+ await repo.writeFile("to-delete.ts", "delete me");
114
+ await repo.git(["add", "."]);
115
+ await repo.git(["commit", "-m", "Add file to delete"]);
116
+
117
+ const saga = new CaptureTreeSaga(mockLogger);
118
+ const firstResult = await saga.run({
119
+ repositoryPath: repo.path,
120
+ taskId: "task-1",
121
+ runId: "run-1",
122
+ lastTreeHash: null,
123
+ });
124
+ expect(firstResult.success).toBe(true);
125
+ if (!firstResult.success) return;
126
+
127
+ await repo.deleteFile("to-delete.ts");
128
+
129
+ const saga2 = new CaptureTreeSaga(mockLogger);
130
+ const secondResult = await saga2.run({
131
+ repositoryPath: repo.path,
132
+ taskId: "task-1",
133
+ runId: "run-2",
134
+ lastTreeHash: firstResult.data.newTreeHash,
135
+ });
136
+
137
+ expect(secondResult.success).toBe(true);
138
+ if (!secondResult.success) return;
139
+
140
+ expect(secondResult.data.snapshot?.changes).toContainEqual({
141
+ path: "to-delete.ts",
142
+ status: "D",
143
+ });
144
+ });
145
+
146
+ it("captures mixed changes", async () => {
147
+ await repo.writeFile("existing.ts", "original");
148
+ await repo.git(["add", "."]);
149
+ await repo.git(["commit", "-m", "Add existing"]);
150
+
151
+ const saga = new CaptureTreeSaga(mockLogger);
152
+ const firstResult = await saga.run({
153
+ repositoryPath: repo.path,
154
+ taskId: "task-1",
155
+ runId: "run-1",
156
+ lastTreeHash: null,
157
+ });
158
+ expect(firstResult.success).toBe(true);
159
+ if (!firstResult.success) return;
160
+
161
+ await repo.writeFile("new.ts", "new file");
162
+ await repo.writeFile("existing.ts", "modified");
163
+ await repo.deleteFile("README.md");
164
+
165
+ const saga2 = new CaptureTreeSaga(mockLogger);
166
+ const secondResult = await saga2.run({
167
+ repositoryPath: repo.path,
168
+ taskId: "task-1",
169
+ runId: "run-2",
170
+ lastTreeHash: firstResult.data.newTreeHash,
171
+ });
172
+
173
+ expect(secondResult.success).toBe(true);
174
+ if (!secondResult.success) return;
175
+
176
+ const changes = secondResult.data.snapshot?.changes ?? [];
177
+ expect(changes).toContainEqual({ path: "new.ts", status: "A" });
178
+ expect(changes).toContainEqual({ path: "existing.ts", status: "M" });
179
+ expect(changes).toContainEqual({ path: "README.md", status: "D" });
180
+ });
181
+
182
+ it("sets interrupted flag when provided", async () => {
183
+ await repo.writeFile("file.ts", "content");
184
+
185
+ const saga = new CaptureTreeSaga(mockLogger);
186
+ const result = await saga.run({
187
+ repositoryPath: repo.path,
188
+ taskId: "task-1",
189
+ runId: "run-1",
190
+ lastTreeHash: null,
191
+ interrupted: true,
192
+ });
193
+
194
+ expect(result.success).toBe(true);
195
+ if (result.success) {
196
+ expect(result.data.snapshot?.interrupted).toBe(true);
197
+ }
198
+ });
199
+
200
+ it("includes base commit in snapshot", async () => {
201
+ const headCommit = await repo.git(["rev-parse", "HEAD"]);
202
+ await repo.writeFile("file.ts", "content");
203
+
204
+ const saga = new CaptureTreeSaga(mockLogger);
205
+ const result = await saga.run({
206
+ repositoryPath: repo.path,
207
+ taskId: "task-1",
208
+ runId: "run-1",
209
+ lastTreeHash: null,
210
+ });
211
+
212
+ expect(result.success).toBe(true);
213
+ if (result.success) {
214
+ expect(result.data.snapshot?.baseCommit).toBe(headCommit);
215
+ }
216
+ });
217
+ });
218
+
219
+ describe("exclusions", () => {
220
+ it("excludes .posthog directory from changes", async () => {
221
+ await repo.writeFile(".posthog/config.json", "{}");
222
+ await repo.writeFile("regular.ts", "content");
223
+
224
+ const saga = new CaptureTreeSaga(mockLogger);
225
+ const result = await saga.run({
226
+ repositoryPath: repo.path,
227
+ taskId: "task-1",
228
+ runId: "run-1",
229
+ lastTreeHash: null,
230
+ });
231
+
232
+ expect(result.success).toBe(true);
233
+ if (!result.success) return;
234
+
235
+ const changes = result.data.snapshot?.changes ?? [];
236
+ expect(changes.find((c) => c.path.includes(".posthog"))).toBeUndefined();
237
+ expect(changes.find((c) => c.path === "regular.ts")).toBeDefined();
238
+ });
239
+ });
240
+
241
+ describe("archive upload", () => {
242
+ it("uploads archive when API client provided", async () => {
243
+ const mockApiClient = createMockApiClient();
244
+ await repo.writeFile("new.ts", "content");
245
+
246
+ const saga = new CaptureTreeSaga(mockLogger);
247
+ const result = await saga.run({
248
+ repositoryPath: repo.path,
249
+ taskId: "task-1",
250
+ runId: "run-1",
251
+ lastTreeHash: null,
252
+ apiClient: mockApiClient,
253
+ });
254
+
255
+ expect(result.success).toBe(true);
256
+ if (result.success) {
257
+ expect(result.data.snapshot?.archiveUrl).toBe(
258
+ "gs://bucket/trees/test.tar.gz",
259
+ );
260
+ }
261
+ expect(mockApiClient.uploadTaskArtifacts).toHaveBeenCalled();
262
+ });
263
+
264
+ it("skips upload when only deletions", async () => {
265
+ await repo.writeFile("to-delete.ts", "delete me");
266
+ await repo.git(["add", "."]);
267
+ await repo.git(["commit", "-m", "Add file"]);
268
+
269
+ const saga = new CaptureTreeSaga(mockLogger);
270
+ const firstResult = await saga.run({
271
+ repositoryPath: repo.path,
272
+ taskId: "task-1",
273
+ runId: "run-1",
274
+ lastTreeHash: null,
275
+ });
276
+ expect(firstResult.success).toBe(true);
277
+ if (!firstResult.success) return;
278
+
279
+ await repo.deleteFile("to-delete.ts");
280
+
281
+ const mockApiClient = createMockApiClient();
282
+ const saga2 = new CaptureTreeSaga(mockLogger);
283
+ const secondResult = await saga2.run({
284
+ repositoryPath: repo.path,
285
+ taskId: "task-1",
286
+ runId: "run-2",
287
+ lastTreeHash: firstResult.data.newTreeHash,
288
+ apiClient: mockApiClient,
289
+ });
290
+
291
+ expect(secondResult.success).toBe(true);
292
+ expect(mockApiClient.uploadTaskArtifacts).not.toHaveBeenCalled();
293
+ });
294
+
295
+ it("handles upload failure", async () => {
296
+ const mockApiClient = createMockApiClient();
297
+ (
298
+ mockApiClient.uploadTaskArtifacts as ReturnType<typeof vi.fn>
299
+ ).mockRejectedValue(new Error("Network error"));
300
+
301
+ await repo.writeFile("new.ts", "content");
302
+
303
+ const saga = new CaptureTreeSaga(mockLogger);
304
+ const result = await saga.run({
305
+ repositoryPath: repo.path,
306
+ taskId: "task-1",
307
+ runId: "run-1",
308
+ lastTreeHash: null,
309
+ apiClient: mockApiClient,
310
+ });
311
+
312
+ expect(result.success).toBe(false);
313
+ if (!result.success) {
314
+ expect(result.failedStep).toBe("upload_archive");
315
+ }
316
+ });
317
+
318
+ it("cleans up temp index and archive on upload failure (rollback verification)", async () => {
319
+ const { readdir } = await import("node:fs/promises");
320
+
321
+ const mockApiClient = createMockApiClient();
322
+ (
323
+ mockApiClient.uploadTaskArtifacts as ReturnType<typeof vi.fn>
324
+ ).mockRejectedValue(new Error("Network error"));
325
+
326
+ await repo.writeFile("new.ts", "content");
327
+
328
+ const saga = new CaptureTreeSaga(mockLogger);
329
+ const result = await saga.run({
330
+ repositoryPath: repo.path,
331
+ taskId: "task-1",
332
+ runId: "run-1",
333
+ lastTreeHash: null,
334
+ apiClient: mockApiClient,
335
+ });
336
+
337
+ expect(result.success).toBe(false);
338
+
339
+ const tmpDir = join(repo.path, ".posthog", "tmp");
340
+ const files = await readdir(tmpDir).catch(() => []);
341
+
342
+ const indexFiles = files.filter((f: string) => f.startsWith("index-"));
343
+ expect(indexFiles).toHaveLength(0);
344
+
345
+ const archiveFiles = files.filter((f: string) => f.endsWith(".tar.gz"));
346
+ expect(archiveFiles).toHaveLength(0);
347
+ });
348
+
349
+ it("cleans up temp index on success", async () => {
350
+ const { readdir } = await import("node:fs/promises");
351
+
352
+ await repo.writeFile("new.ts", "content");
353
+
354
+ const saga = new CaptureTreeSaga(mockLogger);
355
+ const result = await saga.run({
356
+ repositoryPath: repo.path,
357
+ taskId: "task-1",
358
+ runId: "run-1",
359
+ lastTreeHash: null,
360
+ });
361
+
362
+ expect(result.success).toBe(true);
363
+
364
+ const tmpDir = join(repo.path, ".posthog", "tmp");
365
+ const files = await readdir(tmpDir).catch(() => []);
366
+ const indexFiles = files.filter((f: string) => f.startsWith("index-"));
367
+ expect(indexFiles).toHaveLength(0);
368
+ });
369
+ });
370
+
371
+ describe("git state isolation", () => {
372
+ it("does not modify user's staged files", async () => {
373
+ await repo.writeFile("staged.ts", "staged content");
374
+ await repo.git(["add", "staged.ts"]);
375
+
376
+ await repo.writeFile("unstaged.ts", "unstaged content");
377
+
378
+ const saga = new CaptureTreeSaga(mockLogger);
379
+ await saga.run({
380
+ repositoryPath: repo.path,
381
+ taskId: "task-1",
382
+ runId: "run-1",
383
+ lastTreeHash: null,
384
+ });
385
+
386
+ const status = await repo.git(["status", "--porcelain"]);
387
+ expect(status).toContain("A staged.ts");
388
+ expect(status).toContain("?? unstaged.ts");
389
+ });
390
+
391
+ it("does not affect working directory", async () => {
392
+ await repo.writeFile("file.ts", "original content");
393
+
394
+ const saga = new CaptureTreeSaga(mockLogger);
395
+ await saga.run({
396
+ repositoryPath: repo.path,
397
+ taskId: "task-1",
398
+ runId: "run-1",
399
+ lastTreeHash: null,
400
+ });
401
+
402
+ const content = await repo.readFile("file.ts");
403
+ expect(content).toBe("original content");
404
+ });
405
+ });
406
+
407
+ describe("concurrent captures", () => {
408
+ it("handles concurrent captures without interference", async () => {
409
+ await repo.writeFile("file1.ts", "content1");
410
+
411
+ const saga1 = new CaptureTreeSaga(mockLogger);
412
+ const saga2 = new CaptureTreeSaga(mockLogger);
413
+
414
+ const [result1, result2] = await Promise.all([
415
+ saga1.run({
416
+ repositoryPath: repo.path,
417
+ taskId: "task-1",
418
+ runId: "run-1",
419
+ lastTreeHash: null,
420
+ }),
421
+ saga2.run({
422
+ repositoryPath: repo.path,
423
+ taskId: "task-1",
424
+ runId: "run-2",
425
+ lastTreeHash: null,
426
+ }),
427
+ ]);
428
+
429
+ expect(result1.success).toBe(true);
430
+ expect(result2.success).toBe(true);
431
+
432
+ if (result1.success && result2.success) {
433
+ expect(result1.data.snapshot?.changes).toContainEqual({
434
+ path: "file1.ts",
435
+ status: "A",
436
+ });
437
+ expect(result2.data.snapshot?.changes).toContainEqual({
438
+ path: "file1.ts",
439
+ status: "A",
440
+ });
441
+ }
442
+ });
443
+ });
444
+
445
+ describe("renamed files", () => {
446
+ it("captures renamed files as delete + add (without -M flag)", async () => {
447
+ await repo.writeFile("old-name.ts", "content");
448
+ await repo.git(["add", "."]);
449
+ await repo.git(["commit", "-m", "Add original file"]);
450
+
451
+ const saga = new CaptureTreeSaga(mockLogger);
452
+ const firstResult = await saga.run({
453
+ repositoryPath: repo.path,
454
+ taskId: "task-1",
455
+ runId: "run-1",
456
+ lastTreeHash: null,
457
+ });
458
+ expect(firstResult.success).toBe(true);
459
+ if (!firstResult.success) return;
460
+
461
+ await repo.git(["mv", "old-name.ts", "new-name.ts"]);
462
+
463
+ const saga2 = new CaptureTreeSaga(mockLogger);
464
+ const secondResult = await saga2.run({
465
+ repositoryPath: repo.path,
466
+ taskId: "task-1",
467
+ runId: "run-2",
468
+ lastTreeHash: firstResult.data.newTreeHash,
469
+ });
470
+
471
+ expect(secondResult.success).toBe(true);
472
+ if (!secondResult.success) return;
473
+
474
+ const changes = secondResult.data.snapshot?.changes ?? [];
475
+ expect(changes).toContainEqual({ path: "old-name.ts", status: "D" });
476
+ expect(changes).toContainEqual({ path: "new-name.ts", status: "A" });
477
+ });
478
+
479
+ it("captures renamed files with modifications", async () => {
480
+ await repo.writeFile("original.ts", "original content");
481
+ await repo.git(["add", "."]);
482
+ await repo.git(["commit", "-m", "Add original file"]);
483
+
484
+ const saga = new CaptureTreeSaga(mockLogger);
485
+ const firstResult = await saga.run({
486
+ repositoryPath: repo.path,
487
+ taskId: "task-1",
488
+ runId: "run-1",
489
+ lastTreeHash: null,
490
+ });
491
+ expect(firstResult.success).toBe(true);
492
+ if (!firstResult.success) return;
493
+
494
+ await repo.git(["mv", "original.ts", "renamed.ts"]);
495
+ await repo.writeFile("renamed.ts", "modified content");
496
+
497
+ const saga2 = new CaptureTreeSaga(mockLogger);
498
+ const secondResult = await saga2.run({
499
+ repositoryPath: repo.path,
500
+ taskId: "task-1",
501
+ runId: "run-2",
502
+ lastTreeHash: firstResult.data.newTreeHash,
503
+ });
504
+
505
+ expect(secondResult.success).toBe(true);
506
+ if (!secondResult.success) return;
507
+
508
+ const changes = secondResult.data.snapshot?.changes ?? [];
509
+ expect(changes).toContainEqual({ path: "original.ts", status: "D" });
510
+ expect(
511
+ changes.some(
512
+ (c) =>
513
+ c.path === "renamed.ts" && (c.status === "A" || c.status === "M"),
514
+ ),
515
+ ).toBe(true);
516
+ });
517
+ });
518
+
519
+ describe("edge cases", () => {
520
+ it("handles files with spaces in names", async () => {
521
+ await repo.writeFile("file with spaces.ts", "content");
522
+
523
+ const saga = new CaptureTreeSaga(mockLogger);
524
+ const result = await saga.run({
525
+ repositoryPath: repo.path,
526
+ taskId: "task-1",
527
+ runId: "run-1",
528
+ lastTreeHash: null,
529
+ });
530
+
531
+ expect(result.success).toBe(true);
532
+ if (result.success) {
533
+ expect(result.data.snapshot?.changes).toContainEqual({
534
+ path: "file with spaces.ts",
535
+ status: "A",
536
+ });
537
+ }
538
+ });
539
+
540
+ it("handles nested directories", async () => {
541
+ await repo.writeFile(
542
+ "src/components/Button.tsx",
543
+ "export const Button = () => {}",
544
+ );
545
+
546
+ const saga = new CaptureTreeSaga(mockLogger);
547
+ const result = await saga.run({
548
+ repositoryPath: repo.path,
549
+ taskId: "task-1",
550
+ runId: "run-1",
551
+ lastTreeHash: null,
552
+ });
553
+
554
+ expect(result.success).toBe(true);
555
+ if (result.success) {
556
+ expect(result.data.snapshot?.changes).toContainEqual({
557
+ path: "src/components/Button.tsx",
558
+ status: "A",
559
+ });
560
+ }
561
+ });
562
+
563
+ it("handles binary files", async () => {
564
+ const binaryContent = Buffer.from([0x00, 0xff, 0x00, 0xff]);
565
+ const { writeFile: fsWriteFile } = await import("node:fs/promises");
566
+ await fsWriteFile(join(repo.path, "binary.bin"), binaryContent);
567
+
568
+ const saga = new CaptureTreeSaga(mockLogger);
569
+ const result = await saga.run({
570
+ repositoryPath: repo.path,
571
+ taskId: "task-1",
572
+ runId: "run-1",
573
+ lastTreeHash: null,
574
+ });
575
+
576
+ expect(result.success).toBe(true);
577
+ if (result.success) {
578
+ expect(result.data.snapshot?.changes).toContainEqual({
579
+ path: "binary.bin",
580
+ status: "A",
581
+ });
582
+ }
583
+ });
584
+
585
+ it("handles symlinks", async () => {
586
+ const { symlink, lstat } = await import("node:fs/promises");
587
+
588
+ await repo.writeFile("target.txt", "symlink target content");
589
+ await symlink("target.txt", join(repo.path, "link.txt"));
590
+
591
+ const saga = new CaptureTreeSaga(mockLogger);
592
+ const result = await saga.run({
593
+ repositoryPath: repo.path,
594
+ taskId: "task-1",
595
+ runId: "run-1",
596
+ lastTreeHash: null,
597
+ });
598
+
599
+ expect(result.success).toBe(true);
600
+ if (result.success) {
601
+ expect(result.data.snapshot?.changes).toContainEqual({
602
+ path: "link.txt",
603
+ status: "A",
604
+ });
605
+ }
606
+ });
607
+ });
608
+
609
+ describe("delta calculation", () => {
610
+ it("always calculates delta against HEAD, not lastTreeHash", async () => {
611
+ await repo.writeFile("file1.ts", "content1");
612
+ await repo.git(["add", "."]);
613
+ await repo.git(["commit", "-m", "Add file1"]);
614
+
615
+ await repo.writeFile("file2.ts", "content2");
616
+
617
+ const saga1 = new CaptureTreeSaga(mockLogger);
618
+ const firstResult = await saga1.run({
619
+ repositoryPath: repo.path,
620
+ taskId: "task-1",
621
+ runId: "run-1",
622
+ lastTreeHash: null,
623
+ });
624
+ expect(firstResult.success).toBe(true);
625
+ if (!firstResult.success) return;
626
+
627
+ expect(firstResult.data.snapshot?.changes).toContainEqual({
628
+ path: "file2.ts",
629
+ status: "A",
630
+ });
631
+
632
+ await repo.writeFile("file3.ts", "content3");
633
+
634
+ const saga2 = new CaptureTreeSaga(mockLogger);
635
+ const secondResult = await saga2.run({
636
+ repositoryPath: repo.path,
637
+ taskId: "task-1",
638
+ runId: "run-2",
639
+ lastTreeHash: firstResult.data.newTreeHash,
640
+ });
641
+
642
+ expect(secondResult.success).toBe(true);
643
+ if (!secondResult.success) return;
644
+
645
+ const changes = secondResult.data.snapshot?.changes ?? [];
646
+ expect(changes).toContainEqual({ path: "file2.ts", status: "A" });
647
+ expect(changes).toContainEqual({ path: "file3.ts", status: "A" });
648
+ });
649
+
650
+ it("second capture shows full delta from HEAD (not incremental)", async () => {
651
+ await repo.writeFile("existing.ts", "original");
652
+ await repo.git(["add", "."]);
653
+ await repo.git(["commit", "-m", "Add existing"]);
654
+
655
+ await repo.writeFile("existing.ts", "modified");
656
+
657
+ const saga1 = new CaptureTreeSaga(mockLogger);
658
+ const firstResult = await saga1.run({
659
+ repositoryPath: repo.path,
660
+ taskId: "task-1",
661
+ runId: "run-1",
662
+ lastTreeHash: null,
663
+ });
664
+ expect(firstResult.success).toBe(true);
665
+ if (!firstResult.success) return;
666
+
667
+ expect(firstResult.data.snapshot?.changes).toContainEqual({
668
+ path: "existing.ts",
669
+ status: "M",
670
+ });
671
+
672
+ // Make another change to trigger a new capture (otherwise skip-unchanged kicks in)
673
+ await repo.writeFile("existing.ts", "modified again");
674
+
675
+ const saga2 = new CaptureTreeSaga(mockLogger);
676
+ const secondResult = await saga2.run({
677
+ repositoryPath: repo.path,
678
+ taskId: "task-1",
679
+ runId: "run-2",
680
+ lastTreeHash: firstResult.data.newTreeHash,
681
+ });
682
+
683
+ expect(secondResult.success).toBe(true);
684
+ if (!secondResult.success) return;
685
+
686
+ // Even though only the content of existing.ts changed since last capture,
687
+ // the delta should still show M (modified from HEAD), not just incremental changes
688
+ expect(secondResult.data.snapshot?.changes).toContainEqual({
689
+ path: "existing.ts",
690
+ status: "M",
691
+ });
692
+ });
693
+
694
+ it("uses lastTreeHash only for skip-unchanged optimization", async () => {
695
+ await repo.writeFile("file.ts", "content");
696
+
697
+ const saga1 = new CaptureTreeSaga(mockLogger);
698
+ const firstResult = await saga1.run({
699
+ repositoryPath: repo.path,
700
+ taskId: "task-1",
701
+ runId: "run-1",
702
+ lastTreeHash: null,
703
+ });
704
+ expect(firstResult.success).toBe(true);
705
+ if (!firstResult.success) return;
706
+
707
+ const saga2 = new CaptureTreeSaga(mockLogger);
708
+ const secondResult = await saga2.run({
709
+ repositoryPath: repo.path,
710
+ taskId: "task-1",
711
+ runId: "run-2",
712
+ lastTreeHash: firstResult.data.newTreeHash,
713
+ });
714
+
715
+ expect(secondResult.success).toBe(true);
716
+ if (!secondResult.success) return;
717
+ expect(secondResult.data.snapshot).toBeNull();
718
+ expect(secondResult.data.newTreeHash).toBe(firstResult.data.newTreeHash);
719
+ });
720
+ });
721
+
722
+ describe("submodule detection", () => {
723
+ it("warns when repository has .gitmodules file", async () => {
724
+ await repo.writeFile(
725
+ ".gitmodules",
726
+ '[submodule "vendor/lib"]\n\tpath = vendor/lib\n\turl = https://example.com/lib.git',
727
+ );
728
+ await repo.writeFile("file.ts", "content");
729
+
730
+ const saga = new CaptureTreeSaga(mockLogger);
731
+ const result = await saga.run({
732
+ repositoryPath: repo.path,
733
+ taskId: "task-1",
734
+ runId: "run-1",
735
+ lastTreeHash: null,
736
+ });
737
+
738
+ expect(result.success).toBe(true);
739
+ expect(mockLogger.warn).toHaveBeenCalledWith(
740
+ "Repository has submodules - snapshot may not capture submodule state",
741
+ );
742
+ });
743
+
744
+ it("does not warn when repository has no submodules", async () => {
745
+ await repo.writeFile("file.ts", "content");
746
+
747
+ const saga = new CaptureTreeSaga(mockLogger);
748
+ const result = await saga.run({
749
+ repositoryPath: repo.path,
750
+ taskId: "task-1",
751
+ runId: "run-1",
752
+ lastTreeHash: null,
753
+ });
754
+
755
+ expect(result.success).toBe(true);
756
+ expect(mockLogger.warn).not.toHaveBeenCalledWith(
757
+ expect.stringContaining("submodules"),
758
+ );
759
+ });
760
+ });
761
+ });
762
+
763
+ describe("validateForCloudHandoff", () => {
764
+ let repo: TestRepo;
765
+
766
+ beforeEach(async () => {
767
+ repo = await createTestRepo("cloud-handoff");
768
+ });
769
+
770
+ afterEach(async () => {
771
+ await repo.cleanup();
772
+ });
773
+
774
+ it("throws error when snapshot has no base commit", async () => {
775
+ const snapshot = createSnapshot({ baseCommit: null });
776
+
777
+ await expect(validateForCloudHandoff(snapshot, repo.path)).rejects.toThrow(
778
+ "Cannot hand off to cloud: no base commit",
779
+ );
780
+ });
781
+
782
+ it("throws error when base commit is not on any remote", async () => {
783
+ const headCommit = await repo.git(["rev-parse", "HEAD"]);
784
+ const snapshot = createSnapshot({ baseCommit: headCommit });
785
+
786
+ await expect(validateForCloudHandoff(snapshot, repo.path)).rejects.toThrow(
787
+ /is not pushed.*Run 'git push'/,
788
+ );
789
+ });
790
+
791
+ it("succeeds when base commit is on remote", async () => {
792
+ const { execFile } = await import("node:child_process");
793
+ const { promisify } = await import("node:util");
794
+ const { tmpdir } = await import("node:os");
795
+ const { mkdir, rm } = await import("node:fs/promises");
796
+ const { join } = await import("node:path");
797
+
798
+ const execFileAsync = promisify(execFile);
799
+
800
+ const remoteDir = join(tmpdir(), `remote-${Date.now()}`);
801
+ await mkdir(remoteDir, { recursive: true });
802
+ await execFileAsync("git", ["init", "--bare"], { cwd: remoteDir });
803
+
804
+ const branchName = await repo.git(["rev-parse", "--abbrev-ref", "HEAD"]);
805
+ await repo.git(["remote", "add", "origin", remoteDir]);
806
+ await repo.git(["push", "-u", "origin", branchName]);
807
+
808
+ const headCommit = await repo.git(["rev-parse", "HEAD"]);
809
+ const snapshot = createSnapshot({ baseCommit: headCommit });
810
+
811
+ await expect(
812
+ validateForCloudHandoff(snapshot, repo.path),
813
+ ).resolves.toBeUndefined();
814
+
815
+ await rm(remoteDir, { recursive: true, force: true });
816
+ });
817
+ });
818
+
819
+ describe("isCommitOnRemote", () => {
820
+ let repo: TestRepo;
821
+
822
+ beforeEach(async () => {
823
+ repo = await createTestRepo("commit-remote");
824
+ });
825
+
826
+ afterEach(async () => {
827
+ await repo.cleanup();
828
+ });
829
+
830
+ it("returns false when no remote configured", async () => {
831
+ const headCommit = await repo.git(["rev-parse", "HEAD"]);
832
+ const result = await isCommitOnRemote(headCommit, repo.path);
833
+ expect(result).toBe(false);
834
+ });
835
+
836
+ it("returns false for invalid commit", async () => {
837
+ const result = await isCommitOnRemote("invalid-commit-hash", repo.path);
838
+ expect(result).toBe(false);
839
+ });
840
+
841
+ it("returns false for local-only commit", async () => {
842
+ const { execFile } = await import("node:child_process");
843
+ const { promisify } = await import("node:util");
844
+ const { tmpdir } = await import("node:os");
845
+ const { mkdir, rm } = await import("node:fs/promises");
846
+ const { join } = await import("node:path");
847
+
848
+ const execFileAsync = promisify(execFile);
849
+
850
+ const remoteDir = join(tmpdir(), `remote-${Date.now()}`);
851
+ await mkdir(remoteDir, { recursive: true });
852
+ await execFileAsync("git", ["init", "--bare"], { cwd: remoteDir });
853
+
854
+ const branchName = await repo.git(["rev-parse", "--abbrev-ref", "HEAD"]);
855
+ await repo.git(["remote", "add", "origin", remoteDir]);
856
+ await repo.git(["push", "-u", "origin", branchName]);
857
+
858
+ await repo.writeFile("new.ts", "content");
859
+ await repo.git(["add", "."]);
860
+ await repo.git(["commit", "-m", "Local only"]);
861
+
862
+ const localCommit = await repo.git(["rev-parse", "HEAD"]);
863
+ const result = await isCommitOnRemote(localCommit, repo.path);
864
+ expect(result).toBe(false);
865
+
866
+ await rm(remoteDir, { recursive: true, force: true });
867
+ });
868
+
869
+ it("returns true for pushed commit", async () => {
870
+ const { execFile } = await import("node:child_process");
871
+ const { promisify } = await import("node:util");
872
+ const { tmpdir } = await import("node:os");
873
+ const { mkdir, rm } = await import("node:fs/promises");
874
+ const { join } = await import("node:path");
875
+
876
+ const execFileAsync = promisify(execFile);
877
+
878
+ const remoteDir = join(tmpdir(), `remote-${Date.now()}`);
879
+ await mkdir(remoteDir, { recursive: true });
880
+ await execFileAsync("git", ["init", "--bare"], { cwd: remoteDir });
881
+
882
+ const branchName = await repo.git(["rev-parse", "--abbrev-ref", "HEAD"]);
883
+ await repo.git(["remote", "add", "origin", remoteDir]);
884
+ await repo.git(["push", "-u", "origin", branchName]);
885
+
886
+ const headCommit = await repo.git(["rev-parse", "HEAD"]);
887
+ const result = await isCommitOnRemote(headCommit, repo.path);
888
+ expect(result).toBe(true);
889
+
890
+ await rm(remoteDir, { recursive: true, force: true });
891
+ });
892
+ });