@posthog/agent 2.3.386 → 2.3.388

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 (45) hide show
  1. package/dist/adapters/claude/session/jsonl-hydration.d.ts +1 -0
  2. package/dist/agent.d.ts +1 -0
  3. package/dist/agent.js +20 -2
  4. package/dist/agent.js.map +1 -1
  5. package/dist/handoff-checkpoint.d.ts +43 -0
  6. package/dist/handoff-checkpoint.js +6684 -0
  7. package/dist/handoff-checkpoint.js.map +1 -0
  8. package/dist/index.d.ts +2 -0
  9. package/dist/index.js +2 -0
  10. package/dist/index.js.map +1 -1
  11. package/dist/posthog-api.d.ts +2 -0
  12. package/dist/posthog-api.js +18 -2
  13. package/dist/posthog-api.js.map +1 -1
  14. package/dist/resume.d.ts +4 -8
  15. package/dist/resume.js +266 -6491
  16. package/dist/resume.js.map +1 -1
  17. package/dist/server/agent-server.d.ts +7 -16
  18. package/dist/server/agent-server.js +2333 -1383
  19. package/dist/server/agent-server.js.map +1 -1
  20. package/dist/server/bin.cjs +2332 -1382
  21. package/dist/server/bin.cjs.map +1 -1
  22. package/dist/server/schemas.d.ts +191 -0
  23. package/dist/server/schemas.js +108 -0
  24. package/dist/server/schemas.js.map +1 -0
  25. package/dist/tree-tracker.d.ts +1 -0
  26. package/dist/tree-tracker.js +18 -4
  27. package/dist/tree-tracker.js.map +1 -1
  28. package/dist/types.d.ts +18 -1
  29. package/dist/types.js +5 -0
  30. package/dist/types.js.map +1 -1
  31. package/package.json +10 -2
  32. package/src/acp-extensions.ts +3 -0
  33. package/src/handoff-checkpoint.test.ts +183 -0
  34. package/src/handoff-checkpoint.ts +367 -0
  35. package/src/posthog-api.test.ts +29 -0
  36. package/src/posthog-api.ts +13 -1
  37. package/src/resume.ts +27 -12
  38. package/src/sagas/apply-snapshot-saga.ts +7 -0
  39. package/src/sagas/capture-tree-saga.ts +10 -3
  40. package/src/sagas/resume-saga.test.ts +7 -47
  41. package/src/sagas/resume-saga.ts +42 -64
  42. package/src/sagas/test-fixtures.ts +46 -0
  43. package/src/server/agent-server.ts +193 -70
  44. package/src/server/schemas.ts +21 -2
  45. package/src/types.ts +24 -0
@@ -153,6 +153,14 @@ export class PostHogAPIClient {
153
153
  );
154
154
  }
155
155
 
156
+ async resumeRunInCloud(taskId: string, runId: string): Promise<TaskRun> {
157
+ const teamId = this.getTeamId();
158
+ return this.apiRequest<TaskRun>(
159
+ `/api/projects/${teamId}/tasks/${taskId}/runs/${runId}/resume_in_cloud/`,
160
+ { method: "POST" },
161
+ );
162
+ }
163
+
156
164
  async updateTaskRun(
157
165
  taskId: string,
158
166
  runId: string,
@@ -230,7 +238,11 @@ export class PostHogAPIClient {
230
238
  },
231
239
  );
232
240
 
233
- return response.artifacts ?? [];
241
+ const manifest = response.artifacts ?? [];
242
+
243
+ // The backend returns the full run artifact manifest after each upload.
244
+ // Callers want the artifacts corresponding to this upload request only.
245
+ return manifest.slice(-artifacts.length);
234
246
  }
235
247
 
236
248
  /**
package/src/resume.ts CHANGED
@@ -19,14 +19,17 @@ import type { ContentBlock } from "@agentclientprotocol/sdk";
19
19
  import { selectRecentTurns } from "./adapters/claude/session/jsonl-hydration";
20
20
  import type { PostHogAPIClient } from "./posthog-api";
21
21
  import { ResumeSaga } from "./sagas/resume-saga";
22
- import type { DeviceInfo, TreeSnapshotEvent } from "./types";
22
+ import type {
23
+ DeviceInfo,
24
+ GitCheckpointEvent,
25
+ TreeSnapshotEvent,
26
+ } from "./types";
23
27
  import { Logger } from "./utils/logger";
24
28
 
25
29
  export interface ResumeState {
26
30
  conversation: ConversationTurn[];
27
31
  latestSnapshot: TreeSnapshotEvent | null;
28
- /** Whether the tree snapshot was successfully applied (files restored) */
29
- snapshotApplied: boolean;
32
+ latestGitCheckpoint: GitCheckpointEvent | null;
30
33
  interrupted: boolean;
31
34
  lastDevice?: DeviceInfo;
32
35
  logEntryCount: number;
@@ -56,11 +59,7 @@ export interface ResumeConfig {
56
59
  /**
57
60
  * Resume a task from its persisted log.
58
61
  * Returns the rebuilt state for the agent to continue from.
59
- *
60
- * Uses Saga pattern internally for atomic operations.
61
- * Note: snapshotApplied field indicates if files were actually restored -
62
- * even if latestSnapshot is non-null, files may not have been restored if
63
- * the snapshot had no archive URL or download/extraction failed.
62
+ * Snapshot and checkpoint application happens in the agent server after SSE connects.
64
63
  */
65
64
  export async function resumeFromLog(
66
65
  config: ResumeConfig,
@@ -96,7 +95,7 @@ export async function resumeFromLog(
96
95
  return {
97
96
  conversation: result.data.conversation as ConversationTurn[],
98
97
  latestSnapshot: result.data.latestSnapshot,
99
- snapshotApplied: result.data.snapshotApplied,
98
+ latestGitCheckpoint: result.data.latestGitCheckpoint,
100
99
  interrupted: result.data.interrupted,
101
100
  lastDevice: result.data.lastDevice,
102
101
  logEntryCount: result.data.logEntryCount,
@@ -118,15 +117,31 @@ export function conversationToPromptHistory(
118
117
  const RESUME_HISTORY_TOKEN_BUDGET = 50_000;
119
118
  const TOOL_RESULT_MAX_CHARS = 2000;
120
119
 
120
+ const RESUME_CONTEXT_MARKERS = [
121
+ "You are resuming a previous conversation",
122
+ "Here is the conversation history from the",
123
+ "Continue from where you left off",
124
+ ];
125
+
126
+ function isResumeContextTurn(turn: ConversationTurn): boolean {
127
+ if (turn.role !== "user") return false;
128
+ const text = turn.content
129
+ .filter((b) => b.type === "text")
130
+ .map((b) => (b as { type: "text"; text: string }).text)
131
+ .join("");
132
+ return RESUME_CONTEXT_MARKERS.some((marker) => text.includes(marker));
133
+ }
134
+
121
135
  export function formatConversationForResume(
122
136
  conversation: ConversationTurn[],
123
137
  ): string {
124
- const selected = selectRecentTurns(conversation, RESUME_HISTORY_TOKEN_BUDGET);
138
+ const filtered = conversation.filter((turn) => !isResumeContextTurn(turn));
139
+ const selected = selectRecentTurns(filtered, RESUME_HISTORY_TOKEN_BUDGET);
125
140
  const parts: string[] = [];
126
141
 
127
- if (selected.length < conversation.length) {
142
+ if (selected.length < filtered.length) {
128
143
  parts.push(
129
- `*(${conversation.length - selected.length} earlier turns omitted)*`,
144
+ `*(${filtered.length - selected.length} earlier turns omitted)*`,
130
145
  );
131
146
  }
132
147
 
@@ -59,6 +59,13 @@ export class ApplySnapshotSaga extends Saga<
59
59
  const base64Content = Buffer.from(arrayBuffer).toString("utf-8");
60
60
  const binaryContent = Buffer.from(base64Content, "base64");
61
61
  await writeFile(archivePath, binaryContent);
62
+ this.log.info("Tree archive downloaded", {
63
+ treeHash: snapshot.treeHash,
64
+ snapshotBytes: binaryContent.byteLength,
65
+ snapshotWireBytes: arrayBuffer.byteLength,
66
+ totalBytes: binaryContent.byteLength,
67
+ totalWireBytes: arrayBuffer.byteLength,
68
+ });
62
69
  },
63
70
  rollback: async () => {
64
71
  if (this.archivePath) {
@@ -113,6 +113,8 @@ export class CaptureTreeSaga extends Saga<CaptureTreeInput, CaptureTreeOutput> {
113
113
  execute: async () => {
114
114
  const archiveContent = await readFile(archivePath);
115
115
  const base64Content = archiveContent.toString("base64");
116
+ const snapshotBytes = archiveContent.byteLength;
117
+ const snapshotWireBytes = Buffer.byteLength(base64Content, "utf-8");
116
118
 
117
119
  const artifacts = await apiClient.uploadTaskArtifacts(taskId, runId, [
118
120
  {
@@ -123,12 +125,17 @@ export class CaptureTreeSaga extends Saga<CaptureTreeInput, CaptureTreeOutput> {
123
125
  },
124
126
  ]);
125
127
 
126
- if (artifacts.length > 0 && artifacts[0].storage_path) {
128
+ const uploadedArtifact = artifacts[0];
129
+ if (uploadedArtifact?.storage_path) {
127
130
  this.log.info("Tree archive uploaded", {
128
- storagePath: artifacts[0].storage_path,
131
+ storagePath: uploadedArtifact.storage_path,
129
132
  treeHash,
133
+ snapshotBytes,
134
+ snapshotWireBytes,
135
+ totalBytes: snapshotBytes,
136
+ totalWireBytes: snapshotWireBytes,
130
137
  });
131
- return artifacts[0].storage_path;
138
+ return uploadedArtifact.storage_path;
132
139
  }
133
140
 
134
141
  return undefined;
@@ -6,7 +6,6 @@ import { ResumeSaga } from "./resume-saga";
6
6
  import {
7
7
  createAgentChunk,
8
8
  createAgentMessage,
9
- createArchiveBuffer,
10
9
  createMockApiClient,
11
10
  createMockLogger,
12
11
  createNotification,
@@ -52,7 +51,6 @@ describe("ResumeSaga", () => {
52
51
  if (result.success) {
53
52
  expect(result.data.conversation).toHaveLength(0);
54
53
  expect(result.data.latestSnapshot).toBeNull();
55
- expect(result.data.snapshotApplied).toBe(false);
56
54
  expect(result.data.logEntryCount).toBe(0);
57
55
  }
58
56
  });
@@ -468,14 +466,10 @@ describe("ResumeSaga", () => {
468
466
  });
469
467
  });
470
468
 
471
- describe("snapshot application", () => {
472
- it("applies snapshot when archive URL present", async () => {
469
+ describe("snapshot metadata", () => {
470
+ it("returns latest snapshot metadata when archive URL present", async () => {
473
471
  const baseCommit = await repo.git(["rev-parse", "HEAD"]);
474
472
 
475
- const archive = await createArchiveBuffer([
476
- { path: "restored.ts", content: "restored content" },
477
- ]);
478
-
479
473
  (mockApiClient.getTaskRun as ReturnType<typeof vi.fn>).mockResolvedValue(
480
474
  createTaskRun(),
481
475
  );
@@ -490,9 +484,6 @@ describe("ResumeSaga", () => {
490
484
  timestamp: new Date().toISOString(),
491
485
  }),
492
486
  ]);
493
- (
494
- mockApiClient.downloadArtifact as ReturnType<typeof vi.fn>
495
- ).mockResolvedValue(archive);
496
487
 
497
488
  const saga = new ResumeSaga(mockLogger);
498
489
  const result = await saga.run({
@@ -505,13 +496,13 @@ describe("ResumeSaga", () => {
505
496
  expect(result.success).toBe(true);
506
497
  if (!result.success) return;
507
498
 
508
- expect(result.data.snapshotApplied).toBe(true);
509
-
510
- const content = await repo.readFile("restored.ts");
511
- expect(content).toBe("restored content");
499
+ expect(result.data.latestSnapshot?.treeHash).toBe("hash-1");
500
+ expect(result.data.latestSnapshot?.archiveUrl).toBe(
501
+ "gs://bucket/hash-1.tar.gz",
502
+ );
512
503
  });
513
504
 
514
- it("continues without snapshot when no archive URL", async () => {
505
+ it("returns snapshot metadata even when no archive URL", async () => {
515
506
  (mockApiClient.getTaskRun as ReturnType<typeof vi.fn>).mockResolvedValue(
516
507
  createTaskRun(),
517
508
  );
@@ -533,40 +524,9 @@ describe("ResumeSaga", () => {
533
524
  expect(result.success).toBe(true);
534
525
  if (!result.success) return;
535
526
 
536
- expect(result.data.snapshotApplied).toBe(false);
537
527
  expect(result.data.latestSnapshot).not.toBeNull();
538
528
  expect(result.data.conversation).toHaveLength(1);
539
529
  });
540
-
541
- it("continues without snapshot when apply fails", async () => {
542
- (mockApiClient.getTaskRun as ReturnType<typeof vi.fn>).mockResolvedValue(
543
- createTaskRun(),
544
- );
545
- (
546
- mockApiClient.fetchTaskRunLogs as ReturnType<typeof vi.fn>
547
- ).mockResolvedValue([
548
- createTreeSnapshotNotification("hash-1", "gs://bucket/hash-1.tar.gz"),
549
- createUserMessage("hello"),
550
- ]);
551
- (
552
- mockApiClient.downloadArtifact as ReturnType<typeof vi.fn>
553
- ).mockRejectedValue(new Error("Download failed"));
554
-
555
- const saga = new ResumeSaga(mockLogger);
556
- const result = await saga.run({
557
- taskId: "task-1",
558
- runId: "run-1",
559
- repositoryPath: repo.path,
560
- apiClient: mockApiClient,
561
- });
562
-
563
- expect(result.success).toBe(true);
564
- if (!result.success) return;
565
-
566
- expect(result.data.snapshotApplied).toBe(false);
567
- expect(result.data.conversation).toHaveLength(1);
568
- expect(mockLogger.warn).toHaveBeenCalled();
569
- });
570
530
  });
571
531
 
572
532
  describe("device info", () => {
@@ -2,13 +2,13 @@ import type { ContentBlock } from "@agentclientprotocol/sdk";
2
2
  import { Saga } from "@posthog/shared";
3
3
  import { isNotification, POSTHOG_NOTIFICATIONS } from "../acp-extensions";
4
4
  import type { PostHogAPIClient } from "../posthog-api";
5
- import { TreeTracker } from "../tree-tracker";
6
5
  import type {
7
6
  DeviceInfo,
7
+ GitCheckpointEvent,
8
8
  StoredNotification,
9
9
  TreeSnapshotEvent,
10
10
  } from "../types";
11
- import { Logger } from "../utils/logger";
11
+ import type { Logger } from "../utils/logger";
12
12
 
13
13
  export interface ConversationTurn {
14
14
  role: "user" | "assistant";
@@ -34,7 +34,7 @@ export interface ResumeInput {
34
34
  export interface ResumeOutput {
35
35
  conversation: ConversationTurn[];
36
36
  latestSnapshot: TreeSnapshotEvent | null;
37
- snapshotApplied: boolean;
37
+ latestGitCheckpoint: GitCheckpointEvent | null;
38
38
  interrupted: boolean;
39
39
  lastDevice?: DeviceInfo;
40
40
  logEntryCount: number;
@@ -44,9 +44,7 @@ export class ResumeSaga extends Saga<ResumeInput, ResumeOutput> {
44
44
  readonly sagaName = "ResumeSaga";
45
45
 
46
46
  protected async execute(input: ResumeInput): Promise<ResumeOutput> {
47
- const { taskId, runId, repositoryPath, apiClient } = input;
48
- const logger =
49
- input.logger || new Logger({ debug: false, prefix: "[Resume]" });
47
+ const { taskId, runId, apiClient } = input;
50
48
 
51
49
  // Step 1: Fetch task run (read-only)
52
50
  const taskRun = await this.readOnlyStep("fetch_task_run", () =>
@@ -75,70 +73,27 @@ export class ResumeSaga extends Saga<ResumeInput, ResumeOutput> {
75
73
  Promise.resolve(this.findLatestTreeSnapshot(entries)),
76
74
  );
77
75
 
76
+ const latestGitCheckpoint = await this.readOnlyStep(
77
+ "find_git_checkpoint",
78
+ () => Promise.resolve(this.findLatestGitCheckpoint(entries)),
79
+ );
80
+
78
81
  // Step 4: Apply snapshot if present (wrapped in step for consistent logging)
79
- // Note: We use a try/catch inside the step because snapshot failure should NOT fail the saga
80
- let snapshotApplied = false;
81
- if (latestSnapshot?.archiveUrl && repositoryPath) {
82
+ if (latestSnapshot) {
82
83
  this.log.info("Found tree snapshot", {
83
84
  treeHash: latestSnapshot.treeHash,
84
- hasArchiveUrl: true,
85
+ hasArchiveUrl: !!latestSnapshot.archiveUrl,
85
86
  changes: latestSnapshot.changes?.length ?? 0,
86
- interrupted: latestSnapshot.interrupted,
87
87
  });
88
+ }
88
89
 
89
- await this.step({
90
- name: "apply_snapshot",
91
- execute: async () => {
92
- const treeTracker = new TreeTracker({
93
- repositoryPath,
94
- taskId,
95
- runId,
96
- apiClient,
97
- logger: logger.child("TreeTracker"),
98
- });
99
-
100
- try {
101
- await treeTracker.applyTreeSnapshot(latestSnapshot);
102
- treeTracker.setLastTreeHash(latestSnapshot.treeHash);
103
- snapshotApplied = true;
104
- this.log.info("Tree snapshot applied successfully", {
105
- treeHash: latestSnapshot.treeHash,
106
- });
107
- } catch (error) {
108
- // Log but don't fail - continue with conversation rebuild
109
- // ApplySnapshotSaga handles its own rollback internally
110
- this.log.warn(
111
- "Failed to apply tree snapshot, continuing without it",
112
- {
113
- error: error instanceof Error ? error.message : String(error),
114
- treeHash: latestSnapshot.treeHash,
115
- },
116
- );
117
- }
118
- },
119
- rollback: async () => {
120
- // Inner ApplySnapshotSaga handles its own rollback
121
- },
90
+ if (latestGitCheckpoint) {
91
+ this.log.info("Found git checkpoint", {
92
+ checkpointId: latestGitCheckpoint.checkpointId,
93
+ branch: latestGitCheckpoint.branch,
122
94
  });
123
- } else if (latestSnapshot?.archiveUrl && !repositoryPath) {
124
- this.log.warn(
125
- "Snapshot found but no repositoryPath configured - files cannot be restored",
126
- {
127
- treeHash: latestSnapshot.treeHash,
128
- changes: latestSnapshot.changes?.length ?? 0,
129
- },
130
- );
131
- } else if (latestSnapshot) {
132
- this.log.warn(
133
- "Snapshot found but has no archive URL - files cannot be restored",
134
- {
135
- treeHash: latestSnapshot.treeHash,
136
- changes: latestSnapshot.changes?.length ?? 0,
137
- },
138
- );
139
95
  }
140
96
 
141
- // Step 5: Rebuild conversation (read-only, pure computation)
142
97
  const conversation = await this.readOnlyStep("rebuild_conversation", () =>
143
98
  Promise.resolve(this.rebuildConversation(entries)),
144
99
  );
@@ -151,14 +106,14 @@ export class ResumeSaga extends Saga<ResumeInput, ResumeOutput> {
151
106
  this.log.info("Resume state rebuilt", {
152
107
  turns: conversation.length,
153
108
  hasSnapshot: !!latestSnapshot,
154
- snapshotApplied,
109
+ hasGitCheckpoint: !!latestGitCheckpoint,
155
110
  interrupted: latestSnapshot?.interrupted ?? false,
156
111
  });
157
112
 
158
113
  return {
159
114
  conversation,
160
115
  latestSnapshot,
161
- snapshotApplied,
116
+ latestGitCheckpoint,
162
117
  interrupted: latestSnapshot?.interrupted ?? false,
163
118
  lastDevice,
164
119
  logEntryCount: entries.length,
@@ -169,7 +124,7 @@ export class ResumeSaga extends Saga<ResumeInput, ResumeOutput> {
169
124
  return {
170
125
  conversation: [],
171
126
  latestSnapshot: null,
172
- snapshotApplied: false,
127
+ latestGitCheckpoint: null,
173
128
  interrupted: false,
174
129
  logEntryCount: 0,
175
130
  };
@@ -197,6 +152,29 @@ export class ResumeSaga extends Saga<ResumeInput, ResumeOutput> {
197
152
  return null;
198
153
  }
199
154
 
155
+ private findLatestGitCheckpoint(
156
+ entries: StoredNotification[],
157
+ ): GitCheckpointEvent | null {
158
+ const sdkPrefixedMethod = `_${POSTHOG_NOTIFICATIONS.GIT_CHECKPOINT}`;
159
+
160
+ for (let i = entries.length - 1; i >= 0; i--) {
161
+ const entry = entries[i];
162
+ const method = entry.notification?.method;
163
+ if (
164
+ method === sdkPrefixedMethod ||
165
+ method === POSTHOG_NOTIFICATIONS.GIT_CHECKPOINT
166
+ ) {
167
+ const params = entry.notification?.params as
168
+ | GitCheckpointEvent
169
+ | undefined;
170
+ if (params?.checkpointId && params?.checkpointRef) {
171
+ return params;
172
+ }
173
+ }
174
+ }
175
+ return null;
176
+ }
177
+
200
178
  private findLastDeviceInfo(
201
179
  entries: StoredNotification[],
202
180
  ): DeviceInfo | undefined {
@@ -67,6 +67,52 @@ export async function createTestRepo(prefix = "test-repo"): Promise<TestRepo> {
67
67
  };
68
68
  }
69
69
 
70
+ export async function cloneTestRepo(
71
+ sourcePath: string,
72
+ prefix = "test-repo-clone",
73
+ ): Promise<TestRepo> {
74
+ const clonePath = join(
75
+ tmpdir(),
76
+ `${prefix}-${Date.now()}-${Math.random().toString(36).slice(2)}`,
77
+ );
78
+ await execFileAsync("git", ["clone", sourcePath, clonePath]);
79
+ await execFileAsync("git", ["config", "user.email", "test@test.com"], {
80
+ cwd: clonePath,
81
+ });
82
+ await execFileAsync("git", ["config", "user.name", "Test"], {
83
+ cwd: clonePath,
84
+ });
85
+ await execFileAsync("git", ["config", "commit.gpgsign", "false"], {
86
+ cwd: clonePath,
87
+ });
88
+
89
+ const git = async (args: string[]): Promise<string> => {
90
+ const { stdout } = await execFileAsync("git", args, { cwd: clonePath });
91
+ return stdout.trim();
92
+ };
93
+
94
+ return {
95
+ path: clonePath,
96
+ cleanup: () => rm(clonePath, { recursive: true, force: true }),
97
+ git,
98
+ writeFile: async (relativePath: string, content: string) => {
99
+ const fullPath = join(clonePath, relativePath);
100
+ const dir = join(fullPath, "..");
101
+ await mkdir(dir, { recursive: true });
102
+ await writeFile(fullPath, content);
103
+ },
104
+ readFile: async (relativePath: string) => {
105
+ return readFile(join(clonePath, relativePath), "utf-8");
106
+ },
107
+ deleteFile: async (relativePath: string) => {
108
+ await rm(join(clonePath, relativePath), { force: true });
109
+ },
110
+ exists: (relativePath: string) => {
111
+ return existsSync(join(clonePath, relativePath));
112
+ },
113
+ };
114
+ }
115
+
70
116
  export function createMockLogger(): SagaLogger {
71
117
  return {
72
118
  info: vi.fn(),