@posthog/agent 2.3.110 → 2.3.125

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@posthog/agent",
3
- "version": "2.3.110",
3
+ "version": "2.3.125",
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
@@ -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
  }
@@ -508,6 +508,10 @@ export class AgentServer {
508
508
  }),
509
509
  });
510
510
 
511
+ this.logger.info("User message completed", {
512
+ stopReason: result.stopReason,
513
+ });
514
+
511
515
  this.broadcastTurnComplete(result.stopReason);
512
516
 
513
517
  if (result.stopReason === "end_turn") {
@@ -523,7 +527,9 @@ export class AgentServer {
523
527
  // against async log persistence to object storage.
524
528
  let assistantMessage: string | undefined;
525
529
  try {
526
- await this.session.logWriter.flush(this.session.payload.run_id);
530
+ await this.session.logWriter.flush(this.session.payload.run_id, {
531
+ coalesce: true,
532
+ });
527
533
  assistantMessage = this.session.logWriter.getFullAgentResponse(
528
534
  this.session.payload.run_id,
529
535
  );
@@ -747,6 +753,9 @@ export class AgentServer {
747
753
  });
748
754
 
749
755
  this.logger.info("Session initialized successfully");
756
+ this.logger.info(
757
+ `Agent version: ${this.config.version ?? packageJson.version}`,
758
+ );
750
759
 
751
760
  // Signal in_progress so the UI can start polling for updates
752
761
  this.posthogAPI
@@ -1111,7 +1120,9 @@ Important:
1111
1120
  ): Promise<void> {
1112
1121
  if (this.session?.payload.run_id === payload.run_id) {
1113
1122
  try {
1114
- await this.session.logWriter.flush(payload.run_id);
1123
+ await this.session.logWriter.flush(payload.run_id, {
1124
+ coalesce: true,
1125
+ });
1115
1126
  } catch (error) {
1116
1127
  this.logger.warn("Failed to flush session logs before completion", {
1117
1128
  taskId: payload.task_id,
@@ -1270,7 +1281,7 @@ Important:
1270
1281
  }
1271
1282
 
1272
1283
  try {
1273
- await this.session.logWriter.flush(payload.run_id);
1284
+ await this.session.logWriter.flush(payload.run_id, { coalesce: true });
1274
1285
  } catch (error) {
1275
1286
  this.logger.warn("Failed to flush logs before Slack relay", {
1276
1287
  taskId: payload.task_id,
@@ -1473,7 +1484,9 @@ Important:
1473
1484
  }
1474
1485
 
1475
1486
  try {
1476
- await this.session.logWriter.flush(this.session.payload.run_id);
1487
+ await this.session.logWriter.flush(this.session.payload.run_id, {
1488
+ coalesce: true,
1489
+ });
1477
1490
  } catch (error) {
1478
1491
  this.logger.error("Failed to flush session logs", error);
1479
1492
  }
@@ -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