@posthog/agent 2.3.657 → 2.3.663

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.
package/dist/types.d.ts CHANGED
@@ -91,6 +91,8 @@ interface TaskExecutionOptions {
91
91
  processCallbacks?: ProcessSpawnedCallback;
92
92
  /** Callback invoked when the agent calls the create_output tool for structured output */
93
93
  onStructuredOutput?: (output: Record<string, unknown>) => Promise<void>;
94
+ /** Additional directories the agent process can access beyond cwd. */
95
+ additionalDirectories?: string[];
94
96
  }
95
97
  type LogLevel = "debug" | "info" | "warn" | "error";
96
98
  type OnLogCallback = (level: LogLevel, scope: string, message: string, data?: unknown) => void;
package/dist/types.js.map CHANGED
@@ -1 +1 @@
1
- {"version":3,"sources":["../src/types.ts"],"sourcesContent":["import type {\n GitHandoffCheckpoint,\n HandoffLocalGitState as GitHandoffLocalGitState,\n} from \"@posthog/git/handoff\";\n\n/**\n * Stored custom notification following ACP extensibility model.\n * Custom notifications use underscore-prefixed methods (e.g., `_posthog/phase_start`).\n * See: https://agentclientprotocol.com/docs/extensibility\n */\nexport interface StoredNotification {\n type: \"notification\";\n /** When this notification was stored */\n timestamp: string;\n /** JSON-RPC 2.0 notification (no id field = notification, not request) */\n notification: {\n jsonrpc: \"2.0\";\n method: string;\n params?: Record<string, unknown>;\n };\n}\n\n/**\n * Type alias for stored log entries.\n */\nexport type StoredEntry = StoredNotification;\n\n// PostHog Task model (matches PostHog Code's OpenAPI schema)\nexport interface Task {\n id: string;\n task_number?: number;\n slug?: string;\n title: string;\n description: string;\n origin_product:\n | \"error_tracking\"\n | \"eval_clusters\"\n | \"user_created\"\n | \"support_queue\"\n | \"session_summaries\"\n | \"signal_report\";\n github_integration?: number | null;\n repository: string; // Format: \"organization/repository\" (e.g., \"posthog/posthog-js\")\n json_schema?: Record<string, unknown> | null; // JSON schema for task output validation\n internal?: boolean;\n created_at: string;\n updated_at: string;\n created_by?: {\n id: number;\n uuid: string;\n distinct_id: string;\n first_name: string;\n email: string;\n };\n latest_run?: TaskRun;\n}\n\n// Log entry structure for TaskRun.log\n\nexport type ArtifactType =\n | \"plan\"\n | \"context\"\n | \"reference\"\n | \"output\"\n | \"artifact\"\n | \"user_attachment\";\n\nexport interface TaskRunArtifact {\n id?: string;\n name: string;\n type: ArtifactType;\n source?: string;\n size?: number;\n content_type?: string;\n storage_path?: string;\n uploaded_at?: string;\n}\n\nexport type TaskRunStatus =\n | \"not_started\"\n | \"queued\"\n | \"in_progress\"\n | \"completed\"\n | \"failed\"\n | \"cancelled\";\n\nexport type TaskRunEnvironment = \"local\" | \"cloud\";\n\n// TaskRun model - represents individual execution runs of tasks\nexport interface TaskRun {\n id: string;\n task: string; // Task ID\n team: number;\n branch: string | null;\n stage: string | null; // Current stage (e.g., 'research', 'plan', 'build')\n environment: TaskRunEnvironment;\n status: TaskRunStatus;\n log_url: string;\n error_message: string | null;\n output: Record<string, unknown> | null; // Structured output (PR URL, commit SHA, etc.)\n state: Record<string, unknown>; // Intermediate run state (defaults to {}, never null)\n artifacts?: TaskRunArtifact[];\n created_at: string;\n updated_at: string;\n completed_at: string | null;\n}\n\nexport interface ProcessSpawnedCallback {\n onProcessSpawned?: (info: {\n pid: number;\n command: string;\n sessionId?: string;\n }) => void;\n onProcessExited?: (pid: number) => void;\n onMcpServersReady?: (serverNames: string[]) => void;\n}\n\nexport interface TaskExecutionOptions {\n repositoryPath?: string;\n adapter?: \"claude\" | \"codex\";\n model?: string;\n gatewayUrl?: string;\n codexBinaryPath?: string;\n instructions?: string;\n processCallbacks?: ProcessSpawnedCallback;\n /** Callback invoked when the agent calls the create_output tool for structured output */\n onStructuredOutput?: (output: Record<string, unknown>) => Promise<void>;\n}\n\nexport type LogLevel = \"debug\" | \"info\" | \"warn\" | \"error\";\n\nexport type OnLogCallback = (\n level: LogLevel,\n scope: string,\n message: string,\n data?: unknown,\n) => void;\n\nexport interface PostHogAPIConfig {\n apiUrl: string;\n getApiKey: () => string | Promise<string>;\n refreshApiKey?: () => string | Promise<string>;\n projectId: number;\n userAgent?: string;\n}\n\nexport interface OtelTransportConfig {\n /** PostHog ingest host, e.g., \"https://us.i.posthog.com\" */\n host: string;\n /** Project API key */\n apiKey: string;\n /** Override the logs endpoint path (default: /i/v1/logs) */\n logsPath?: string;\n}\n\nexport interface AgentConfig {\n posthog?: PostHogAPIConfig;\n /** OTEL transport config for shipping logs to PostHog Logs */\n otelTransport?: OtelTransportConfig;\n /** Skip session log persistence (e.g. for preview sessions with no real task) */\n skipLogPersistence?: boolean;\n /** Local cache path for instant log loading (e.g., ~/.posthog-code) */\n localCachePath?: string;\n /**\n * Annotate files the agent reads with PostHog enrichment (event volume,\n * flag rollout/staleness, experiment links). Defaults to enabled when\n * `posthog` config is present; set `{ enabled: false }` to opt out.\n */\n enricher?: { enabled?: boolean };\n debug?: boolean;\n onLog?: OnLogCallback;\n}\n\n// Device info for tracking where work happens\nexport interface DeviceInfo {\n type: \"local\" | \"cloud\";\n name?: string;\n}\n\n// Agent execution mode - for tracking interactive vs background runs, when backgrounded an agent will continue working without asking questions\nexport type AgentMode = \"interactive\" | \"background\";\n\n// Git file status codes\nexport type FileStatus = \"A\" | \"M\" | \"D\";\n\nexport interface FileChange {\n path: string;\n status: FileStatus;\n}\n\nexport type HandoffLocalGitState = GitHandoffLocalGitState;\n\nexport interface GitCheckpoint extends GitHandoffCheckpoint {\n artifactPath?: string;\n indexArtifactPath?: string;\n}\n\nexport interface GitCheckpointEvent extends GitCheckpoint {\n device?: DeviceInfo;\n}\n\n/**\n * Keeps the emitted `@posthog/agent/types` entrypoint as a runtime ESM module.\n *\n * `export {}` is stripped by tsup in this package, which leaves `dist/types.js`\n * empty and breaks downstream type resolution for the exported subpath.\n */\nexport const AGENT_TYPES_MODULE = true;\n"],"mappings":";AA+MO,IAAM,qBAAqB;","names":[]}
1
+ {"version":3,"sources":["../src/types.ts"],"sourcesContent":["import type {\n GitHandoffCheckpoint,\n HandoffLocalGitState as GitHandoffLocalGitState,\n} from \"@posthog/git/handoff\";\n\n/**\n * Stored custom notification following ACP extensibility model.\n * Custom notifications use underscore-prefixed methods (e.g., `_posthog/phase_start`).\n * See: https://agentclientprotocol.com/docs/extensibility\n */\nexport interface StoredNotification {\n type: \"notification\";\n /** When this notification was stored */\n timestamp: string;\n /** JSON-RPC 2.0 notification (no id field = notification, not request) */\n notification: {\n jsonrpc: \"2.0\";\n method: string;\n params?: Record<string, unknown>;\n };\n}\n\n/**\n * Type alias for stored log entries.\n */\nexport type StoredEntry = StoredNotification;\n\n// PostHog Task model (matches PostHog Code's OpenAPI schema)\nexport interface Task {\n id: string;\n task_number?: number;\n slug?: string;\n title: string;\n description: string;\n origin_product:\n | \"error_tracking\"\n | \"eval_clusters\"\n | \"user_created\"\n | \"support_queue\"\n | \"session_summaries\"\n | \"signal_report\";\n github_integration?: number | null;\n repository: string; // Format: \"organization/repository\" (e.g., \"posthog/posthog-js\")\n json_schema?: Record<string, unknown> | null; // JSON schema for task output validation\n internal?: boolean;\n created_at: string;\n updated_at: string;\n created_by?: {\n id: number;\n uuid: string;\n distinct_id: string;\n first_name: string;\n email: string;\n };\n latest_run?: TaskRun;\n}\n\n// Log entry structure for TaskRun.log\n\nexport type ArtifactType =\n | \"plan\"\n | \"context\"\n | \"reference\"\n | \"output\"\n | \"artifact\"\n | \"user_attachment\";\n\nexport interface TaskRunArtifact {\n id?: string;\n name: string;\n type: ArtifactType;\n source?: string;\n size?: number;\n content_type?: string;\n storage_path?: string;\n uploaded_at?: string;\n}\n\nexport type TaskRunStatus =\n | \"not_started\"\n | \"queued\"\n | \"in_progress\"\n | \"completed\"\n | \"failed\"\n | \"cancelled\";\n\nexport type TaskRunEnvironment = \"local\" | \"cloud\";\n\n// TaskRun model - represents individual execution runs of tasks\nexport interface TaskRun {\n id: string;\n task: string; // Task ID\n team: number;\n branch: string | null;\n stage: string | null; // Current stage (e.g., 'research', 'plan', 'build')\n environment: TaskRunEnvironment;\n status: TaskRunStatus;\n log_url: string;\n error_message: string | null;\n output: Record<string, unknown> | null; // Structured output (PR URL, commit SHA, etc.)\n state: Record<string, unknown>; // Intermediate run state (defaults to {}, never null)\n artifacts?: TaskRunArtifact[];\n created_at: string;\n updated_at: string;\n completed_at: string | null;\n}\n\nexport interface ProcessSpawnedCallback {\n onProcessSpawned?: (info: {\n pid: number;\n command: string;\n sessionId?: string;\n }) => void;\n onProcessExited?: (pid: number) => void;\n onMcpServersReady?: (serverNames: string[]) => void;\n}\n\nexport interface TaskExecutionOptions {\n repositoryPath?: string;\n adapter?: \"claude\" | \"codex\";\n model?: string;\n gatewayUrl?: string;\n codexBinaryPath?: string;\n instructions?: string;\n processCallbacks?: ProcessSpawnedCallback;\n /** Callback invoked when the agent calls the create_output tool for structured output */\n onStructuredOutput?: (output: Record<string, unknown>) => Promise<void>;\n /** Additional directories the agent process can access beyond cwd. */\n additionalDirectories?: string[];\n}\n\nexport type LogLevel = \"debug\" | \"info\" | \"warn\" | \"error\";\n\nexport type OnLogCallback = (\n level: LogLevel,\n scope: string,\n message: string,\n data?: unknown,\n) => void;\n\nexport interface PostHogAPIConfig {\n apiUrl: string;\n getApiKey: () => string | Promise<string>;\n refreshApiKey?: () => string | Promise<string>;\n projectId: number;\n userAgent?: string;\n}\n\nexport interface OtelTransportConfig {\n /** PostHog ingest host, e.g., \"https://us.i.posthog.com\" */\n host: string;\n /** Project API key */\n apiKey: string;\n /** Override the logs endpoint path (default: /i/v1/logs) */\n logsPath?: string;\n}\n\nexport interface AgentConfig {\n posthog?: PostHogAPIConfig;\n /** OTEL transport config for shipping logs to PostHog Logs */\n otelTransport?: OtelTransportConfig;\n /** Skip session log persistence (e.g. for preview sessions with no real task) */\n skipLogPersistence?: boolean;\n /** Local cache path for instant log loading (e.g., ~/.posthog-code) */\n localCachePath?: string;\n /**\n * Annotate files the agent reads with PostHog enrichment (event volume,\n * flag rollout/staleness, experiment links). Defaults to enabled when\n * `posthog` config is present; set `{ enabled: false }` to opt out.\n */\n enricher?: { enabled?: boolean };\n debug?: boolean;\n onLog?: OnLogCallback;\n}\n\n// Device info for tracking where work happens\nexport interface DeviceInfo {\n type: \"local\" | \"cloud\";\n name?: string;\n}\n\n// Agent execution mode - for tracking interactive vs background runs, when backgrounded an agent will continue working without asking questions\nexport type AgentMode = \"interactive\" | \"background\";\n\n// Git file status codes\nexport type FileStatus = \"A\" | \"M\" | \"D\";\n\nexport interface FileChange {\n path: string;\n status: FileStatus;\n}\n\nexport type HandoffLocalGitState = GitHandoffLocalGitState;\n\nexport interface GitCheckpoint extends GitHandoffCheckpoint {\n artifactPath?: string;\n indexArtifactPath?: string;\n}\n\nexport interface GitCheckpointEvent extends GitCheckpoint {\n device?: DeviceInfo;\n}\n\n/**\n * Keeps the emitted `@posthog/agent/types` entrypoint as a runtime ESM module.\n *\n * `export {}` is stripped by tsup in this package, which leaves `dist/types.js`\n * empty and breaks downstream type resolution for the exported subpath.\n */\nexport const AGENT_TYPES_MODULE = true;\n"],"mappings":";AAiNO,IAAM,qBAAqB;","names":[]}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@posthog/agent",
3
- "version": "2.3.657",
3
+ "version": "2.3.663",
4
4
  "repository": "https://github.com/PostHog/code",
5
5
  "description": "TypeScript agent framework wrapping Claude Agent SDK with Git-based task execution for PostHog",
6
6
  "exports": {
@@ -17,6 +17,8 @@ export interface CodexProcessOptions {
17
17
  logger?: Logger;
18
18
  processCallbacks?: ProcessSpawnedCallback;
19
19
  settings?: CodexSettings;
20
+ /** Additional writable roots passed to Codex's workspace-write sandbox. */
21
+ additionalDirectories?: string[];
20
22
  }
21
23
 
22
24
  export interface CodexProcess {
@@ -57,6 +59,13 @@ function buildConfigArgs(options: CodexProcessOptions): string[] {
57
59
  args.push("-c", `model_reasoning_effort="${options.reasoningEffort}"`);
58
60
  }
59
61
 
62
+ if (options.additionalDirectories?.length) {
63
+ const escaped = options.additionalDirectories
64
+ .map((p) => `"${p.replace(/\\/g, "\\\\").replace(/"/g, '\\"')}"`)
65
+ .join(",");
66
+ args.push("-c", `sandbox_workspace_write.writable_roots=[${escaped}]`);
67
+ }
68
+
60
69
  if (options.instructions) {
61
70
  const escaped = options.instructions
62
71
  .replace(/\\/g, "\\\\")
package/src/agent.ts CHANGED
@@ -136,6 +136,7 @@ export class Agent {
136
136
  binaryPath: options.codexBinaryPath,
137
137
  model: sanitizedModel,
138
138
  instructions: options.instructions,
139
+ additionalDirectories: options.additionalDirectories,
139
140
  }
140
141
  : undefined,
141
142
  });
@@ -203,6 +203,247 @@ describe("AgentServer HTTP Mode", () => {
203
203
  });
204
204
 
205
205
  describe("turn completion", () => {
206
+ function stubSessionCleanup(testServer: unknown): {
207
+ cleanupSession: (options?: {
208
+ completeEventStream?: boolean;
209
+ }) => Promise<void>;
210
+ eventStreamSender: {
211
+ enqueue: ReturnType<typeof vi.fn>;
212
+ stop: ReturnType<typeof vi.fn>;
213
+ };
214
+ } {
215
+ const cleanupServer = testServer as {
216
+ session: unknown;
217
+ eventStreamSender: {
218
+ enqueue: ReturnType<typeof vi.fn>;
219
+ stop: ReturnType<typeof vi.fn>;
220
+ };
221
+ captureCheckpointState: ReturnType<typeof vi.fn>;
222
+ cleanupSession: (options?: {
223
+ completeEventStream?: boolean;
224
+ }) => Promise<void>;
225
+ };
226
+ cleanupServer.captureCheckpointState = vi.fn(async () => {});
227
+ cleanupServer.eventStreamSender = {
228
+ enqueue: vi.fn(),
229
+ stop: vi.fn(async () => {}),
230
+ };
231
+ cleanupServer.session = {
232
+ payload: { run_id: "run-1" },
233
+ pendingHandoffGitState: undefined,
234
+ logWriter: { flush: vi.fn(async () => {}) },
235
+ acpConnection: { cleanup: vi.fn(async () => {}) },
236
+ sseController: { close: vi.fn() },
237
+ };
238
+ return cleanupServer;
239
+ }
240
+
241
+ it("keeps event ingest open for non-terminal session cleanup", async () => {
242
+ const testServer = stubSessionCleanup(createServer());
243
+
244
+ await testServer.cleanupSession();
245
+
246
+ expect(testServer.eventStreamSender.enqueue).not.toHaveBeenCalled();
247
+ expect(testServer.eventStreamSender.stop).not.toHaveBeenCalled();
248
+ });
249
+
250
+ it("stops event ingest for terminal session cleanup without fake task completion", async () => {
251
+ const testServer = stubSessionCleanup(createServer());
252
+
253
+ await testServer.cleanupSession({ completeEventStream: true });
254
+
255
+ expect(testServer.eventStreamSender.enqueue).not.toHaveBeenCalled();
256
+ expect(testServer.eventStreamSender.stop).toHaveBeenCalledOnce();
257
+ });
258
+
259
+ it("writes terminal failure status before completing event ingest", async () => {
260
+ const order: string[] = [];
261
+ const testServer = new AgentServer({
262
+ port,
263
+ jwtPublicKey: TEST_PUBLIC_KEY,
264
+ repositoryPath: repo.path,
265
+ apiUrl: "http://localhost:8000",
266
+ apiKey: "test-api-key",
267
+ projectId: 1,
268
+ mode: "interactive",
269
+ taskId: "test-task-id",
270
+ runId: "test-run-id",
271
+ }) as unknown as {
272
+ eventStreamSender: {
273
+ enqueue: (event: Record<string, unknown>) => void;
274
+ stop: () => Promise<void>;
275
+ };
276
+ posthogAPI: {
277
+ updateTaskRun: (
278
+ taskId: string,
279
+ runId: string,
280
+ payload: Record<string, unknown>,
281
+ ) => Promise<unknown>;
282
+ };
283
+ signalTaskComplete(
284
+ payload: JwtPayload,
285
+ stopReason: string,
286
+ errorMessage?: string,
287
+ ): Promise<void>;
288
+ };
289
+ testServer.eventStreamSender = {
290
+ enqueue: vi.fn(() => {
291
+ order.push("enqueue");
292
+ }),
293
+ stop: vi.fn(async () => {
294
+ order.push("stop");
295
+ }),
296
+ };
297
+ testServer.posthogAPI = {
298
+ updateTaskRun: vi.fn(async () => {
299
+ order.push("update");
300
+ return {};
301
+ }),
302
+ };
303
+
304
+ await testServer.signalTaskComplete(
305
+ {
306
+ run_id: "run-1",
307
+ task_id: "task-1",
308
+ team_id: 1,
309
+ user_id: 1,
310
+ distinct_id: "distinct-id",
311
+ mode: "interactive",
312
+ },
313
+ "error",
314
+ "boom",
315
+ );
316
+
317
+ expect(order).toEqual(["enqueue", "update", "stop"]);
318
+ expect(testServer.eventStreamSender.enqueue).toHaveBeenCalledWith(
319
+ expect.objectContaining({
320
+ type: "notification",
321
+ notification: expect.objectContaining({
322
+ method: "_posthog/error",
323
+ params: expect.objectContaining({ error: "boom" }),
324
+ }),
325
+ }),
326
+ );
327
+ expect(testServer.posthogAPI.updateTaskRun).toHaveBeenCalledWith(
328
+ "task-1",
329
+ "run-1",
330
+ {
331
+ status: "failed",
332
+ error_message: "boom",
333
+ },
334
+ );
335
+ });
336
+
337
+ it("still stops event ingest when terminal failure status update fails", async () => {
338
+ const testServer = new AgentServer({
339
+ port,
340
+ jwtPublicKey: TEST_PUBLIC_KEY,
341
+ repositoryPath: repo.path,
342
+ apiUrl: "http://localhost:8000",
343
+ apiKey: "test-api-key",
344
+ projectId: 1,
345
+ mode: "interactive",
346
+ taskId: "test-task-id",
347
+ runId: "test-run-id",
348
+ }) as unknown as {
349
+ eventStreamSender: {
350
+ enqueue: (event: Record<string, unknown>) => void;
351
+ stop: () => Promise<void>;
352
+ };
353
+ posthogAPI: {
354
+ updateTaskRun: (
355
+ taskId: string,
356
+ runId: string,
357
+ payload: Record<string, unknown>,
358
+ ) => Promise<unknown>;
359
+ };
360
+ signalTaskComplete(
361
+ payload: JwtPayload,
362
+ stopReason: string,
363
+ errorMessage?: string,
364
+ ): Promise<void>;
365
+ };
366
+ testServer.eventStreamSender = {
367
+ enqueue: vi.fn(),
368
+ stop: vi.fn(async () => {}),
369
+ };
370
+ testServer.posthogAPI = {
371
+ updateTaskRun: vi.fn(async () => {
372
+ throw new Error("update failed");
373
+ }),
374
+ };
375
+
376
+ await testServer.signalTaskComplete(
377
+ {
378
+ run_id: "run-1",
379
+ task_id: "task-1",
380
+ team_id: 1,
381
+ user_id: 1,
382
+ distinct_id: "distinct-id",
383
+ mode: "interactive",
384
+ },
385
+ "error",
386
+ "boom",
387
+ );
388
+
389
+ expect(testServer.eventStreamSender.enqueue).toHaveBeenCalledOnce();
390
+ expect(testServer.posthogAPI.updateTaskRun).toHaveBeenCalledOnce();
391
+ expect(testServer.eventStreamSender.stop).toHaveBeenCalledOnce();
392
+ });
393
+
394
+ it("leaves event ingest open for non-error stop reasons", async () => {
395
+ const testServer = new AgentServer({
396
+ port,
397
+ jwtPublicKey: TEST_PUBLIC_KEY,
398
+ repositoryPath: repo.path,
399
+ apiUrl: "http://localhost:8000",
400
+ apiKey: "test-api-key",
401
+ projectId: 1,
402
+ mode: "interactive",
403
+ taskId: "test-task-id",
404
+ runId: "test-run-id",
405
+ }) as unknown as {
406
+ eventStreamSender: {
407
+ enqueue: (event: Record<string, unknown>) => void;
408
+ stop: () => Promise<void>;
409
+ };
410
+ posthogAPI: {
411
+ updateTaskRun: (
412
+ taskId: string,
413
+ runId: string,
414
+ payload: Record<string, unknown>,
415
+ ) => Promise<unknown>;
416
+ };
417
+ signalTaskComplete(
418
+ payload: JwtPayload,
419
+ stopReason: string,
420
+ ): Promise<void>;
421
+ };
422
+ testServer.eventStreamSender = {
423
+ enqueue: vi.fn(),
424
+ stop: vi.fn(async () => {}),
425
+ };
426
+ testServer.posthogAPI = {
427
+ updateTaskRun: vi.fn(async () => ({})),
428
+ };
429
+
430
+ await testServer.signalTaskComplete(
431
+ {
432
+ run_id: "run-1",
433
+ task_id: "task-1",
434
+ team_id: 1,
435
+ user_id: 1,
436
+ distinct_id: "distinct-id",
437
+ mode: "interactive",
438
+ },
439
+ "end_turn",
440
+ );
441
+
442
+ expect(testServer.eventStreamSender.enqueue).not.toHaveBeenCalled();
443
+ expect(testServer.eventStreamSender.stop).not.toHaveBeenCalled();
444
+ expect(testServer.posthogAPI.updateTaskRun).not.toHaveBeenCalled();
445
+ });
446
+
206
447
  it("persists structured turn completion notifications", () => {
207
448
  const appendRawLine = vi.fn();
208
449
  const testServer = new AgentServer({
@@ -59,6 +59,7 @@ import {
59
59
  normalizeCloudPromptContent,
60
60
  promptBlocksToText,
61
61
  } from "./cloud-prompt";
62
+ import { TaskRunEventStreamSender } from "./event-stream-sender";
62
63
  import { type JwtPayload, JwtValidationError, validateJwt } from "./jwt";
63
64
  import {
64
65
  handoffLocalGitStateSchema,
@@ -233,6 +234,7 @@ export class AgentServer {
233
234
  private session: ActiveSession | null = null;
234
235
  private app: Hono;
235
236
  private posthogAPI: PostHogAPIClient;
237
+ private eventStreamSender: TaskRunEventStreamSender | null = null;
236
238
  private questionRelayedToSlack = false;
237
239
  private detectedPrUrl: string | null = null;
238
240
  private lastReportedBranch: string | null = null;
@@ -297,6 +299,17 @@ export class AgentServer {
297
299
  getApiKey: () => config.apiKey,
298
300
  userAgent: `posthog/cloud.hog.dev; version: ${config.version ?? packageJson.version}`,
299
301
  });
302
+ if (config.eventIngestToken) {
303
+ this.eventStreamSender = new TaskRunEventStreamSender({
304
+ apiUrl: config.apiUrl,
305
+ projectId: config.projectId,
306
+ taskId: config.taskId,
307
+ runId: config.runId,
308
+ token: config.eventIngestToken,
309
+ logger: this.logger.child("EventIngest"),
310
+ streamWindowMs: config.eventIngestStreamWindowMs,
311
+ });
312
+ }
300
313
  this.app = this.createApp();
301
314
  }
302
315
 
@@ -560,7 +573,9 @@ export class AgentServer {
560
573
  this.logger.debug("Stopping agent server...");
561
574
 
562
575
  if (this.session) {
563
- await this.cleanupSession();
576
+ await this.cleanupSession({ completeEventStream: true });
577
+ } else {
578
+ await this.eventStreamSender?.stop();
564
579
  }
565
580
 
566
581
  if (this.server) {
@@ -1798,6 +1813,12 @@ ${signedCommitInstructions}
1798
1813
 
1799
1814
  const status = "failed";
1800
1815
 
1816
+ this.enqueueTaskTerminalEvent(POSTHOG_NOTIFICATIONS.ERROR, {
1817
+ source: "agent_server",
1818
+ stopReason,
1819
+ error: errorMessage ?? "Agent error",
1820
+ });
1821
+
1801
1822
  try {
1802
1823
  await this.posthogAPI.updateTaskRun(payload.task_id, payload.run_id, {
1803
1824
  status,
@@ -1806,9 +1827,28 @@ ${signedCommitInstructions}
1806
1827
  this.logger.debug("Task completion signaled", { status, stopReason });
1807
1828
  } catch (error) {
1808
1829
  this.logger.error("Failed to signal task completion", error);
1830
+ } finally {
1831
+ await this.eventStreamSender?.stop();
1809
1832
  }
1810
1833
  }
1811
1834
 
1835
+ private enqueueTaskTerminalEvent(
1836
+ method:
1837
+ | typeof POSTHOG_NOTIFICATIONS.TASK_COMPLETE
1838
+ | typeof POSTHOG_NOTIFICATIONS.ERROR,
1839
+ params: Record<string, unknown>,
1840
+ ): void {
1841
+ this.eventStreamSender?.enqueue({
1842
+ type: "notification",
1843
+ timestamp: new Date().toISOString(),
1844
+ notification: {
1845
+ jsonrpc: "2.0",
1846
+ method,
1847
+ params,
1848
+ },
1849
+ });
1850
+ }
1851
+
1812
1852
  private configureEnvironment({
1813
1853
  isInternal = false,
1814
1854
  originProduct,
@@ -2224,7 +2264,11 @@ ${signedCommitInstructions}
2224
2264
  }
2225
2265
  }
2226
2266
 
2227
- private async cleanupSession(): Promise<void> {
2267
+ private async cleanupSession({
2268
+ completeEventStream = false,
2269
+ }: {
2270
+ completeEventStream?: boolean;
2271
+ } = {}): Promise<void> {
2228
2272
  if (!this.session) return;
2229
2273
 
2230
2274
  this.logger.debug("Cleaning up session");
@@ -2263,6 +2307,10 @@ ${signedCommitInstructions}
2263
2307
  this.session.sseController.close();
2264
2308
  }
2265
2309
 
2310
+ if (completeEventStream) {
2311
+ await this.eventStreamSender?.stop();
2312
+ }
2313
+
2266
2314
  this.pendingEvents = [];
2267
2315
  this.lastReportedBranch = null;
2268
2316
  this.session = null;
@@ -2346,9 +2394,13 @@ ${signedCommitInstructions}
2346
2394
  }
2347
2395
 
2348
2396
  private broadcastEvent(event: Record<string, unknown>): void {
2397
+ if (!this.session) return;
2398
+
2399
+ this.eventStreamSender?.enqueue(event);
2400
+
2349
2401
  if (this.session?.sseController) {
2350
2402
  this.sendSseEvent(this.session.sseController, event);
2351
- } else if (this.session) {
2403
+ } else {
2352
2404
  // Buffer events during initialization (sseController not yet attached)
2353
2405
  this.pendingEvents.push(event);
2354
2406
  }
package/src/server/bin.ts CHANGED
@@ -32,6 +32,15 @@ const envSchema = z.object({
32
32
  POSTHOG_CODE_REASONING_EFFORT: z
33
33
  .enum(["low", "medium", "high", "xhigh", "max"])
34
34
  .optional(),
35
+ POSTHOG_TASK_RUN_EVENT_INGEST_TOKEN: z.string().min(1).optional(),
36
+ POSTHOG_TASK_RUN_EVENT_INGEST_STREAM_WINDOW_MS: z
37
+ .string()
38
+ .regex(
39
+ /^[1-9]\d*$/,
40
+ "POSTHOG_TASK_RUN_EVENT_INGEST_STREAM_WINDOW_MS must be a positive integer",
41
+ )
42
+ .transform((value) => parseInt(value, 10))
43
+ .optional(),
35
44
  });
36
45
 
37
46
  const program = new Command();
@@ -148,6 +157,9 @@ program
148
157
  const server = new AgentServer({
149
158
  port: parseInt(options.port, 10),
150
159
  jwtPublicKey: env.JWT_PUBLIC_KEY,
160
+ eventIngestToken: env.POSTHOG_TASK_RUN_EVENT_INGEST_TOKEN,
161
+ eventIngestStreamWindowMs:
162
+ env.POSTHOG_TASK_RUN_EVENT_INGEST_STREAM_WINDOW_MS,
151
163
  repositoryPath: options.repositoryPath,
152
164
  apiUrl: env.POSTHOG_API_URL,
153
165
  apiKey: env.POSTHOG_PERSONAL_API_KEY,
@@ -67,6 +67,46 @@ function responseForBody(body: string, lastAcceptedSeq = 0): Response {
67
67
  });
68
68
  }
69
69
 
70
+ type StreamingRequestInit = RequestInit & { duplex: "half" };
71
+
72
+ function createFetchStreamingUpload({
73
+ url,
74
+ headers,
75
+ abortController,
76
+ }: {
77
+ url: string;
78
+ headers: Record<string, string>;
79
+ abortController: AbortController;
80
+ }) {
81
+ const bodyStream = new TransformStream<Uint8Array, Uint8Array>();
82
+ const writer = bodyStream.writable.getWriter();
83
+ const requestInit: StreamingRequestInit = {
84
+ method: "POST",
85
+ headers,
86
+ body: bodyStream.readable as BodyInit,
87
+ signal: abortController.signal,
88
+ duplex: "half",
89
+ };
90
+
91
+ return {
92
+ write(chunk: Uint8Array): Promise<void> {
93
+ return writer.write(chunk);
94
+ },
95
+ close(): Promise<void> {
96
+ return writer.close();
97
+ },
98
+ async abort(): Promise<void> {
99
+ abortController.abort();
100
+ try {
101
+ await writer.abort();
102
+ } catch {
103
+ // The fetch mock may have already closed the body reader.
104
+ }
105
+ },
106
+ responsePromise: fetch(url, requestInit),
107
+ };
108
+ }
109
+
70
110
  function createSender(
71
111
  options: Partial<
72
112
  ConstructorParameters<typeof TaskRunEventStreamSender>[0]
@@ -79,6 +119,7 @@ function createSender(
79
119
  runId: "run-1",
80
120
  token: "ingest-token",
81
121
  logger: new Logger({ debug: false }),
122
+ createStreamingUpload: createFetchStreamingUpload,
82
123
  ...options,
83
124
  });
84
125
  }
@@ -198,6 +239,39 @@ describe("TaskRunEventStreamSender", () => {
198
239
  ]);
199
240
  });
200
241
 
242
+ it("closes an idle active ingest request after the stream window elapses", async () => {
243
+ const requestBodies: string[] = [];
244
+ let activeStreamClosed = false;
245
+ const fetchMock = vi.fn(
246
+ async (_url: string | URL | Request, init?: RequestInit) => {
247
+ if (!init?.body || typeof init.body === "string") {
248
+ return responseForBody(await readRequestBody(init));
249
+ }
250
+
251
+ const body = await readRequestBody(init);
252
+ activeStreamClosed = true;
253
+ requestBodies.push(body);
254
+ return responseForBody(body);
255
+ },
256
+ );
257
+ vi.stubGlobal("fetch", fetchMock);
258
+
259
+ const sender = createSender({ flushDelayMs: 0, streamWindowMs: 5 });
260
+
261
+ sender.enqueue({ type: "notification", notification: { method: "first" } });
262
+ await waitFor(() => fetchMock.mock.calls.length === 2);
263
+ expect(activeStreamClosed).toBe(false);
264
+
265
+ await waitFor(() => activeStreamClosed, 200);
266
+ expect(eventSequences(requestBodies[0])).toEqual([1]);
267
+ expect(completionSequences(requestBodies[0])).toEqual([]);
268
+
269
+ await sender.stop();
270
+
271
+ expect(eventSequences(requestBodies[1])).toEqual([]);
272
+ expect(completionSequences(requestBodies[1])).toEqual([1]);
273
+ });
274
+
201
275
  it("aborts a stuck ingest response after closing the request body", async () => {
202
276
  let aborted = false;
203
277
  const fetchMock = vi.fn(