@posthog/agent 2.3.209 → 2.3.216

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.209",
3
+ "version": "2.3.216",
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": {
@@ -252,6 +252,30 @@ describe("AgentServer HTTP Mode", () => {
252
252
  const body = await response.json();
253
253
  expect(body.error).toBe("No active session for this run");
254
254
  });
255
+
256
+ it("accepts structured user_message content", async () => {
257
+ await createServer().start();
258
+ const token = createToken({ run_id: "different-run-id" });
259
+
260
+ const response = await fetch(`http://localhost:${port}/command`, {
261
+ method: "POST",
262
+ headers: {
263
+ Authorization: `Bearer ${token}`,
264
+ "Content-Type": "application/json",
265
+ },
266
+ body: JSON.stringify({
267
+ jsonrpc: "2.0",
268
+ method: "user_message",
269
+ params: {
270
+ content: [{ type: "text", text: "test" }],
271
+ },
272
+ }),
273
+ });
274
+
275
+ expect(response.status).toBe(400);
276
+ const body = await response.json();
277
+ expect(body.error).toBe("No active session for this run");
278
+ });
255
279
  });
256
280
 
257
281
  describe("404 handling", () => {
@@ -1,9 +1,11 @@
1
+ import type { ContentBlock } from "@agentclientprotocol/sdk";
1
2
  import {
2
3
  ClientSideConnection,
3
4
  ndJsonStream,
4
5
  PROTOCOL_VERSION,
5
6
  } from "@agentclientprotocol/sdk";
6
7
  import { type ServerType, serve } from "@hono/node-server";
8
+ import { getCurrentBranch } from "@posthog/git/queries";
7
9
  import { Hono } from "hono";
8
10
  import packageJson from "../../package.json" with { type: "json" };
9
11
  import { POSTHOG_NOTIFICATIONS } from "../acp-extensions";
@@ -30,6 +32,11 @@ import type {
30
32
  import { AsyncMutex } from "../utils/async-mutex";
31
33
  import { getLlmGatewayUrl } from "../utils/gateway";
32
34
  import { Logger } from "../utils/logger";
35
+ import {
36
+ deserializeCloudPrompt,
37
+ normalizeCloudPromptContent,
38
+ promptBlocksToText,
39
+ } from "./cloud-prompt";
33
40
  import { type JwtPayload, JwtValidationError, validateJwt } from "./jwt";
34
41
  import { jsonRpcRequestSchema, validateCommandParams } from "./schemas";
35
42
  import type { AgentServerConfig } from "./types";
@@ -161,6 +168,7 @@ export class AgentServer {
161
168
  private posthogAPI: PostHogAPIClient;
162
169
  private questionRelayedToSlack = false;
163
170
  private detectedPrUrl: string | null = null;
171
+ private lastReportedBranch: string | null = null;
164
172
  private resumeState: ResumeState | null = null;
165
173
  // Guards against concurrent session initialization. autoInitializeSession() and
166
174
  // the GET /events SSE handler can both call initializeSession() — the SSE connection
@@ -487,17 +495,20 @@ export class AgentServer {
487
495
  switch (method) {
488
496
  case POSTHOG_NOTIFICATIONS.USER_MESSAGE:
489
497
  case "user_message": {
490
- const content = params.content as string;
498
+ const prompt = normalizeCloudPromptContent(
499
+ params.content as string | ContentBlock[],
500
+ );
501
+ const promptPreview = promptBlocksToText(prompt);
491
502
 
492
503
  this.logger.info(
493
- `Processing user message (detectedPrUrl=${this.detectedPrUrl ?? "none"}): ${content.substring(0, 100)}...`,
504
+ `Processing user message (detectedPrUrl=${this.detectedPrUrl ?? "none"}): ${promptPreview.substring(0, 100)}...`,
494
505
  );
495
506
 
496
507
  this.session.logWriter.resetTurnMessages(this.session.payload.run_id);
497
508
 
498
509
  const result = await this.session.clientConnection.prompt({
499
510
  sessionId: this.session.acpSessionId,
500
- prompt: [{ type: "text", text: content }],
511
+ prompt,
501
512
  ...(this.detectedPrUrl && {
502
513
  _meta: {
503
514
  prContext:
@@ -515,6 +526,10 @@ export class AgentServer {
515
526
  stopReason: result.stopReason,
516
527
  });
517
528
 
529
+ if (result.stopReason === "end_turn") {
530
+ void this.syncCloudBranchMetadata(this.session.payload);
531
+ }
532
+
518
533
  this.broadcastTurnComplete(result.stopReason);
519
534
 
520
535
  if (result.stopReason === "end_turn") {
@@ -837,30 +852,43 @@ export class AgentServer {
837
852
  const initialPromptOverride = taskRun
838
853
  ? this.getInitialPromptOverride(taskRun)
839
854
  : null;
840
- const initialPrompt = initialPromptOverride ?? task.description;
855
+ const pendingUserPrompt = this.getPendingUserPrompt(taskRun);
856
+ let initialPrompt: ContentBlock[] = [];
857
+ if (pendingUserPrompt?.length) {
858
+ initialPrompt = pendingUserPrompt;
859
+ } else if (initialPromptOverride) {
860
+ initialPrompt = [{ type: "text", text: initialPromptOverride }];
861
+ } else if (task.description) {
862
+ initialPrompt = [{ type: "text", text: task.description }];
863
+ }
841
864
 
842
- if (!initialPrompt) {
865
+ if (initialPrompt.length === 0) {
843
866
  this.logger.warn("Task has no description, skipping initial message");
844
867
  return;
845
868
  }
846
869
 
847
870
  this.logger.info("Sending initial task message", {
848
871
  taskId: payload.task_id,
849
- descriptionLength: initialPrompt.length,
872
+ descriptionLength: promptBlocksToText(initialPrompt).length,
850
873
  usedInitialPromptOverride: !!initialPromptOverride,
874
+ usedPendingUserMessage: !!pendingUserPrompt?.length,
851
875
  });
852
876
 
853
877
  this.session.logWriter.resetTurnMessages(payload.run_id);
854
878
 
855
879
  const result = await this.session.clientConnection.prompt({
856
880
  sessionId: this.session.acpSessionId,
857
- prompt: [{ type: "text", text: initialPrompt }],
881
+ prompt: initialPrompt,
858
882
  });
859
883
 
860
884
  this.logger.info("Initial task message completed", {
861
885
  stopReason: result.stopReason,
862
886
  });
863
887
 
888
+ if (result.stopReason === "end_turn") {
889
+ void this.syncCloudBranchMetadata(payload);
890
+ }
891
+
864
892
  this.broadcastTurnComplete(result.stopReason);
865
893
 
866
894
  if (result.stopReason === "end_turn") {
@@ -886,38 +914,49 @@ export class AgentServer {
886
914
  this.resumeState.conversation,
887
915
  );
888
916
 
889
- // Read the pending user message from TaskRun state (set by the workflow
917
+ // Read the pending user prompt from TaskRun state (set by the workflow
890
918
  // when the user sends a follow-up message that triggers a resume).
891
- const pendingUserMessage = this.getPendingUserMessage(taskRun);
919
+ const pendingUserPrompt = this.getPendingUserPrompt(taskRun);
892
920
 
893
921
  const sandboxContext = this.resumeState.snapshotApplied
894
922
  ? `The workspace environment (all files, packages, and code changes) has been fully restored from where you left off.`
895
923
  : `The workspace files from the previous session were not restored (the file snapshot may have expired), so you are starting with a fresh environment. Your conversation history is fully preserved below.`;
896
924
 
897
- let resumePrompt: string;
898
- if (pendingUserMessage) {
899
- // Include the pending message as the user's new question so the agent
900
- // responds to it directly instead of the generic resume context.
901
- resumePrompt =
902
- `You are resuming a previous conversation. ${sandboxContext}\n\n` +
903
- `Here is the conversation history from the previous session:\n\n` +
904
- `${conversationSummary}\n\n` +
905
- `The user has sent a new message:\n\n` +
906
- `${pendingUserMessage}\n\n` +
907
- `Respond to the user's new message above. You have full context from the previous session.`;
925
+ let resumePromptBlocks: ContentBlock[];
926
+ if (pendingUserPrompt?.length) {
927
+ resumePromptBlocks = [
928
+ {
929
+ type: "text",
930
+ text:
931
+ `You are resuming a previous conversation. ${sandboxContext}\n\n` +
932
+ `Here is the conversation history from the previous session:\n\n` +
933
+ `${conversationSummary}\n\n` +
934
+ `The user has sent a new message:\n\n`,
935
+ },
936
+ ...pendingUserPrompt,
937
+ {
938
+ type: "text",
939
+ text: "\n\nRespond to the user's new message above. You have full context from the previous session.",
940
+ },
941
+ ];
908
942
  } else {
909
- resumePrompt =
910
- `You are resuming a previous conversation. ${sandboxContext}\n\n` +
911
- `Here is the conversation history from the previous session:\n\n` +
912
- `${conversationSummary}\n\n` +
913
- `Continue from where you left off. The user is waiting for your response.`;
943
+ resumePromptBlocks = [
944
+ {
945
+ type: "text",
946
+ text:
947
+ `You are resuming a previous conversation. ${sandboxContext}\n\n` +
948
+ `Here is the conversation history from the previous session:\n\n` +
949
+ `${conversationSummary}\n\n` +
950
+ `Continue from where you left off. The user is waiting for your response.`,
951
+ },
952
+ ];
914
953
  }
915
954
 
916
955
  this.logger.info("Sending resume message", {
917
956
  taskId: payload.task_id,
918
957
  conversationTurns: this.resumeState.conversation.length,
919
- promptLength: resumePrompt.length,
920
- hasPendingUserMessage: !!pendingUserMessage,
958
+ promptLength: promptBlocksToText(resumePromptBlocks).length,
959
+ hasPendingUserMessage: !!pendingUserPrompt?.length,
921
960
  snapshotApplied: this.resumeState.snapshotApplied,
922
961
  });
923
962
 
@@ -928,13 +967,17 @@ export class AgentServer {
928
967
 
929
968
  const result = await this.session.clientConnection.prompt({
930
969
  sessionId: this.session.acpSessionId,
931
- prompt: [{ type: "text", text: resumePrompt }],
970
+ prompt: resumePromptBlocks,
932
971
  });
933
972
 
934
973
  this.logger.info("Resume message completed", {
935
974
  stopReason: result.stopReason,
936
975
  });
937
976
 
977
+ if (result.stopReason === "end_turn") {
978
+ void this.syncCloudBranchMetadata(payload);
979
+ }
980
+
938
981
  this.broadcastTurnComplete(result.stopReason);
939
982
 
940
983
  if (result.stopReason === "end_turn") {
@@ -1013,7 +1056,7 @@ export class AgentServer {
1013
1056
  return trimmed.length > 0 ? trimmed : null;
1014
1057
  }
1015
1058
 
1016
- private getPendingUserMessage(taskRun: TaskRun | null): string | null {
1059
+ private getPendingUserPrompt(taskRun: TaskRun | null): ContentBlock[] | null {
1017
1060
  if (!taskRun) return null;
1018
1061
  const state = taskRun.state as Record<string, unknown> | undefined;
1019
1062
  const message = state?.pending_user_message;
@@ -1021,8 +1064,8 @@ export class AgentServer {
1021
1064
  return null;
1022
1065
  }
1023
1066
 
1024
- const trimmed = message.trim();
1025
- return trimmed.length > 0 ? trimmed : null;
1067
+ const prompt = deserializeCloudPrompt(message);
1068
+ return prompt.length > 0 ? prompt : null;
1026
1069
  }
1027
1070
 
1028
1071
  private getResumeRunId(taskRun: TaskRun | null): string | null {
@@ -1139,6 +1182,44 @@ ${attributionInstructions}
1139
1182
  `;
1140
1183
  }
1141
1184
 
1185
+ private async getCurrentGitBranch(): Promise<string | null> {
1186
+ if (!this.config.repositoryPath) {
1187
+ return null;
1188
+ }
1189
+
1190
+ try {
1191
+ return await getCurrentBranch(this.config.repositoryPath);
1192
+ } catch (error) {
1193
+ this.logger.warn("Failed to determine current git branch", {
1194
+ repositoryPath: this.config.repositoryPath,
1195
+ error,
1196
+ });
1197
+ return null;
1198
+ }
1199
+ }
1200
+
1201
+ private async syncCloudBranchMetadata(payload: JwtPayload): Promise<void> {
1202
+ const branchName = await this.getCurrentGitBranch();
1203
+ if (!branchName || branchName === this.lastReportedBranch) {
1204
+ return;
1205
+ }
1206
+
1207
+ try {
1208
+ await this.posthogAPI.updateTaskRun(payload.task_id, payload.run_id, {
1209
+ branch: branchName,
1210
+ output: { head_branch: branchName },
1211
+ });
1212
+ this.lastReportedBranch = branchName;
1213
+ } catch (error) {
1214
+ this.logger.warn("Failed to attach current branch to task run", {
1215
+ taskId: payload.task_id,
1216
+ runId: payload.run_id,
1217
+ branchName,
1218
+ error,
1219
+ });
1220
+ }
1221
+ }
1222
+
1142
1223
  private async signalTaskComplete(
1143
1224
  payload: JwtPayload,
1144
1225
  stopReason: string,
@@ -1527,6 +1608,7 @@ ${attributionInstructions}
1527
1608
  }
1528
1609
 
1529
1610
  this.pendingEvents = [];
1611
+ this.lastReportedBranch = null;
1530
1612
  this.session = null;
1531
1613
  }
1532
1614
 
@@ -0,0 +1,13 @@
1
+ import type { ContentBlock } from "@agentclientprotocol/sdk";
2
+ import { deserializeCloudPrompt, promptBlocksToText } from "@posthog/shared";
3
+
4
+ export { deserializeCloudPrompt, promptBlocksToText };
5
+
6
+ export function normalizeCloudPromptContent(
7
+ content: string | ContentBlock[],
8
+ ): ContentBlock[] {
9
+ if (typeof content === "string") {
10
+ return deserializeCloudPrompt(content);
11
+ }
12
+ return content;
13
+ }
@@ -371,6 +371,53 @@ describe("Question relay", () => {
371
371
  });
372
372
 
373
373
  describe("sendInitialTaskMessage prompt source", () => {
374
+ it("uses pending user prompt blocks when present", async () => {
375
+ vi.spyOn(server.posthogAPI, "getTask").mockResolvedValue({
376
+ id: "test-task-id",
377
+ title: "t",
378
+ description: "original task description",
379
+ } as unknown as Task);
380
+ vi.spyOn(server.posthogAPI, "getTaskRun").mockResolvedValue({
381
+ id: "test-run-id",
382
+ task: "test-task-id",
383
+ state: {
384
+ pending_user_message:
385
+ '__twig_cloud_prompt_v1__:{"blocks":[{"type":"text","text":"read this attachment"},{"type":"resource","resource":{"uri":"attachment://test.txt","text":"hello from file","mimeType":"text/plain"}}]}',
386
+ },
387
+ } as unknown as TaskRun);
388
+
389
+ const promptSpy = vi.fn().mockResolvedValue({ stopReason: "max_tokens" });
390
+ server.session = {
391
+ payload: TEST_PAYLOAD,
392
+ acpSessionId: "acp-session",
393
+ clientConnection: { prompt: promptSpy },
394
+ logWriter: {
395
+ flushAll: vi.fn().mockResolvedValue(undefined),
396
+ getFullAgentResponse: vi.fn().mockReturnValue(null),
397
+ resetTurnMessages: vi.fn(),
398
+ flush: vi.fn().mockResolvedValue(undefined),
399
+ isRegistered: vi.fn().mockReturnValue(true),
400
+ },
401
+ };
402
+
403
+ await server.sendInitialTaskMessage(TEST_PAYLOAD);
404
+
405
+ expect(promptSpy).toHaveBeenCalledWith({
406
+ sessionId: "acp-session",
407
+ prompt: [
408
+ { type: "text", text: "read this attachment" },
409
+ {
410
+ type: "resource",
411
+ resource: {
412
+ uri: "attachment://test.txt",
413
+ text: "hello from file",
414
+ mimeType: "text/plain",
415
+ },
416
+ },
417
+ ],
418
+ });
419
+ });
420
+
374
421
  it("uses run state initial_prompt_override when present", async () => {
375
422
  vi.spyOn(server.posthogAPI, "getTask").mockResolvedValue({
376
423
  id: "test-task-id",
@@ -1,5 +1,5 @@
1
1
  import { describe, expect, it } from "vitest";
2
- import { mcpServersSchema } from "./schemas";
2
+ import { mcpServersSchema, validateCommandParams } from "./schemas";
3
3
 
4
4
  describe("mcpServersSchema", () => {
5
5
  it("accepts a valid HTTP server", () => {
@@ -115,3 +115,21 @@ describe("mcpServersSchema", () => {
115
115
  expect(result.success).toBe(false);
116
116
  });
117
117
  });
118
+
119
+ describe("validateCommandParams", () => {
120
+ it("accepts structured user_message content arrays", () => {
121
+ const result = validateCommandParams("user_message", {
122
+ content: [{ type: "text", text: "hello" }],
123
+ });
124
+
125
+ expect(result.success).toBe(true);
126
+ });
127
+
128
+ it("rejects empty content array", () => {
129
+ const result = validateCommandParams("user_message", {
130
+ content: [],
131
+ });
132
+
133
+ expect(result.success).toBe(false);
134
+ });
135
+ });
@@ -42,7 +42,10 @@ export const jsonRpcRequestSchema = z.object({
42
42
  export type JsonRpcRequest = z.infer<typeof jsonRpcRequestSchema>;
43
43
 
44
44
  export const userMessageParamsSchema = z.object({
45
- content: z.string().min(1, "Content is required"),
45
+ content: z.union([
46
+ z.string().min(1, "Content is required"),
47
+ z.array(z.record(z.string(), z.unknown())).min(1, "Content is required"),
48
+ ]),
46
49
  });
47
50
 
48
51
  export const commandParamsSchemas = {