@posthog/agent 2.1.118 → 2.1.120

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.1.118",
3
+ "version": "2.1.120",
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": {
@@ -87,6 +87,12 @@ export function promptToClaude(prompt: PromptRequest): SDKUserMessage {
87
87
  const content: ContentBlockParam[] = [];
88
88
  const context: ContentBlockParam[] = [];
89
89
 
90
+ const prContext = (prompt._meta as Record<string, unknown> | undefined)
91
+ ?.prContext;
92
+ if (typeof prContext === "string") {
93
+ content.push(sdkText(prContext));
94
+ }
95
+
90
96
  for (const chunk of prompt.prompt) {
91
97
  processPromptChunk(chunk, content, context);
92
98
  }
@@ -276,9 +276,15 @@ async function handleAskUserQuestionTool(
276
276
  });
277
277
 
278
278
  if (response.outcome?.outcome !== "selected") {
279
+ const customMessage = (
280
+ response._meta as Record<string, unknown> | undefined
281
+ )?.message;
279
282
  return {
280
283
  behavior: "deny",
281
- message: "User cancelled the questions",
284
+ message:
285
+ typeof customMessage === "string"
286
+ ? customMessage
287
+ : "User cancelled the questions",
282
288
  interrupt: true,
283
289
  };
284
290
  }
@@ -137,6 +137,21 @@ export class PostHogAPIClient {
137
137
  );
138
138
  }
139
139
 
140
+ async relayMessage(
141
+ taskId: string,
142
+ runId: string,
143
+ text: string,
144
+ ): Promise<void> {
145
+ const teamId = this.getTeamId();
146
+ await this.apiRequest<{ status: string }>(
147
+ `/api/projects/${teamId}/tasks/${taskId}/runs/${runId}/relay_message/`,
148
+ {
149
+ method: "POST",
150
+ body: JSON.stringify({ text }),
151
+ },
152
+ );
153
+ }
154
+
140
155
  async uploadTaskArtifacts(
141
156
  taskId: string,
142
157
  runId: string,
@@ -3,6 +3,7 @@ import { type SetupServerApi, setupServer } from "msw/node";
3
3
  import { afterEach, beforeEach, describe, expect, it } from "vitest";
4
4
  import { createTestRepo, type TestRepo } from "../test/fixtures/api.js";
5
5
  import { createPostHogHandlers } from "../test/mocks/msw-handlers.js";
6
+ import type { TaskRun } from "../types.js";
6
7
  import { AgentServer } from "./agent-server.js";
7
8
  import { type JwtPayload, SANDBOX_CONNECTION_AUDIENCE } from "./jwt.js";
8
9
 
@@ -217,4 +218,112 @@ describe("AgentServer HTTP Mode", () => {
217
218
  expect(body.error).toBe("Not found");
218
219
  });
219
220
  });
221
+
222
+ describe("getInitialPromptOverride", () => {
223
+ it("returns override string from run state", () => {
224
+ const s = createServer();
225
+ const run = {
226
+ state: { initial_prompt_override: "do something else" },
227
+ } as unknown as TaskRun;
228
+ const result = (s as any).getInitialPromptOverride(run);
229
+ expect(result).toBe("do something else");
230
+ });
231
+
232
+ it("returns null when override is absent", () => {
233
+ const s = createServer();
234
+ const run = { state: {} } as unknown as TaskRun;
235
+ const result = (s as any).getInitialPromptOverride(run);
236
+ expect(result).toBeNull();
237
+ });
238
+
239
+ it("returns null for whitespace-only override", () => {
240
+ const s = createServer();
241
+ const run = {
242
+ state: { initial_prompt_override: " " },
243
+ } as unknown as TaskRun;
244
+ const result = (s as any).getInitialPromptOverride(run);
245
+ expect(result).toBeNull();
246
+ });
247
+
248
+ it("returns null for non-string override", () => {
249
+ const s = createServer();
250
+ const run = {
251
+ state: { initial_prompt_override: 42 },
252
+ } as unknown as TaskRun;
253
+ const result = (s as any).getInitialPromptOverride(run);
254
+ expect(result).toBeNull();
255
+ });
256
+ });
257
+
258
+ describe("detectedPrUrl tracking", () => {
259
+ it("stores PR URL when detectAndAttachPrUrl finds a match", () => {
260
+ const s = createServer();
261
+ const payload = {
262
+ task_id: "test-task-id",
263
+ run_id: "test-run-id",
264
+ };
265
+ const update = {
266
+ _meta: {
267
+ claudeCode: {
268
+ toolName: "Bash",
269
+ toolResponse: {
270
+ stdout:
271
+ "https://github.com/PostHog/posthog/pull/42\nCreating pull request...",
272
+ },
273
+ },
274
+ },
275
+ };
276
+
277
+ (s as any).detectAndAttachPrUrl(payload, update);
278
+ expect((s as any).detectedPrUrl).toBe(
279
+ "https://github.com/PostHog/posthog/pull/42",
280
+ );
281
+ });
282
+
283
+ it("does not set detectedPrUrl when no PR URL is found", () => {
284
+ const s = createServer();
285
+ const payload = {
286
+ task_id: "test-task-id",
287
+ run_id: "test-run-id",
288
+ };
289
+ const update = {
290
+ _meta: {
291
+ claudeCode: {
292
+ toolName: "Bash",
293
+ toolResponse: { stdout: "just some output" },
294
+ },
295
+ },
296
+ };
297
+
298
+ (s as any).detectAndAttachPrUrl(payload, update);
299
+ expect((s as any).detectedPrUrl).toBeNull();
300
+ });
301
+ });
302
+
303
+ describe("buildCloudSystemPrompt", () => {
304
+ it("returns PR-aware prompt when prUrl is provided", () => {
305
+ const s = createServer();
306
+ const prompt = (s as any).buildCloudSystemPrompt(
307
+ "https://github.com/org/repo/pull/1",
308
+ );
309
+ expect(prompt).toContain("Do NOT create a new branch");
310
+ expect(prompt).toContain("https://github.com/org/repo/pull/1");
311
+ expect(prompt).toContain("gh pr checkout");
312
+ expect(prompt).not.toContain("Create a pull request");
313
+ });
314
+
315
+ it("returns default prompt when no prUrl", () => {
316
+ const s = createServer();
317
+ const prompt = (s as any).buildCloudSystemPrompt();
318
+ expect(prompt).toContain("Create a new branch");
319
+ expect(prompt).toContain("Create a pull request");
320
+ });
321
+
322
+ it("returns default prompt when prUrl is null", () => {
323
+ const s = createServer();
324
+ const prompt = (s as any).buildCloudSystemPrompt(null);
325
+ expect(prompt).toContain("Create a new branch");
326
+ expect(prompt).toContain("Create a pull request");
327
+ });
328
+ });
220
329
  });
@@ -14,7 +14,12 @@ import {
14
14
  import { PostHogAPIClient } from "../posthog-api.js";
15
15
  import { SessionLogWriter } from "../session-log-writer.js";
16
16
  import { TreeTracker } from "../tree-tracker.js";
17
- import type { AgentMode, DeviceInfo, TreeSnapshotEvent } from "../types.js";
17
+ import type {
18
+ AgentMode,
19
+ DeviceInfo,
20
+ TaskRun,
21
+ TreeSnapshotEvent,
22
+ } from "../types.js";
18
23
  import { AsyncMutex } from "../utils/async-mutex.js";
19
24
  import { getLlmGatewayUrl } from "../utils/gateway.js";
20
25
  import { Logger } from "../utils/logger.js";
@@ -147,6 +152,8 @@ export class AgentServer {
147
152
  private session: ActiveSession | null = null;
148
153
  private app: Hono;
149
154
  private posthogAPI: PostHogAPIClient;
155
+ private questionRelayedToSlack = false;
156
+ private detectedPrUrl: string | null = null;
150
157
 
151
158
  constructor(config: AgentServerConfig) {
152
159
  this.config = config;
@@ -408,12 +415,23 @@ export class AgentServer {
408
415
  const content = params.content as string;
409
416
 
410
417
  this.logger.info(
411
- `Processing user message: ${content.substring(0, 100)}...`,
418
+ `Processing user message (detectedPrUrl=${this.detectedPrUrl ?? "none"}): ${content.substring(0, 100)}...`,
412
419
  );
413
420
 
414
421
  const result = await this.session.clientConnection.prompt({
415
422
  sessionId: this.session.acpSessionId,
416
423
  prompt: [{ type: "text", text: content }],
424
+ ...(this.detectedPrUrl && {
425
+ _meta: {
426
+ prContext:
427
+ `IMPORTANT — OVERRIDE PREVIOUS INSTRUCTIONS ABOUT CREATING BRANCHES/PRs.\n` +
428
+ `You already have an open pull request: ${this.detectedPrUrl}\n` +
429
+ `You MUST:\n` +
430
+ `1. Check out the existing PR branch with \`gh pr checkout ${this.detectedPrUrl}\`\n` +
431
+ `2. Make changes, commit, and push to that branch\n` +
432
+ `You MUST NOT create a new branch, close the existing PR, or create a new PR.`,
433
+ },
434
+ }),
417
435
  });
418
436
 
419
437
  return { stopReason: result.stopReason };
@@ -521,13 +539,37 @@ export class AgentServer {
521
539
  clientCapabilities: {},
522
540
  });
523
541
 
542
+ let preTaskRun: TaskRun | null = null;
543
+ try {
544
+ preTaskRun = await this.posthogAPI.getTaskRun(
545
+ payload.task_id,
546
+ payload.run_id,
547
+ );
548
+ } catch {
549
+ this.logger.warn("Failed to fetch task run for session context", {
550
+ taskId: payload.task_id,
551
+ runId: payload.run_id,
552
+ });
553
+ }
554
+
555
+ const prUrl =
556
+ typeof (preTaskRun?.state as Record<string, unknown>)
557
+ ?.slack_notified_pr_url === "string"
558
+ ? ((preTaskRun?.state as Record<string, unknown>)
559
+ .slack_notified_pr_url as string)
560
+ : null;
561
+
562
+ if (prUrl) {
563
+ this.detectedPrUrl = prUrl;
564
+ }
565
+
524
566
  const sessionResponse = await clientConnection.newSession({
525
567
  cwd: this.config.repositoryPath,
526
568
  mcpServers: [],
527
569
  _meta: {
528
570
  sessionId: payload.run_id,
529
571
  taskRunId: payload.run_id,
530
- systemPrompt: { append: this.buildCloudSystemPrompt() },
572
+ systemPrompt: { append: this.buildCloudSystemPrompt(prUrl) },
531
573
  },
532
574
  });
533
575
 
@@ -559,34 +601,65 @@ export class AgentServer {
559
601
  this.logger.warn("Failed to set task run to in_progress", err),
560
602
  );
561
603
 
562
- await this.sendInitialTaskMessage(payload);
604
+ await this.sendInitialTaskMessage(payload, preTaskRun);
563
605
  }
564
606
 
565
- private async sendInitialTaskMessage(payload: JwtPayload): Promise<void> {
607
+ private async sendInitialTaskMessage(
608
+ payload: JwtPayload,
609
+ prefetchedRun?: TaskRun | null,
610
+ ): Promise<void> {
566
611
  if (!this.session) return;
567
612
 
568
613
  try {
569
- this.logger.info("Fetching task details", { taskId: payload.task_id });
570
614
  const task = await this.posthogAPI.getTask(payload.task_id);
571
615
 
572
- if (!task.description) {
616
+ let taskRun = prefetchedRun ?? null;
617
+ if (!taskRun) {
618
+ try {
619
+ taskRun = await this.posthogAPI.getTaskRun(
620
+ payload.task_id,
621
+ payload.run_id,
622
+ );
623
+ } catch (error) {
624
+ this.logger.warn(
625
+ "Failed to fetch task run for initial prompt override",
626
+ {
627
+ taskId: payload.task_id,
628
+ runId: payload.run_id,
629
+ error,
630
+ },
631
+ );
632
+ }
633
+ }
634
+
635
+ const initialPromptOverride = taskRun
636
+ ? this.getInitialPromptOverride(taskRun)
637
+ : null;
638
+ const initialPrompt = initialPromptOverride ?? task.description;
639
+
640
+ if (!initialPrompt) {
573
641
  this.logger.warn("Task has no description, skipping initial message");
574
642
  return;
575
643
  }
576
644
 
577
645
  this.logger.info("Sending initial task message", {
578
646
  taskId: payload.task_id,
579
- descriptionLength: task.description.length,
647
+ descriptionLength: initialPrompt.length,
648
+ usedInitialPromptOverride: !!initialPromptOverride,
580
649
  });
581
650
 
582
651
  const result = await this.session.clientConnection.prompt({
583
652
  sessionId: this.session.acpSessionId,
584
- prompt: [{ type: "text", text: task.description }],
653
+ prompt: [{ type: "text", text: initialPrompt }],
585
654
  });
586
655
 
587
656
  this.logger.info("Initial task message completed", {
588
657
  stopReason: result.stopReason,
589
658
  });
659
+
660
+ if (result.stopReason === "end_turn") {
661
+ await this.relayAgentResponse(payload);
662
+ }
590
663
  } catch (error) {
591
664
  this.logger.error("Failed to send initial task message", error);
592
665
  if (this.session) {
@@ -596,7 +669,36 @@ export class AgentServer {
596
669
  }
597
670
  }
598
671
 
599
- private buildCloudSystemPrompt(): string {
672
+ private getInitialPromptOverride(taskRun: TaskRun): string | null {
673
+ const state = taskRun.state as Record<string, unknown> | undefined;
674
+ const override = state?.initial_prompt_override;
675
+ if (typeof override !== "string") {
676
+ return null;
677
+ }
678
+
679
+ const trimmed = override.trim();
680
+ return trimmed.length > 0 ? trimmed : null;
681
+ }
682
+
683
+ private buildCloudSystemPrompt(prUrl?: string | null): string {
684
+ if (prUrl) {
685
+ return `
686
+ # Cloud Task Execution
687
+
688
+ This task already has an open pull request: ${prUrl}
689
+
690
+ After completing the requested changes:
691
+ 1. Check out the existing PR branch with \`gh pr checkout ${prUrl}\`
692
+ 2. Stage and commit all changes with a clear commit message
693
+ 3. Push to the existing PR branch
694
+
695
+ Important:
696
+ - Do NOT create a new branch or a new pull request.
697
+ - Do NOT add "Co-Authored-By" trailers to commit messages.
698
+ - Do NOT add "Generated with [Claude Code]" or similar attribution lines to PR descriptions.
699
+ `;
700
+ }
701
+
600
702
  return `
601
703
  # Cloud Task Execution
602
704
 
@@ -680,26 +782,50 @@ Important:
680
782
 
681
783
  private createCloudClient(payload: JwtPayload) {
682
784
  const mode = this.getEffectiveMode(payload);
785
+ const interactionOrigin = process.env.TWIG_INTERACTION_ORIGIN;
683
786
 
684
787
  return {
685
788
  requestPermission: async (params: {
686
- options: Array<{ kind: string; optionId: string }>;
789
+ options: Array<{ kind: string; optionId: string; name?: string }>;
790
+ toolCall?: {
791
+ _meta?: Record<string, unknown> | null;
792
+ };
687
793
  }) => {
688
794
  // Background mode: always auto-approve permissions
689
795
  // Interactive mode: also auto-approve for now (user can monitor via SSE)
690
796
  // Future: interactive mode could pause and wait for user approval via SSE
691
797
  this.logger.debug("Permission request", {
692
798
  mode,
799
+ interactionOrigin,
693
800
  options: params.options,
694
801
  });
695
802
 
696
803
  const allowOption = params.options.find(
697
804
  (o) => o.kind === "allow_once" || o.kind === "allow_always",
698
805
  );
806
+ const selectedOptionId =
807
+ allowOption?.optionId ?? params.options[0].optionId;
808
+
809
+ if (interactionOrigin === "slack") {
810
+ const twigToolKind = params.toolCall?._meta?.twigToolKind;
811
+ if (twigToolKind === "question") {
812
+ this.relaySlackQuestion(payload, params.toolCall?._meta);
813
+ return {
814
+ outcome: { outcome: "cancelled" as const },
815
+ _meta: {
816
+ message:
817
+ "This question has been relayed to the Slack thread where this task originated. " +
818
+ "The user will reply there. Do NOT re-ask the question or pick an answer yourself. " +
819
+ "Simply let the user know you are waiting for their reply.",
820
+ },
821
+ };
822
+ }
823
+ }
824
+
699
825
  return {
700
826
  outcome: {
701
827
  outcome: "selected" as const,
702
- optionId: allowOption?.optionId ?? params.options[0].optionId,
828
+ optionId: selectedOptionId,
703
829
  },
704
830
  };
705
831
  },
@@ -735,6 +861,128 @@ Important:
735
861
  };
736
862
  }
737
863
 
864
+ private async relayAgentResponse(payload: JwtPayload): Promise<void> {
865
+ if (!this.session) {
866
+ return;
867
+ }
868
+
869
+ if (this.questionRelayedToSlack) {
870
+ this.questionRelayedToSlack = false;
871
+ return;
872
+ }
873
+
874
+ try {
875
+ await this.session.logWriter.flush(payload.run_id);
876
+ } catch (error) {
877
+ this.logger.warn("Failed to flush logs before Slack relay", {
878
+ taskId: payload.task_id,
879
+ runId: payload.run_id,
880
+ error,
881
+ });
882
+ }
883
+
884
+ const message = this.session.logWriter.getLastAgentMessage(payload.run_id);
885
+ if (!message) {
886
+ this.logger.warn("No agent message found for Slack relay", {
887
+ taskId: payload.task_id,
888
+ runId: payload.run_id,
889
+ sessionRegistered: this.session.logWriter.isRegistered(payload.run_id),
890
+ });
891
+ return;
892
+ }
893
+
894
+ try {
895
+ await this.posthogAPI.relayMessage(
896
+ payload.task_id,
897
+ payload.run_id,
898
+ message,
899
+ );
900
+ } catch (error) {
901
+ this.logger.warn("Failed to relay initial agent response to Slack", {
902
+ taskId: payload.task_id,
903
+ runId: payload.run_id,
904
+ error,
905
+ });
906
+ }
907
+ }
908
+
909
+ private relaySlackQuestion(
910
+ payload: JwtPayload,
911
+ toolMeta: Record<string, unknown> | null | undefined,
912
+ ): void {
913
+ const firstQuestion = this.getFirstQuestionMeta(toolMeta);
914
+ if (!this.isQuestionMeta(firstQuestion)) {
915
+ return;
916
+ }
917
+
918
+ let message = `*${firstQuestion.question}*\n\n`;
919
+ if (firstQuestion.options?.length) {
920
+ firstQuestion.options.forEach(
921
+ (opt: { label: string; description?: string }, i: number) => {
922
+ message += `${i + 1}. *${opt.label}*`;
923
+ if (opt.description) message += ` — ${opt.description}`;
924
+ message += "\n";
925
+ },
926
+ );
927
+ }
928
+ message += "\nReply in this thread with your choice.";
929
+
930
+ this.questionRelayedToSlack = true;
931
+ this.posthogAPI
932
+ .relayMessage(payload.task_id, payload.run_id, message)
933
+ .catch((err) =>
934
+ this.logger.warn("Failed to relay question to Slack", { err }),
935
+ );
936
+ }
937
+
938
+ private getFirstQuestionMeta(
939
+ toolMeta: Record<string, unknown> | null | undefined,
940
+ ): unknown {
941
+ if (!toolMeta) {
942
+ return null;
943
+ }
944
+
945
+ const questionsValue = toolMeta.questions;
946
+ if (!Array.isArray(questionsValue) || questionsValue.length === 0) {
947
+ return null;
948
+ }
949
+
950
+ return questionsValue[0];
951
+ }
952
+
953
+ private isQuestionMeta(value: unknown): value is {
954
+ question: string;
955
+ options?: Array<{ label: string; description?: string }>;
956
+ } {
957
+ if (!value || typeof value !== "object") {
958
+ return false;
959
+ }
960
+
961
+ const candidate = value as {
962
+ question?: unknown;
963
+ options?: unknown;
964
+ };
965
+
966
+ if (typeof candidate.question !== "string") {
967
+ return false;
968
+ }
969
+
970
+ if (candidate.options === undefined) {
971
+ return true;
972
+ }
973
+
974
+ if (!Array.isArray(candidate.options)) {
975
+ return false;
976
+ }
977
+
978
+ return candidate.options.every(
979
+ (option) =>
980
+ !!option &&
981
+ typeof option === "object" &&
982
+ typeof (option as { label?: unknown }).label === "string",
983
+ );
984
+ }
985
+
738
986
  private detectAndAttachPrUrl(
739
987
  payload: JwtPayload,
740
988
  update: Record<string, unknown>,
@@ -780,6 +1028,7 @@ Important:
780
1028
  if (!prUrlMatch) return;
781
1029
 
782
1030
  const prUrl = prUrlMatch[0];
1031
+ this.detectedPrUrl = prUrl;
783
1032
  this.logger.info("Detected PR URL in bash output", {
784
1033
  runId: payload.run_id,
785
1034
  prUrl,