@posthog/agent 2.0.2 → 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/dist/{agent-DBQY1BfC.d.ts → agent-kRbaLUfe.d.ts} +3 -0
- package/dist/agent.d.ts +1 -1
- package/dist/agent.js +60 -3
- package/dist/agent.js.map +1 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.js +72 -3
- package/dist/index.js.map +1 -1
- package/dist/server/agent-server.js +60 -3
- package/dist/server/agent-server.js.map +1 -1
- package/dist/server/{bin.js → bin.cjs} +283 -242
- package/dist/server/bin.cjs.map +1 -0
- package/package.json +4 -4
- package/src/sagas/resume-saga.test.ts +144 -0
- package/src/sagas/resume-saga.ts +23 -0
- package/src/sagas/test-fixtures.ts +9 -0
- package/src/session-log-writer.ts +81 -1
- package/dist/server/bin.d.ts +0 -1
- package/dist/server/bin.js.map +0 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@posthog/agent",
|
|
3
|
-
"version": "2.0.
|
|
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.
|
|
49
|
+
"agent-server": "./dist/server/bin.cjs"
|
|
50
50
|
},
|
|
51
51
|
"type": "module",
|
|
52
52
|
"keywords": [
|
|
@@ -72,8 +72,8 @@
|
|
|
72
72
|
"tsx": "^4.20.6",
|
|
73
73
|
"typescript": "^5.5.0",
|
|
74
74
|
"vitest": "^2.1.8",
|
|
75
|
-
"@
|
|
76
|
-
"@
|
|
75
|
+
"@posthog/shared": "1.0.0",
|
|
76
|
+
"@twig/git": "1.0.0"
|
|
77
77
|
},
|
|
78
78
|
"dependencies": {
|
|
79
79
|
"@opentelemetry/api-logs": "^0.208.0",
|
|
@@ -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(),
|
package/src/sagas/resume-saga.ts
CHANGED
|
@@ -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
|
|
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);
|
package/dist/server/bin.d.ts
DELETED
|
@@ -1 +0,0 @@
|
|
|
1
|
-
#!/usr/bin/env node
|