@posthog/agent 2.0.1 → 2.0.3

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.0.1",
3
+ "version": "2.0.3",
4
4
  "repository": "https://github.com/PostHog/twig",
5
5
  "description": "TypeScript agent framework wrapping Claude Agent SDK with Git-based task execution for PostHog",
6
6
  "exports": {
@@ -46,7 +46,7 @@
46
46
  }
47
47
  },
48
48
  "bin": {
49
- "agent-server": "./dist/server/bin.js"
49
+ "agent-server": "./dist/server/bin.cjs"
50
50
  },
51
51
  "type": "module",
52
52
  "keywords": [
@@ -71,7 +71,9 @@
71
71
  "tsup": "^8.5.1",
72
72
  "tsx": "^4.20.6",
73
73
  "typescript": "^5.5.0",
74
- "vitest": "^2.1.8"
74
+ "vitest": "^2.1.8",
75
+ "@posthog/shared": "1.0.0",
76
+ "@twig/git": "1.0.0"
75
77
  },
76
78
  "dependencies": {
77
79
  "@opentelemetry/api-logs": "^0.208.0",
@@ -93,9 +95,7 @@
93
95
  "tar": "^7.5.0",
94
96
  "uuid": "13.0.0",
95
97
  "yoga-wasm-web": "^0.3.3",
96
- "zod": "^3.24.1",
97
- "@posthog/shared": "1.0.0",
98
- "@twig/git": "1.0.0"
98
+ "zod": "^3.24.1"
99
99
  },
100
100
  "files": [
101
101
  "dist/**/*",
@@ -5,6 +5,7 @@ import type { PostHogAPIClient } from "../posthog-api.js";
5
5
  import { ResumeSaga } from "./resume-saga.js";
6
6
  import {
7
7
  createAgentChunk,
8
+ createAgentMessage,
8
9
  createArchiveBuffer,
9
10
  createMockApiClient,
10
11
  createMockLogger,
@@ -141,6 +142,149 @@ describe("ResumeSaga", () => {
141
142
  expect(content).toEqual({ type: "text", text: "Hello world!" });
142
143
  });
143
144
 
145
+ it("rebuilds from coalesced agent_message events", async () => {
146
+ (mockApiClient.getTaskRun as ReturnType<typeof vi.fn>).mockResolvedValue(
147
+ createTaskRun(),
148
+ );
149
+ (
150
+ mockApiClient.fetchTaskRunLogs as ReturnType<typeof vi.fn>
151
+ ).mockResolvedValue([
152
+ createUserMessage("Hello"),
153
+ createAgentMessage("Hi there! Let me help."),
154
+ createUserMessage("Thanks"),
155
+ createAgentMessage("Sure thing."),
156
+ ]);
157
+
158
+ const saga = new ResumeSaga(mockLogger);
159
+ const result = await saga.run({
160
+ taskId: "task-1",
161
+ runId: "run-1",
162
+ repositoryPath: repo.path,
163
+ apiClient: mockApiClient,
164
+ });
165
+
166
+ expect(result.success).toBe(true);
167
+ if (!result.success) return;
168
+
169
+ expect(result.data.conversation).toHaveLength(4);
170
+ expect(result.data.conversation[0].role).toBe("user");
171
+ expect(result.data.conversation[1].role).toBe("assistant");
172
+ expect(result.data.conversation[1].content[0]).toEqual({
173
+ type: "text",
174
+ text: "Hi there! Let me help.",
175
+ });
176
+ expect(result.data.conversation[3].content[0]).toEqual({
177
+ type: "text",
178
+ text: "Sure thing.",
179
+ });
180
+ });
181
+
182
+ it("merges multiple agent_message events within a single turn", async () => {
183
+ (mockApiClient.getTaskRun as ReturnType<typeof vi.fn>).mockResolvedValue(
184
+ createTaskRun(),
185
+ );
186
+ (
187
+ mockApiClient.fetchTaskRunLogs as ReturnType<typeof vi.fn>
188
+ ).mockResolvedValue([
189
+ createUserMessage("Fix the bug"),
190
+ createAgentMessage("I'll look into this."),
191
+ createToolCall("call-1", "ReadFile", { path: "/bug.ts" }),
192
+ createToolResult("call-1", "buggy code"),
193
+ createAgentMessage(" Here's the fix."),
194
+ createToolCall("call-2", "Edit", { path: "/bug.ts" }),
195
+ createToolResult("call-2", "done"),
196
+ createAgentMessage(" All fixed now."),
197
+ ]);
198
+
199
+ const saga = new ResumeSaga(mockLogger);
200
+ const result = await saga.run({
201
+ taskId: "task-1",
202
+ runId: "run-1",
203
+ repositoryPath: repo.path,
204
+ apiClient: mockApiClient,
205
+ });
206
+
207
+ expect(result.success).toBe(true);
208
+ if (!result.success) return;
209
+
210
+ expect(result.data.conversation).toHaveLength(2);
211
+ const assistantTurn = result.data.conversation[1];
212
+ expect(assistantTurn.role).toBe("assistant");
213
+ // All text segments should merge into one block
214
+ expect(assistantTurn.content).toHaveLength(1);
215
+ expect(assistantTurn.content[0]).toEqual({
216
+ type: "text",
217
+ text: "I'll look into this. Here's the fix. All fixed now.",
218
+ });
219
+ expect(assistantTurn.toolCalls).toHaveLength(2);
220
+ });
221
+
222
+ it("produces same result from chunks and coalesced messages", async () => {
223
+ const chunkEntries = [
224
+ createUserMessage("Hello"),
225
+ createAgentChunk("First "),
226
+ createAgentChunk("part."),
227
+ createToolCall("call-1", "Read", { path: "/a" }),
228
+ createToolResult("call-1", "content"),
229
+ createAgentChunk("Second "),
230
+ createAgentChunk("part."),
231
+ ];
232
+
233
+ const coalescedEntries = [
234
+ createUserMessage("Hello"),
235
+ createAgentMessage("First part."),
236
+ createToolCall("call-1", "Read", { path: "/a" }),
237
+ createToolResult("call-1", "content"),
238
+ createAgentMessage("Second part."),
239
+ ];
240
+
241
+ // Run with chunks (old format)
242
+ (mockApiClient.getTaskRun as ReturnType<typeof vi.fn>).mockResolvedValue(
243
+ createTaskRun(),
244
+ );
245
+ (
246
+ mockApiClient.fetchTaskRunLogs as ReturnType<typeof vi.fn>
247
+ ).mockResolvedValue(chunkEntries);
248
+
249
+ const saga1 = new ResumeSaga(mockLogger);
250
+ const result1 = await saga1.run({
251
+ taskId: "task-1",
252
+ runId: "run-1",
253
+ repositoryPath: repo.path,
254
+ apiClient: mockApiClient,
255
+ });
256
+
257
+ // Run with coalesced (new format)
258
+ (
259
+ mockApiClient.fetchTaskRunLogs as ReturnType<typeof vi.fn>
260
+ ).mockResolvedValue(coalescedEntries);
261
+
262
+ const saga2 = new ResumeSaga(mockLogger);
263
+ const result2 = await saga2.run({
264
+ taskId: "task-1",
265
+ runId: "run-1",
266
+ repositoryPath: repo.path,
267
+ apiClient: mockApiClient,
268
+ });
269
+
270
+ expect(result1.success).toBe(true);
271
+ expect(result2.success).toBe(true);
272
+ if (!result1.success || !result2.success) return;
273
+
274
+ // Conversation structure should be identical
275
+ expect(result1.data.conversation.length).toBe(
276
+ result2.data.conversation.length,
277
+ );
278
+ for (let i = 0; i < result1.data.conversation.length; i++) {
279
+ expect(result1.data.conversation[i].role).toBe(
280
+ result2.data.conversation[i].role,
281
+ );
282
+ expect(result1.data.conversation[i].content).toEqual(
283
+ result2.data.conversation[i].content,
284
+ );
285
+ }
286
+ });
287
+
144
288
  it("tracks tool calls with results", async () => {
145
289
  (mockApiClient.getTaskRun as ReturnType<typeof vi.fn>).mockResolvedValue(
146
290
  createTaskRun(),
@@ -244,7 +244,30 @@ export class ResumeSaga extends Saga<ResumeInput, ResumeOutput> {
244
244
  break;
245
245
  }
246
246
 
247
+ case "agent_message": {
248
+ const content = update.content as ContentBlock | undefined;
249
+ if (content) {
250
+ if (
251
+ content.type === "text" &&
252
+ currentAssistantContent.length > 0 &&
253
+ currentAssistantContent[currentAssistantContent.length - 1]
254
+ .type === "text"
255
+ ) {
256
+ const lastBlock = currentAssistantContent[
257
+ currentAssistantContent.length - 1
258
+ ] as { type: "text"; text: string };
259
+ lastBlock.text += (
260
+ content as { type: "text"; text: string }
261
+ ).text;
262
+ } else {
263
+ currentAssistantContent.push(content);
264
+ }
265
+ }
266
+ break;
267
+ }
268
+
247
269
  case "agent_message_chunk": {
270
+ // Backward compatibility with older logs that have individual chunks
248
271
  const content = update.content as ContentBlock | undefined;
249
272
  if (content) {
250
273
  if (
@@ -205,6 +205,15 @@ export function createAgentChunk(text: string): StoredNotification {
205
205
  });
206
206
  }
207
207
 
208
+ export function createAgentMessage(text: string): StoredNotification {
209
+ return createNotification("session/update", {
210
+ update: {
211
+ sessionUpdate: "agent_message",
212
+ content: { type: "text", text },
213
+ },
214
+ });
215
+ }
216
+
208
217
  export function createToolCall(
209
218
  toolCallId: string,
210
219
  toolName: string,
@@ -16,9 +16,15 @@ export interface SessionLogWriterOptions {
16
16
  logger?: Logger;
17
17
  }
18
18
 
19
+ interface ChunkBuffer {
20
+ text: string;
21
+ firstTimestamp: string;
22
+ }
23
+
19
24
  interface SessionState {
20
25
  context: SessionContext;
21
26
  otelWriter?: OtelLogWriter;
27
+ chunkBuffer?: ChunkBuffer;
22
28
  }
23
29
 
24
30
  export class SessionLogWriter {
@@ -75,9 +81,28 @@ export class SessionLogWriter {
75
81
 
76
82
  try {
77
83
  const message = JSON.parse(line);
84
+ const timestamp = new Date().toISOString();
85
+
86
+ // Check if this is an agent_message_chunk event
87
+ if (this.isAgentMessageChunk(message)) {
88
+ const text = this.extractChunkText(message);
89
+ if (text) {
90
+ if (!session.chunkBuffer) {
91
+ session.chunkBuffer = { text, firstTimestamp: timestamp };
92
+ } else {
93
+ session.chunkBuffer.text += text;
94
+ }
95
+ }
96
+ // Don't emit chunk events
97
+ return;
98
+ }
99
+
100
+ // Non-chunk event: flush any buffered chunks first
101
+ this.emitCoalescedMessage(sessionId, session);
102
+
78
103
  const entry: StoredNotification = {
79
104
  type: "notification",
80
- timestamp: new Date().toISOString(),
105
+ timestamp,
81
106
  notification: message,
82
107
  };
83
108
 
@@ -103,6 +128,9 @@ export class SessionLogWriter {
103
128
  const session = this.sessions.get(sessionId);
104
129
  if (!session) return;
105
130
 
131
+ // Emit any buffered chunks before flushing
132
+ this.emitCoalescedMessage(sessionId, session);
133
+
106
134
  if (session.otelWriter) {
107
135
  await session.otelWriter.flush();
108
136
  }
@@ -128,6 +156,58 @@ export class SessionLogWriter {
128
156
  }
129
157
  }
130
158
 
159
+ private isAgentMessageChunk(message: Record<string, unknown>): boolean {
160
+ if (message.method !== "session/update") return false;
161
+ const params = message.params as Record<string, unknown> | undefined;
162
+ const update = params?.update as Record<string, unknown> | undefined;
163
+ return update?.sessionUpdate === "agent_message_chunk";
164
+ }
165
+
166
+ private extractChunkText(message: Record<string, unknown>): string {
167
+ const params = message.params as Record<string, unknown> | undefined;
168
+ const update = params?.update as Record<string, unknown> | undefined;
169
+ const content = update?.content as
170
+ | { type: string; text?: string }
171
+ | undefined;
172
+ if (content?.type === "text" && content.text) {
173
+ return content.text;
174
+ }
175
+ return "";
176
+ }
177
+
178
+ private emitCoalescedMessage(sessionId: string, session: SessionState): void {
179
+ if (!session.chunkBuffer) return;
180
+
181
+ const { text, firstTimestamp } = session.chunkBuffer;
182
+ session.chunkBuffer = undefined;
183
+
184
+ const entry: StoredNotification = {
185
+ type: "notification",
186
+ timestamp: firstTimestamp,
187
+ notification: {
188
+ jsonrpc: "2.0",
189
+ method: "session/update",
190
+ params: {
191
+ update: {
192
+ sessionUpdate: "agent_message",
193
+ content: { type: "text", text },
194
+ },
195
+ },
196
+ },
197
+ };
198
+
199
+ if (session.otelWriter) {
200
+ session.otelWriter.emit({ notification: entry });
201
+ }
202
+
203
+ if (this.posthogAPI) {
204
+ const pending = this.pendingEntries.get(sessionId) ?? [];
205
+ pending.push(entry);
206
+ this.pendingEntries.set(sessionId, pending);
207
+ this.scheduleFlush(sessionId);
208
+ }
209
+ }
210
+
131
211
  private scheduleFlush(sessionId: string): void {
132
212
  const existing = this.flushTimeouts.get(sessionId);
133
213
  if (existing) clearTimeout(existing);
@@ -1 +0,0 @@
1
- #!/usr/bin/env node