@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,690 @@
1
+ import { join } from "node:path";
2
+ import type { SagaLogger } from "@posthog/shared";
3
+ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
4
+ import { ApplySnapshotSaga } from "./apply-snapshot-saga.js";
5
+ import {
6
+ createArchiveBuffer,
7
+ createMockApiClient,
8
+ createMockLogger,
9
+ createSnapshot,
10
+ createTestRepo,
11
+ type TestRepo,
12
+ } from "./test-fixtures.js";
13
+
14
+ describe("ApplySnapshotSaga", () => {
15
+ let repo: TestRepo;
16
+ let mockLogger: SagaLogger;
17
+
18
+ beforeEach(async () => {
19
+ repo = await createTestRepo("apply-snapshot");
20
+ mockLogger = createMockLogger();
21
+ });
22
+
23
+ afterEach(async () => {
24
+ await repo.cleanup();
25
+ });
26
+
27
+ describe("file restoration", () => {
28
+ it("extracts files from archive", async () => {
29
+ const archive = await createArchiveBuffer([
30
+ { path: "new-file.ts", content: "console.log('restored')" },
31
+ ]);
32
+ const mockApiClient = createMockApiClient({
33
+ downloadArtifact: vi.fn().mockResolvedValue(archive),
34
+ });
35
+
36
+ const saga = new ApplySnapshotSaga(mockLogger);
37
+ const result = await saga.run({
38
+ snapshot: createSnapshot({
39
+ changes: [{ path: "new-file.ts", status: "A" }],
40
+ }),
41
+ repositoryPath: repo.path,
42
+ apiClient: mockApiClient,
43
+ taskId: "task-1",
44
+ runId: "run-1",
45
+ });
46
+
47
+ expect(result.success).toBe(true);
48
+ expect(repo.exists("new-file.ts")).toBe(true);
49
+ expect(await repo.readFile("new-file.ts")).toBe(
50
+ "console.log('restored')",
51
+ );
52
+ });
53
+
54
+ it("extracts files in nested directories", async () => {
55
+ const archive = await createArchiveBuffer([
56
+ {
57
+ path: "src/components/Button.tsx",
58
+ content: "export const Button = () => {}",
59
+ },
60
+ ]);
61
+ const mockApiClient = createMockApiClient({
62
+ downloadArtifact: vi.fn().mockResolvedValue(archive),
63
+ });
64
+
65
+ const saga = new ApplySnapshotSaga(mockLogger);
66
+ const result = await saga.run({
67
+ snapshot: createSnapshot({
68
+ changes: [{ path: "src/components/Button.tsx", status: "A" }],
69
+ }),
70
+ repositoryPath: repo.path,
71
+ apiClient: mockApiClient,
72
+ taskId: "task-1",
73
+ runId: "run-1",
74
+ });
75
+
76
+ expect(result.success).toBe(true);
77
+ expect(repo.exists("src/components/Button.tsx")).toBe(true);
78
+ });
79
+
80
+ it("overwrites existing files with archive content", async () => {
81
+ await repo.writeFile("existing.ts", "old content");
82
+
83
+ const archive = await createArchiveBuffer([
84
+ { path: "existing.ts", content: "new content from archive" },
85
+ ]);
86
+ const mockApiClient = createMockApiClient({
87
+ downloadArtifact: vi.fn().mockResolvedValue(archive),
88
+ });
89
+
90
+ const saga = new ApplySnapshotSaga(mockLogger);
91
+ await saga.run({
92
+ snapshot: createSnapshot({
93
+ changes: [{ path: "existing.ts", status: "M" }],
94
+ }),
95
+ repositoryPath: repo.path,
96
+ apiClient: mockApiClient,
97
+ taskId: "task-1",
98
+ runId: "run-1",
99
+ });
100
+
101
+ expect(await repo.readFile("existing.ts")).toBe(
102
+ "new content from archive",
103
+ );
104
+ });
105
+
106
+ it("deletes files marked as deleted", async () => {
107
+ await repo.writeFile("to-delete.ts", "delete me");
108
+ await repo.git(["add", "."]);
109
+ await repo.git(["commit", "-m", "Add file"]);
110
+
111
+ const archive = await createArchiveBuffer([
112
+ { path: "placeholder.txt", content: "placeholder" },
113
+ ]);
114
+ const mockApiClient = createMockApiClient({
115
+ downloadArtifact: vi.fn().mockResolvedValue(archive),
116
+ });
117
+
118
+ const saga = new ApplySnapshotSaga(mockLogger);
119
+ await saga.run({
120
+ snapshot: createSnapshot({
121
+ changes: [{ path: "to-delete.ts", status: "D" }],
122
+ }),
123
+ repositoryPath: repo.path,
124
+ apiClient: mockApiClient,
125
+ taskId: "task-1",
126
+ runId: "run-1",
127
+ });
128
+
129
+ expect(repo.exists("to-delete.ts")).toBe(false);
130
+ });
131
+
132
+ it("handles mixed add/modify/delete changes", async () => {
133
+ await repo.writeFile("to-modify.ts", "original");
134
+ await repo.writeFile("to-delete.ts", "delete me");
135
+ await repo.git(["add", "."]);
136
+ await repo.git(["commit", "-m", "Setup"]);
137
+
138
+ const archive = await createArchiveBuffer([
139
+ { path: "new-file.ts", content: "added" },
140
+ { path: "to-modify.ts", content: "modified" },
141
+ ]);
142
+ const mockApiClient = createMockApiClient({
143
+ downloadArtifact: vi.fn().mockResolvedValue(archive),
144
+ });
145
+
146
+ const saga = new ApplySnapshotSaga(mockLogger);
147
+ await saga.run({
148
+ snapshot: createSnapshot({
149
+ changes: [
150
+ { path: "new-file.ts", status: "A" },
151
+ { path: "to-modify.ts", status: "M" },
152
+ { path: "to-delete.ts", status: "D" },
153
+ ],
154
+ }),
155
+ repositoryPath: repo.path,
156
+ apiClient: mockApiClient,
157
+ taskId: "task-1",
158
+ runId: "run-1",
159
+ });
160
+
161
+ expect(repo.exists("new-file.ts")).toBe(true);
162
+ expect(await repo.readFile("new-file.ts")).toBe("added");
163
+ expect(await repo.readFile("to-modify.ts")).toBe("modified");
164
+ expect(repo.exists("to-delete.ts")).toBe(false);
165
+ });
166
+ });
167
+
168
+ describe("base commit checkout", () => {
169
+ it("checks out base commit when different from current HEAD", async () => {
170
+ const initialCommit = await repo.git(["rev-parse", "HEAD"]);
171
+
172
+ await repo.writeFile("new.ts", "content");
173
+ await repo.git(["add", "."]);
174
+ await repo.git(["commit", "-m", "Second commit"]);
175
+
176
+ const archive = await createArchiveBuffer([
177
+ { path: "restored.ts", content: "restored" },
178
+ ]);
179
+ const mockApiClient = createMockApiClient({
180
+ downloadArtifact: vi.fn().mockResolvedValue(archive),
181
+ });
182
+
183
+ const saga = new ApplySnapshotSaga(mockLogger);
184
+ await saga.run({
185
+ snapshot: createSnapshot({
186
+ baseCommit: initialCommit,
187
+ changes: [{ path: "restored.ts", status: "A" }],
188
+ }),
189
+ repositoryPath: repo.path,
190
+ apiClient: mockApiClient,
191
+ taskId: "task-1",
192
+ runId: "run-1",
193
+ });
194
+
195
+ const currentHead = await repo.git(["rev-parse", "HEAD"]);
196
+ expect(currentHead).toBe(initialCommit);
197
+ });
198
+
199
+ it("skips checkout when base commit matches current HEAD", async () => {
200
+ const currentHead = await repo.git(["rev-parse", "HEAD"]);
201
+
202
+ const archive = await createArchiveBuffer([
203
+ { path: "file.ts", content: "content" },
204
+ ]);
205
+ const mockApiClient = createMockApiClient({
206
+ downloadArtifact: vi.fn().mockResolvedValue(archive),
207
+ });
208
+
209
+ const saga = new ApplySnapshotSaga(mockLogger);
210
+ await saga.run({
211
+ snapshot: createSnapshot({
212
+ baseCommit: currentHead,
213
+ changes: [{ path: "file.ts", status: "A" }],
214
+ }),
215
+ repositoryPath: repo.path,
216
+ apiClient: mockApiClient,
217
+ taskId: "task-1",
218
+ runId: "run-1",
219
+ });
220
+
221
+ const newHead = await repo.git(["rev-parse", "HEAD"]);
222
+ expect(newHead).toBe(currentHead);
223
+ });
224
+
225
+ it("skips checkout when base commit is null", async () => {
226
+ const currentHead = await repo.git(["rev-parse", "HEAD"]);
227
+
228
+ const archive = await createArchiveBuffer([
229
+ { path: "file.ts", content: "content" },
230
+ ]);
231
+ const mockApiClient = createMockApiClient({
232
+ downloadArtifact: vi.fn().mockResolvedValue(archive),
233
+ });
234
+
235
+ const saga = new ApplySnapshotSaga(mockLogger);
236
+ await saga.run({
237
+ snapshot: createSnapshot({
238
+ baseCommit: null,
239
+ changes: [{ path: "file.ts", status: "A" }],
240
+ }),
241
+ repositoryPath: repo.path,
242
+ apiClient: mockApiClient,
243
+ taskId: "task-1",
244
+ runId: "run-1",
245
+ });
246
+
247
+ const newHead = await repo.git(["rev-parse", "HEAD"]);
248
+ expect(newHead).toBe(currentHead);
249
+ });
250
+ });
251
+
252
+ describe("failure handling", () => {
253
+ it("fails when snapshot has no archive URL", async () => {
254
+ const mockApiClient = createMockApiClient();
255
+
256
+ const saga = new ApplySnapshotSaga(mockLogger);
257
+ const result = await saga.run({
258
+ snapshot: createSnapshot({ archiveUrl: undefined }),
259
+ repositoryPath: repo.path,
260
+ apiClient: mockApiClient,
261
+ taskId: "task-1",
262
+ runId: "run-1",
263
+ });
264
+
265
+ expect(result.success).toBe(false);
266
+ if (!result.success) {
267
+ expect(result.error).toContain("no archive URL");
268
+ }
269
+ });
270
+
271
+ it("fails when download returns null", async () => {
272
+ const mockApiClient = createMockApiClient({
273
+ downloadArtifact: vi.fn().mockResolvedValue(null),
274
+ });
275
+
276
+ const saga = new ApplySnapshotSaga(mockLogger);
277
+ const result = await saga.run({
278
+ snapshot: createSnapshot(),
279
+ repositoryPath: repo.path,
280
+ apiClient: mockApiClient,
281
+ taskId: "task-1",
282
+ runId: "run-1",
283
+ });
284
+
285
+ expect(result.success).toBe(false);
286
+ if (!result.success) {
287
+ expect(result.failedStep).toBe("download_archive");
288
+ }
289
+ });
290
+
291
+ it("fails when download throws", async () => {
292
+ const mockApiClient = createMockApiClient({
293
+ downloadArtifact: vi.fn().mockRejectedValue(new Error("Network error")),
294
+ });
295
+
296
+ const saga = new ApplySnapshotSaga(mockLogger);
297
+ const result = await saga.run({
298
+ snapshot: createSnapshot(),
299
+ repositoryPath: repo.path,
300
+ apiClient: mockApiClient,
301
+ taskId: "task-1",
302
+ runId: "run-1",
303
+ });
304
+
305
+ expect(result.success).toBe(false);
306
+ if (!result.success) {
307
+ expect(result.error).toContain("Network error");
308
+ }
309
+ });
310
+
311
+ it("cleans up downloaded archive on success", async () => {
312
+ const archive = await createArchiveBuffer([
313
+ { path: "file.ts", content: "content" },
314
+ ]);
315
+ const mockApiClient = createMockApiClient({
316
+ downloadArtifact: vi.fn().mockResolvedValue(archive),
317
+ });
318
+
319
+ const saga = new ApplySnapshotSaga(mockLogger);
320
+ await saga.run({
321
+ snapshot: createSnapshot({
322
+ changes: [{ path: "file.ts", status: "A" }],
323
+ }),
324
+ repositoryPath: repo.path,
325
+ apiClient: mockApiClient,
326
+ taskId: "task-1",
327
+ runId: "run-1",
328
+ });
329
+
330
+ expect(repo.exists(".posthog/tmp/test-tree-hash.tar.gz")).toBe(false);
331
+ });
332
+
333
+ it("cleans up downloaded archive on checkout failure (rollback verification)", async () => {
334
+ const initialCommit = await repo.git(["rev-parse", "HEAD"]);
335
+
336
+ await repo.writeFile("conflicting.ts", "original content");
337
+ await repo.git(["add", "."]);
338
+ await repo.git(["commit", "-m", "Add file"]);
339
+
340
+ await repo.writeFile("conflicting.ts", "uncommitted changes");
341
+
342
+ const archive = await createArchiveBuffer([
343
+ { path: "restored.ts", content: "restored" },
344
+ ]);
345
+ const mockApiClient = createMockApiClient({
346
+ downloadArtifact: vi.fn().mockResolvedValue(archive),
347
+ });
348
+
349
+ const saga = new ApplySnapshotSaga(mockLogger);
350
+ const result = await saga.run({
351
+ snapshot: createSnapshot({
352
+ treeHash: "checkout-fail-hash",
353
+ baseCommit: initialCommit,
354
+ changes: [{ path: "restored.ts", status: "A" }],
355
+ }),
356
+ repositoryPath: repo.path,
357
+ apiClient: mockApiClient,
358
+ taskId: "task-1",
359
+ runId: "run-1",
360
+ });
361
+
362
+ expect(result.success).toBe(false);
363
+
364
+ expect(repo.exists(".posthog/tmp/checkout-fail-hash.tar.gz")).toBe(false);
365
+ });
366
+
367
+ it("cleans up downloaded archive on extract failure (rollback verification)", async () => {
368
+ const invalidArchive = Buffer.from("not a valid tar.gz");
369
+
370
+ const mockApiClient = createMockApiClient({
371
+ downloadArtifact: vi.fn().mockResolvedValue(invalidArchive),
372
+ });
373
+
374
+ const saga = new ApplySnapshotSaga(mockLogger);
375
+ const result = await saga.run({
376
+ snapshot: createSnapshot({
377
+ treeHash: "extract-fail-hash",
378
+ changes: [{ path: "file.ts", status: "A" }],
379
+ }),
380
+ repositoryPath: repo.path,
381
+ apiClient: mockApiClient,
382
+ taskId: "task-1",
383
+ runId: "run-1",
384
+ });
385
+
386
+ expect(result.success).toBe(false);
387
+
388
+ expect(repo.exists(".posthog/tmp/extract-fail-hash.tar.gz")).toBe(false);
389
+ });
390
+ });
391
+
392
+ describe("dirty working directory", () => {
393
+ it("fails early when repo has uncommitted changes before checkout", async () => {
394
+ const initialCommit = await repo.git(["rev-parse", "HEAD"]);
395
+
396
+ await repo.writeFile("file.ts", "content");
397
+ await repo.git(["add", "."]);
398
+ await repo.git(["commit", "-m", "Add file"]);
399
+
400
+ const secondCommit = await repo.git(["rev-parse", "HEAD"]);
401
+
402
+ await repo.writeFile("file.ts", "modified but not committed");
403
+
404
+ const archive = await createArchiveBuffer([
405
+ { path: "restored.ts", content: "restored" },
406
+ ]);
407
+ const mockApiClient = createMockApiClient({
408
+ downloadArtifact: vi.fn().mockResolvedValue(archive),
409
+ });
410
+
411
+ const saga = new ApplySnapshotSaga(mockLogger);
412
+ const result = await saga.run({
413
+ snapshot: createSnapshot({
414
+ baseCommit: initialCommit,
415
+ changes: [{ path: "restored.ts", status: "A" }],
416
+ }),
417
+ repositoryPath: repo.path,
418
+ apiClient: mockApiClient,
419
+ taskId: "task-1",
420
+ runId: "run-1",
421
+ });
422
+
423
+ expect(result.success).toBe(false);
424
+ if (!result.success) {
425
+ expect(result.error).toContain("uncommitted change");
426
+ }
427
+
428
+ const currentHead = await repo.git(["rev-parse", "HEAD"]);
429
+ expect(currentHead).toBe(secondCommit);
430
+ });
431
+
432
+ it("skips working tree check when base commit matches current HEAD", async () => {
433
+ const currentHead = await repo.git(["rev-parse", "HEAD"]);
434
+
435
+ await repo.writeFile("uncommitted.ts", "uncommitted content");
436
+
437
+ const archive = await createArchiveBuffer([
438
+ { path: "restored.ts", content: "restored" },
439
+ ]);
440
+ const mockApiClient = createMockApiClient({
441
+ downloadArtifact: vi.fn().mockResolvedValue(archive),
442
+ });
443
+
444
+ const saga = new ApplySnapshotSaga(mockLogger);
445
+ const result = await saga.run({
446
+ snapshot: createSnapshot({
447
+ baseCommit: currentHead,
448
+ changes: [{ path: "restored.ts", status: "A" }],
449
+ }),
450
+ repositoryPath: repo.path,
451
+ apiClient: mockApiClient,
452
+ taskId: "task-1",
453
+ runId: "run-1",
454
+ });
455
+
456
+ expect(result.success).toBe(true);
457
+ expect(repo.exists("restored.ts")).toBe(true);
458
+ });
459
+
460
+ it("leaves user in detached HEAD after applying snapshot with different base", async () => {
461
+ const initialCommit = await repo.git(["rev-parse", "HEAD"]);
462
+
463
+ await repo.writeFile("other.ts", "content");
464
+ await repo.git(["add", "."]);
465
+ await repo.git(["commit", "-m", "New commit"]);
466
+
467
+ const archive = await createArchiveBuffer([
468
+ { path: "restored.ts", content: "restored" },
469
+ ]);
470
+ const mockApiClient = createMockApiClient({
471
+ downloadArtifact: vi.fn().mockResolvedValue(archive),
472
+ });
473
+
474
+ const saga = new ApplySnapshotSaga(mockLogger);
475
+ await saga.run({
476
+ snapshot: createSnapshot({
477
+ baseCommit: initialCommit,
478
+ changes: [{ path: "restored.ts", status: "A" }],
479
+ }),
480
+ repositoryPath: repo.path,
481
+ apiClient: mockApiClient,
482
+ taskId: "task-1",
483
+ runId: "run-1",
484
+ });
485
+
486
+ const branchOutput = await repo.git(["branch", "--show-current"]);
487
+ expect(branchOutput).toBe("");
488
+
489
+ const headRef = await repo
490
+ .git(["symbolic-ref", "HEAD"])
491
+ .catch(() => "detached");
492
+ expect(headRef).toBe("detached");
493
+ });
494
+
495
+ it("logs warning about detached HEAD state", async () => {
496
+ const initialCommit = await repo.git(["rev-parse", "HEAD"]);
497
+
498
+ await repo.writeFile("other.ts", "content");
499
+ await repo.git(["add", "."]);
500
+ await repo.git(["commit", "-m", "New commit"]);
501
+
502
+ const archive = await createArchiveBuffer([
503
+ { path: "restored.ts", content: "restored" },
504
+ ]);
505
+ const mockApiClient = createMockApiClient({
506
+ downloadArtifact: vi.fn().mockResolvedValue(archive),
507
+ });
508
+
509
+ const saga = new ApplySnapshotSaga(mockLogger);
510
+ await saga.run({
511
+ snapshot: createSnapshot({
512
+ baseCommit: initialCommit,
513
+ changes: [{ path: "restored.ts", status: "A" }],
514
+ }),
515
+ repositoryPath: repo.path,
516
+ apiClient: mockApiClient,
517
+ taskId: "task-1",
518
+ runId: "run-1",
519
+ });
520
+
521
+ expect(mockLogger.warn).toHaveBeenCalledWith(
522
+ "Applied tree from different commit - now in detached HEAD state",
523
+ expect.objectContaining({
524
+ originalBranch: expect.any(String),
525
+ baseCommit: initialCommit,
526
+ }),
527
+ );
528
+ });
529
+
530
+ it("rolls back to original branch on failure after checkout", async () => {
531
+ const initialCommit = await repo.git(["rev-parse", "HEAD"]);
532
+ const originalBranch = await repo.git(["branch", "--show-current"]);
533
+
534
+ await repo.writeFile("other.ts", "content");
535
+ await repo.git(["add", "."]);
536
+ await repo.git(["commit", "-m", "New commit"]);
537
+
538
+ const invalidArchive = Buffer.from("not a valid tar.gz");
539
+ const mockApiClient = createMockApiClient({
540
+ downloadArtifact: vi.fn().mockResolvedValue(invalidArchive),
541
+ });
542
+
543
+ const saga = new ApplySnapshotSaga(mockLogger);
544
+ const result = await saga.run({
545
+ snapshot: createSnapshot({
546
+ baseCommit: initialCommit,
547
+ changes: [{ path: "restored.ts", status: "A" }],
548
+ }),
549
+ repositoryPath: repo.path,
550
+ apiClient: mockApiClient,
551
+ taskId: "task-1",
552
+ runId: "run-1",
553
+ });
554
+
555
+ expect(result.success).toBe(false);
556
+
557
+ const currentBranch = await repo.git(["branch", "--show-current"]);
558
+ expect(currentBranch).toBe(originalBranch);
559
+ });
560
+ });
561
+
562
+ describe("edge cases", () => {
563
+ it("handles empty snapshot (no changes)", async () => {
564
+ const archive = await createArchiveBuffer([
565
+ { path: "placeholder.txt", content: "placeholder" },
566
+ ]);
567
+ const mockApiClient = createMockApiClient({
568
+ downloadArtifact: vi.fn().mockResolvedValue(archive),
569
+ });
570
+
571
+ const saga = new ApplySnapshotSaga(mockLogger);
572
+ const result = await saga.run({
573
+ snapshot: createSnapshot({ changes: [] }),
574
+ repositoryPath: repo.path,
575
+ apiClient: mockApiClient,
576
+ taskId: "task-1",
577
+ runId: "run-1",
578
+ });
579
+
580
+ expect(result.success).toBe(true);
581
+ });
582
+
583
+ it("handles files with spaces in names", async () => {
584
+ const archive = await createArchiveBuffer([
585
+ { path: "file with spaces.ts", content: "content" },
586
+ ]);
587
+ const mockApiClient = createMockApiClient({
588
+ downloadArtifact: vi.fn().mockResolvedValue(archive),
589
+ });
590
+
591
+ const saga = new ApplySnapshotSaga(mockLogger);
592
+ await saga.run({
593
+ snapshot: createSnapshot({
594
+ changes: [{ path: "file with spaces.ts", status: "A" }],
595
+ }),
596
+ repositoryPath: repo.path,
597
+ apiClient: mockApiClient,
598
+ taskId: "task-1",
599
+ runId: "run-1",
600
+ });
601
+
602
+ expect(repo.exists("file with spaces.ts")).toBe(true);
603
+ });
604
+
605
+ it("deleting non-existent file does not fail", async () => {
606
+ const archive = await createArchiveBuffer([
607
+ { path: "placeholder.txt", content: "placeholder" },
608
+ ]);
609
+ const mockApiClient = createMockApiClient({
610
+ downloadArtifact: vi.fn().mockResolvedValue(archive),
611
+ });
612
+
613
+ const saga = new ApplySnapshotSaga(mockLogger);
614
+ const result = await saga.run({
615
+ snapshot: createSnapshot({
616
+ changes: [{ path: "does-not-exist.ts", status: "D" }],
617
+ }),
618
+ repositoryPath: repo.path,
619
+ apiClient: mockApiClient,
620
+ taskId: "task-1",
621
+ runId: "run-1",
622
+ });
623
+
624
+ expect(result.success).toBe(true);
625
+ });
626
+
627
+ it("returns tree hash on success", async () => {
628
+ const archive = await createArchiveBuffer([
629
+ { path: "file.ts", content: "content" },
630
+ ]);
631
+ const mockApiClient = createMockApiClient({
632
+ downloadArtifact: vi.fn().mockResolvedValue(archive),
633
+ });
634
+
635
+ const saga = new ApplySnapshotSaga(mockLogger);
636
+ const result = await saga.run({
637
+ snapshot: createSnapshot({
638
+ treeHash: "my-tree-hash",
639
+ changes: [{ path: "file.ts", status: "A" }],
640
+ }),
641
+ repositoryPath: repo.path,
642
+ apiClient: mockApiClient,
643
+ taskId: "task-1",
644
+ runId: "run-1",
645
+ });
646
+
647
+ expect(result.success).toBe(true);
648
+ if (result.success) {
649
+ expect(result.data.treeHash).toBe("my-tree-hash");
650
+ }
651
+ });
652
+
653
+ it("preserves symlinks in archive extraction", async () => {
654
+ const { lstat, readlink } = await import("node:fs/promises");
655
+
656
+ const archive = await createArchiveBuffer(
657
+ [{ path: "target.txt", content: "symlink target content" }],
658
+ [{ path: "link.txt", target: "target.txt" }],
659
+ );
660
+ const mockApiClient = createMockApiClient({
661
+ downloadArtifact: vi.fn().mockResolvedValue(archive),
662
+ });
663
+
664
+ const saga = new ApplySnapshotSaga(mockLogger);
665
+ const result = await saga.run({
666
+ snapshot: createSnapshot({
667
+ changes: [
668
+ { path: "target.txt", status: "A" },
669
+ { path: "link.txt", status: "A" },
670
+ ],
671
+ }),
672
+ repositoryPath: repo.path,
673
+ apiClient: mockApiClient,
674
+ taskId: "task-1",
675
+ runId: "run-1",
676
+ });
677
+
678
+ expect(result.success).toBe(true);
679
+ expect(repo.exists("target.txt")).toBe(true);
680
+ expect(repo.exists("link.txt")).toBe(true);
681
+
682
+ const linkPath = join(repo.path, "link.txt");
683
+ const stats = await lstat(linkPath);
684
+ expect(stats.isSymbolicLink()).toBe(true);
685
+
686
+ const linkTarget = await readlink(linkPath);
687
+ expect(linkTarget).toBe("target.txt");
688
+ });
689
+ });
690
+ });