@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/dist/agent.js +34 -10
- package/dist/agent.js.map +1 -1
- package/dist/posthog-api.js +1 -1
- package/dist/posthog-api.js.map +1 -1
- package/dist/server/agent-server.js +49 -13
- package/dist/server/agent-server.js.map +1 -1
- package/dist/server/bin.cjs +49 -13
- package/dist/server/bin.cjs.map +1 -1
- package/package.json +1 -1
- package/src/agent.ts +1 -1
- package/src/server/agent-server.ts +17 -4
- package/src/session-log-writer.test.ts +216 -0
- package/src/session-log-writer.ts +48 -11
package/package.json
CHANGED
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
|
-
|
|
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
|
|
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
|
|
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(
|
|
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
|
|
235
|
-
|
|
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
|
|
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
|
|