@posthog/agent 1.30.0 → 2.0.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 (144) 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 +172 -1203
  35. package/dist/index.js +3704 -6826
  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 +4451 -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 +4507 -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 +66 -14
  51. package/src/acp-extensions.ts +93 -61
  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 -611
  70. package/src/adapters/claude/types.ts +61 -0
  71. package/src/adapters/codex/spawn.ts +130 -0
  72. package/src/agent.ts +97 -734
  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 +51 -154
  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/dist/templates/plan-template.md +0 -41
  119. package/src/adapters/claude/claude.ts +0 -1543
  120. package/src/adapters/claude/mcp-server.ts +0 -810
  121. package/src/adapters/claude/utils.ts +0 -267
  122. package/src/agents/execution.ts +0 -37
  123. package/src/agents/planning.ts +0 -60
  124. package/src/agents/research.ts +0 -160
  125. package/src/file-manager.ts +0 -306
  126. package/src/git-manager.ts +0 -577
  127. package/src/prompt-builder.ts +0 -499
  128. package/src/schemas.ts +0 -241
  129. package/src/session-store.ts +0 -259
  130. package/src/task-manager.ts +0 -163
  131. package/src/template-manager.ts +0 -236
  132. package/src/templates/plan-template.md +0 -41
  133. package/src/todo-manager.ts +0 -180
  134. package/src/tools/registry.ts +0 -129
  135. package/src/tools/types.ts +0 -127
  136. package/src/utils/tapped-stream.ts +0 -60
  137. package/src/workflow/config.ts +0 -53
  138. package/src/workflow/steps/build.ts +0 -135
  139. package/src/workflow/steps/finalize.ts +0 -241
  140. package/src/workflow/steps/plan.ts +0 -167
  141. package/src/workflow/steps/research.ts +0 -223
  142. package/src/workflow/types.ts +0 -62
  143. package/src/workflow/utils.ts +0 -53
  144. package/src/worktree-manager.ts +0 -928
@@ -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
+ });