@posthog/agent 2.3.104 → 2.3.116

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
@@ -88,7 +88,8 @@ type LogLevel = "debug" | "info" | "warn" | "error";
88
88
  type OnLogCallback = (level: LogLevel, scope: string, message: string, data?: unknown) => void;
89
89
  interface PostHogAPIConfig {
90
90
  apiUrl: string;
91
- getApiKey: () => string;
91
+ getApiKey: () => string | Promise<string>;
92
+ refreshApiKey?: () => string | Promise<string>;
92
93
  projectId: number;
93
94
  userAgent?: string;
94
95
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@posthog/agent",
3
- "version": "2.3.104",
3
+ "version": "2.3.116",
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": {
package/src/agent.ts CHANGED
@@ -45,17 +45,17 @@ export class Agent {
45
45
  }
46
46
  }
47
47
 
48
- private _configureLlmGateway(overrideUrl?: string): {
48
+ private async _configureLlmGateway(overrideUrl?: string): Promise<{
49
49
  gatewayUrl: string;
50
50
  apiKey: string;
51
- } | null {
51
+ } | null> {
52
52
  if (!this.posthogAPI) {
53
53
  return null;
54
54
  }
55
55
 
56
56
  try {
57
57
  const gatewayUrl = overrideUrl ?? this.posthogAPI.getLlmGatewayUrl();
58
- const apiKey = this.posthogAPI.getApiKey();
58
+ const apiKey = await this.posthogAPI.getApiKey();
59
59
 
60
60
  process.env.OPENAI_BASE_URL = `${gatewayUrl}/v1`;
61
61
  process.env.OPENAI_API_KEY = apiKey;
@@ -74,7 +74,7 @@ export class Agent {
74
74
  taskRunId: string,
75
75
  options: TaskExecutionOptions = {},
76
76
  ): Promise<InProcessAcpConnection> {
77
- const gatewayConfig = this._configureLlmGateway(options.gatewayUrl);
77
+ const gatewayConfig = await this._configureLlmGateway(options.gatewayUrl);
78
78
  this.logger.info("Configured LLM gateway", {
79
79
  adapter: options.adapter,
80
80
  });
@@ -175,7 +175,7 @@ export class Agent {
175
175
 
176
176
  async cleanup(): Promise<void> {
177
177
  if (this.sessionLogWriter && this.taskRunId) {
178
- await this.sessionLogWriter.flush(this.taskRunId);
178
+ await this.sessionLogWriter.flush(this.taskRunId, { coalesce: true });
179
179
  }
180
180
  await this.acpConnection?.cleanup();
181
181
  }
@@ -0,0 +1,48 @@
1
+ import { beforeEach, describe, expect, it, vi } from "vitest";
2
+ import { PostHogAPIClient } from "./posthog-api";
3
+
4
+ const mockFetch = vi.fn();
5
+
6
+ vi.stubGlobal("fetch", mockFetch);
7
+
8
+ describe("PostHogAPIClient", () => {
9
+ beforeEach(() => {
10
+ vi.clearAllMocks();
11
+ });
12
+
13
+ it("refreshes once when fetching task run logs gets an auth failure", async () => {
14
+ const getApiKey = vi.fn().mockResolvedValue("stale-token");
15
+ const refreshApiKey = vi.fn().mockResolvedValue("fresh-token");
16
+ const client = new PostHogAPIClient({
17
+ apiUrl: "https://app.posthog.com",
18
+ getApiKey,
19
+ refreshApiKey,
20
+ projectId: 1,
21
+ });
22
+
23
+ mockFetch
24
+ .mockResolvedValueOnce({
25
+ ok: false,
26
+ status: 401,
27
+ statusText: "Unauthorized",
28
+ })
29
+ .mockResolvedValueOnce({
30
+ ok: true,
31
+ text: vi
32
+ .fn()
33
+ .mockResolvedValue(
34
+ `${JSON.stringify({ type: "notification", notification: { method: "foo" } })}\n`,
35
+ ),
36
+ });
37
+
38
+ const logs = await client.fetchTaskRunLogs({
39
+ id: "run-1",
40
+ task: "task-1",
41
+ } as never);
42
+
43
+ expect(logs).toHaveLength(1);
44
+ expect(getApiKey).toHaveBeenCalledTimes(1);
45
+ expect(refreshApiKey).toHaveBeenCalledTimes(1);
46
+ expect(mockFetch).toHaveBeenCalledTimes(2);
47
+ });
48
+ });
@@ -47,27 +47,63 @@ export class PostHogAPIClient {
47
47
  return host;
48
48
  }
49
49
 
50
- private get headers(): Record<string, string> {
51
- return {
52
- Authorization: `Bearer ${this.config.getApiKey()}`,
53
- "Content-Type": "application/json",
54
- "User-Agent": this.config.userAgent ?? DEFAULT_USER_AGENT,
55
- };
50
+ private isAuthFailure(status: number): boolean {
51
+ return status === 401 || status === 403;
56
52
  }
57
53
 
58
- private async apiRequest<T>(
54
+ private async resolveApiKey(forceRefresh = false): Promise<string> {
55
+ if (forceRefresh && this.config.refreshApiKey) {
56
+ return this.config.refreshApiKey();
57
+ }
58
+
59
+ return this.config.getApiKey();
60
+ }
61
+
62
+ private async buildHeaders(
63
+ options: RequestInit,
64
+ forceRefresh = false,
65
+ ): Promise<Headers> {
66
+ const headers = new Headers(options.headers);
67
+ headers.set(
68
+ "Authorization",
69
+ `Bearer ${await this.resolveApiKey(forceRefresh)}`,
70
+ );
71
+ headers.set("Content-Type", "application/json");
72
+ headers.set("User-Agent", this.config.userAgent ?? DEFAULT_USER_AGENT);
73
+ return headers;
74
+ }
75
+
76
+ private async performRequest(
59
77
  endpoint: string,
60
- options: RequestInit = {},
61
- ): Promise<T> {
78
+ options: RequestInit,
79
+ forceRefresh = false,
80
+ ): Promise<Response> {
62
81
  const url = `${this.baseUrl}${endpoint}`;
63
82
 
64
- const response = await fetch(url, {
83
+ return fetch(url, {
65
84
  ...options,
66
- headers: {
67
- ...this.headers,
68
- ...options.headers,
69
- },
85
+ headers: await this.buildHeaders(options, forceRefresh),
70
86
  });
87
+ }
88
+
89
+ private async performRequestWithRetry(
90
+ endpoint: string,
91
+ options: RequestInit = {},
92
+ ): Promise<Response> {
93
+ let response = await this.performRequest(endpoint, options);
94
+
95
+ if (!response.ok && this.isAuthFailure(response.status)) {
96
+ response = await this.performRequest(endpoint, options, true);
97
+ }
98
+
99
+ return response;
100
+ }
101
+
102
+ private async apiRequest<T>(
103
+ endpoint: string,
104
+ options: RequestInit = {},
105
+ ): Promise<T> {
106
+ const response = await this.performRequestWithRetry(endpoint, options);
71
107
 
72
108
  if (!response.ok) {
73
109
  let errorMessage: string;
@@ -87,8 +123,8 @@ export class PostHogAPIClient {
87
123
  return this.config.projectId;
88
124
  }
89
125
 
90
- getApiKey(): string {
91
- return this.config.getApiKey();
126
+ async getApiKey(forceRefresh = false): Promise<string> {
127
+ return this.resolveApiKey(forceRefresh);
92
128
  }
93
129
 
94
130
  getLlmGatewayUrl(): string {
@@ -228,12 +264,10 @@ export class PostHogAPIClient {
228
264
  */
229
265
  async fetchTaskRunLogs(taskRun: TaskRun): Promise<StoredEntry[]> {
230
266
  const teamId = this.getTeamId();
267
+ const endpoint = `/api/projects/${teamId}/tasks/${taskRun.task}/runs/${taskRun.id}/logs`;
231
268
 
232
269
  try {
233
- const response = await fetch(
234
- `${this.baseUrl}/api/projects/${teamId}/tasks/${taskRun.task}/runs/${taskRun.id}/logs`,
235
- { headers: this.headers },
236
- );
270
+ const response = await this.performRequestWithRetry(endpoint);
237
271
 
238
272
  if (!response.ok) {
239
273
  if (response.status === 404) {
@@ -523,7 +523,9 @@ export class AgentServer {
523
523
  // against async log persistence to object storage.
524
524
  let assistantMessage: string | undefined;
525
525
  try {
526
- await this.session.logWriter.flush(this.session.payload.run_id);
526
+ await this.session.logWriter.flush(this.session.payload.run_id, {
527
+ coalesce: true,
528
+ });
527
529
  assistantMessage = this.session.logWriter.getFullAgentResponse(
528
530
  this.session.payload.run_id,
529
531
  );
@@ -747,6 +749,9 @@ export class AgentServer {
747
749
  });
748
750
 
749
751
  this.logger.info("Session initialized successfully");
752
+ this.logger.info(
753
+ `Agent version: ${this.config.version ?? packageJson.version}`,
754
+ );
750
755
 
751
756
  // Signal in_progress so the UI can start polling for updates
752
757
  this.posthogAPI
@@ -1111,7 +1116,9 @@ Important:
1111
1116
  ): Promise<void> {
1112
1117
  if (this.session?.payload.run_id === payload.run_id) {
1113
1118
  try {
1114
- await this.session.logWriter.flush(payload.run_id);
1119
+ await this.session.logWriter.flush(payload.run_id, {
1120
+ coalesce: true,
1121
+ });
1115
1122
  } catch (error) {
1116
1123
  this.logger.warn("Failed to flush session logs before completion", {
1117
1124
  taskId: payload.task_id,
@@ -1270,7 +1277,7 @@ Important:
1270
1277
  }
1271
1278
 
1272
1279
  try {
1273
- await this.session.logWriter.flush(payload.run_id);
1280
+ await this.session.logWriter.flush(payload.run_id, { coalesce: true });
1274
1281
  } catch (error) {
1275
1282
  this.logger.warn("Failed to flush logs before Slack relay", {
1276
1283
  taskId: payload.task_id,
@@ -1473,7 +1480,9 @@ Important:
1473
1480
  }
1474
1481
 
1475
1482
  try {
1476
- await this.session.logWriter.flush(this.session.payload.run_id);
1483
+ await this.session.logWriter.flush(this.session.payload.run_id, {
1484
+ coalesce: true,
1485
+ });
1477
1486
  } catch (error) {
1478
1487
  this.logger.error("Failed to flush session logs", error);
1479
1488
  }
@@ -159,6 +159,222 @@ describe("SessionLogWriter", () => {
159
159
  });
160
160
  });
161
161
 
162
+ describe("_doFlush does not prematurely coalesce", () => {
163
+ it("does not coalesce buffered chunks during a timed flush", async () => {
164
+ const sessionId = "s1";
165
+ logWriter.register(sessionId, { taskId: "t1", runId: sessionId });
166
+
167
+ // Buffer some chunks (no non-chunk event to trigger coalescing)
168
+ logWriter.appendRawLine(
169
+ sessionId,
170
+ makeSessionUpdate("agent_message_chunk", {
171
+ content: { type: "text", text: "Hello " },
172
+ }),
173
+ );
174
+ logWriter.appendRawLine(
175
+ sessionId,
176
+ makeSessionUpdate("agent_message_chunk", {
177
+ content: { type: "text", text: "world" },
178
+ }),
179
+ );
180
+
181
+ // Flush without any non-chunk event arriving — simulates
182
+ // the 500ms debounce timer firing mid-stream
183
+ await logWriter.flush(sessionId);
184
+
185
+ // No entries should have been sent — chunks are still buffered
186
+ expect(mockAppendLog).not.toHaveBeenCalled();
187
+
188
+ // Now a non-chunk event arrives, triggering natural coalescing
189
+ logWriter.appendRawLine(
190
+ sessionId,
191
+ makeSessionUpdate("usage_update", { used: 100 }),
192
+ );
193
+
194
+ await logWriter.flush(sessionId);
195
+
196
+ expect(mockAppendLog).toHaveBeenCalledTimes(1);
197
+ const entries: StoredNotification[] = mockAppendLog.mock.calls[0][2];
198
+ expect(entries).toHaveLength(2); // coalesced agent_message + usage_update
199
+ const coalesced = entries[0].notification;
200
+ expect(coalesced.params?.update).toEqual({
201
+ sessionUpdate: "agent_message",
202
+ content: { type: "text", text: "Hello world" },
203
+ });
204
+ });
205
+ });
206
+
207
+ describe("flushAll coalesces on shutdown", () => {
208
+ it("coalesces remaining chunks before flushing", async () => {
209
+ const sessionId = "s1";
210
+ logWriter.register(sessionId, { taskId: "t1", runId: sessionId });
211
+
212
+ logWriter.appendRawLine(
213
+ sessionId,
214
+ makeSessionUpdate("agent_message_chunk", {
215
+ content: { type: "text", text: "partial response" },
216
+ }),
217
+ );
218
+
219
+ await logWriter.flushAll();
220
+
221
+ expect(mockAppendLog).toHaveBeenCalledTimes(1);
222
+ const entries: StoredNotification[] = mockAppendLog.mock.calls[0][2];
223
+ expect(entries).toHaveLength(1);
224
+ const coalesced = entries[0].notification;
225
+ expect(coalesced.params?.update).toEqual({
226
+ sessionUpdate: "agent_message",
227
+ content: { type: "text", text: "partial response" },
228
+ });
229
+ });
230
+ });
231
+
232
+ describe("flush with coalesce option", () => {
233
+ it("drains chunk buffer when coalesce is true", async () => {
234
+ const sessionId = "s1";
235
+ logWriter.register(sessionId, { taskId: "t1", runId: sessionId });
236
+
237
+ logWriter.appendRawLine(
238
+ sessionId,
239
+ makeSessionUpdate("agent_message_chunk", {
240
+ content: { type: "text", text: "complete text" },
241
+ }),
242
+ );
243
+
244
+ await logWriter.flush(sessionId, { coalesce: true });
245
+
246
+ expect(mockAppendLog).toHaveBeenCalledTimes(1);
247
+ const entries: StoredNotification[] = mockAppendLog.mock.calls[0][2];
248
+ const coalesced = entries[0].notification;
249
+ expect(coalesced.params?.update).toEqual({
250
+ sessionUpdate: "agent_message",
251
+ content: { type: "text", text: "complete text" },
252
+ });
253
+ });
254
+
255
+ it("does not coalesce when coalesce is false", async () => {
256
+ const sessionId = "s1";
257
+ logWriter.register(sessionId, { taskId: "t1", runId: sessionId });
258
+
259
+ logWriter.appendRawLine(
260
+ sessionId,
261
+ makeSessionUpdate("agent_message_chunk", {
262
+ content: { type: "text", text: "buffered" },
263
+ }),
264
+ );
265
+
266
+ await logWriter.flush(sessionId, { coalesce: false });
267
+
268
+ expect(mockAppendLog).not.toHaveBeenCalled();
269
+ });
270
+ });
271
+
272
+ describe("direct agent_message supersedes chunks", () => {
273
+ it("discards buffered chunks when a direct agent_message arrives", async () => {
274
+ const sessionId = "s1";
275
+ logWriter.register(sessionId, { taskId: "t1", runId: sessionId });
276
+
277
+ // Buffer partial chunks
278
+ logWriter.appendRawLine(
279
+ sessionId,
280
+ makeSessionUpdate("agent_message_chunk", {
281
+ content: { type: "text", text: "partial " },
282
+ }),
283
+ );
284
+ logWriter.appendRawLine(
285
+ sessionId,
286
+ makeSessionUpdate("agent_message_chunk", {
287
+ content: { type: "text", text: "text" },
288
+ }),
289
+ );
290
+
291
+ // Direct agent_message arrives — authoritative full text
292
+ logWriter.appendRawLine(
293
+ sessionId,
294
+ makeSessionUpdate("agent_message", {
295
+ content: { type: "text", text: "complete full response" },
296
+ }),
297
+ );
298
+
299
+ await logWriter.flush(sessionId);
300
+
301
+ expect(mockAppendLog).toHaveBeenCalledTimes(1);
302
+ const entries: StoredNotification[] = mockAppendLog.mock.calls[0][2];
303
+ // Only the direct agent_message — no coalesced partial entry
304
+ expect(entries).toHaveLength(1);
305
+ const coalesced = entries[0].notification;
306
+ expect(coalesced.params?.update).toEqual({
307
+ sessionUpdate: "agent_message",
308
+ content: { type: "text", text: "complete full response" },
309
+ });
310
+ expect(logWriter.getLastAgentMessage(sessionId)).toBe(
311
+ "complete full response",
312
+ );
313
+ });
314
+
315
+ it("is additive with earlier coalesced text in multi-message turns", async () => {
316
+ const sessionId = "s1";
317
+ logWriter.register(sessionId, { taskId: "t1", runId: sessionId });
318
+
319
+ // First assistant message: chunks coalesced by a tool_call event
320
+ logWriter.appendRawLine(
321
+ sessionId,
322
+ makeSessionUpdate("agent_message_chunk", {
323
+ content: { type: "text", text: "first message" },
324
+ }),
325
+ );
326
+ logWriter.appendRawLine(
327
+ sessionId,
328
+ makeSessionUpdate("tool_call", { toolCallId: "tc1" }),
329
+ );
330
+ // "first message" is now coalesced into currentTurnMessages
331
+
332
+ // Second assistant message arrives as direct agent_message
333
+ // (e.g., after tool result, no active chunk buffer)
334
+ logWriter.appendRawLine(
335
+ sessionId,
336
+ makeSessionUpdate("agent_message", {
337
+ content: { type: "text", text: "second message" },
338
+ }),
339
+ );
340
+
341
+ const response = logWriter.getFullAgentResponse(sessionId);
342
+ // Both messages are preserved — direct message is additive
343
+ expect(response).toBe("first message\n\nsecond message");
344
+ });
345
+
346
+ it("persisted log does not contain stale entries when chunks are superseded", async () => {
347
+ const sessionId = "s1";
348
+ logWriter.register(sessionId, { taskId: "t1", runId: sessionId });
349
+
350
+ // Chunks buffered, then direct agent_message supersedes before coalescing
351
+ logWriter.appendRawLine(
352
+ sessionId,
353
+ makeSessionUpdate("agent_message_chunk", {
354
+ content: { type: "text", text: "partial" },
355
+ }),
356
+ );
357
+ logWriter.appendRawLine(
358
+ sessionId,
359
+ makeSessionUpdate("agent_message", {
360
+ content: { type: "text", text: "complete" },
361
+ }),
362
+ );
363
+
364
+ await logWriter.flush(sessionId);
365
+
366
+ expect(mockAppendLog).toHaveBeenCalledTimes(1);
367
+ const entries: StoredNotification[] = mockAppendLog.mock.calls[0][2];
368
+ // Only the direct agent_message — no coalesced partial entry
369
+ expect(entries).toHaveLength(1);
370
+ const persisted = entries[0].notification;
371
+ expect(persisted.params?.update).toEqual({
372
+ sessionUpdate: "agent_message",
373
+ content: { type: "text", text: "complete" },
374
+ });
375
+ });
376
+ });
377
+
162
378
  describe("register", () => {
163
379
  it("does not re-register existing sessions", () => {
164
380
  const sessionId = "s1";
@@ -54,9 +54,12 @@ export class SessionLogWriter {
54
54
  }
55
55
 
56
56
  async flushAll(): Promise<void> {
57
- const sessionIds = [...this.sessions.keys()];
57
+ // Coalesce any in-progress chunk buffers before the final flush
58
+ // During normal operation, chunks are coalesced when the next non-chunk
59
+ // event arrives, but on shutdown there may be no subsequent event
58
60
  const flushPromises: Promise<void>[] = [];
59
- for (const sessionId of sessionIds) {
61
+ for (const [sessionId, session] of this.sessions) {
62
+ this.emitCoalescedMessage(sessionId, session);
60
63
  flushPromises.push(this.flush(sessionId));
61
64
  }
62
65
  await Promise.all(flushPromises);
@@ -123,8 +126,14 @@ export class SessionLogWriter {
123
126
  return;
124
127
  }
125
128
 
126
- // Non-chunk event: flush any buffered chunks first
127
- this.emitCoalescedMessage(sessionId, session);
129
+ // Non-chunk event: flush any buffered chunks first.
130
+ // If this is a direct agent_message AND there are buffered chunks,
131
+ // the direct message supersedes the partial chunks
132
+ if (this.isDirectAgentMessage(message) && session.chunkBuffer) {
133
+ session.chunkBuffer = undefined;
134
+ } else {
135
+ this.emitCoalescedMessage(sessionId, session);
136
+ }
128
137
 
129
138
  const nonChunkAgentText = this.extractAgentMessageText(message);
130
139
  if (nonChunkAgentText) {
@@ -155,7 +164,17 @@ export class SessionLogWriter {
155
164
  }
156
165
  }
157
166
 
158
- async flush(sessionId: string): Promise<void> {
167
+ async flush(
168
+ sessionId: string,
169
+ { coalesce = false }: { coalesce?: boolean } = {},
170
+ ): Promise<void> {
171
+ if (coalesce) {
172
+ const session = this.sessions.get(sessionId);
173
+ if (session) {
174
+ this.emitCoalescedMessage(sessionId, session);
175
+ }
176
+ }
177
+
159
178
  // Serialize flushes per session
160
179
  const prev = this.flushQueues.get(sessionId) ?? Promise.resolve();
161
180
  const next = prev.catch(() => {}).then(() => this._doFlush(sessionId));
@@ -175,9 +194,6 @@ export class SessionLogWriter {
175
194
  return;
176
195
  }
177
196
 
178
- // Emit any buffered chunks before flushing
179
- this.emitCoalescedMessage(sessionId, session);
180
-
181
197
  const pending = this.pendingEntries.get(sessionId);
182
198
  if (!this.posthogAPI || !pending?.length) {
183
199
  return;
@@ -231,11 +247,21 @@ export class SessionLogWriter {
231
247
  }
232
248
  }
233
249
 
234
- private isAgentMessageChunk(message: Record<string, unknown>): boolean {
235
- if (message.method !== "session/update") return false;
250
+ private getSessionUpdateType(
251
+ message: Record<string, unknown>,
252
+ ): string | undefined {
253
+ if (message.method !== "session/update") return undefined;
236
254
  const params = message.params as Record<string, unknown> | undefined;
237
255
  const update = params?.update as Record<string, unknown> | undefined;
238
- return update?.sessionUpdate === "agent_message_chunk";
256
+ return update?.sessionUpdate as string | undefined;
257
+ }
258
+
259
+ private isDirectAgentMessage(message: Record<string, unknown>): boolean {
260
+ return this.getSessionUpdateType(message) === "agent_message";
261
+ }
262
+
263
+ private isAgentMessageChunk(message: Record<string, unknown>): boolean {
264
+ return this.getSessionUpdateType(message) === "agent_message_chunk";
239
265
  }
240
266
 
241
267
  private extractChunkText(message: Record<string, unknown>): string {
@@ -290,6 +316,17 @@ export class SessionLogWriter {
290
316
  getFullAgentResponse(sessionId: string): string | undefined {
291
317
  const session = this.sessions.get(sessionId);
292
318
  if (!session || session.currentTurnMessages.length === 0) return undefined;
319
+
320
+ if (session.chunkBuffer) {
321
+ this.logger.warn(
322
+ "getFullAgentResponse called with non-empty chunk buffer",
323
+ {
324
+ sessionId,
325
+ bufferedLength: session.chunkBuffer.text.length,
326
+ },
327
+ );
328
+ }
329
+
293
330
  return session.currentTurnMessages.join("\n\n");
294
331
  }
295
332
 
package/src/types.ts CHANGED
@@ -126,7 +126,8 @@ export type OnLogCallback = (
126
126
 
127
127
  export interface PostHogAPIConfig {
128
128
  apiUrl: string;
129
- getApiKey: () => string;
129
+ getApiKey: () => string | Promise<string>;
130
+ refreshApiKey?: () => string | Promise<string>;
130
131
  projectId: number;
131
132
  userAgent?: string;
132
133
  }