@posthog/agent 2.3.326 → 2.3.346

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.
Files changed (65) hide show
  1. package/dist/adapters/claude/conversion/tool-use-to-acp.d.ts +9 -0
  2. package/dist/adapters/claude/conversion/tool-use-to-acp.js +15 -1
  3. package/dist/adapters/claude/conversion/tool-use-to-acp.js.map +1 -1
  4. package/dist/adapters/claude/permissions/permission-options.js +18 -11
  5. package/dist/adapters/claude/permissions/permission-options.js.map +1 -1
  6. package/dist/adapters/claude/session/jsonl-hydration.js.map +1 -1
  7. package/dist/adapters/claude/session/models.d.ts +2 -2
  8. package/dist/adapters/claude/session/models.js +12 -6
  9. package/dist/adapters/claude/session/models.js.map +1 -1
  10. package/dist/adapters/claude/tools.js +15 -13
  11. package/dist/adapters/claude/tools.js.map +1 -1
  12. package/dist/adapters/reasoning-effort.d.ts +1 -1
  13. package/dist/adapters/reasoning-effort.js +11 -5
  14. package/dist/adapters/reasoning-effort.js.map +1 -1
  15. package/dist/agent.d.ts +2 -0
  16. package/dist/agent.js +6975 -613
  17. package/dist/agent.js.map +1 -1
  18. package/dist/execution-mode.d.ts +1 -1
  19. package/dist/execution-mode.js +14 -12
  20. package/dist/execution-mode.js.map +1 -1
  21. package/dist/posthog-api.d.ts +5 -3
  22. package/dist/posthog-api.js +12 -22
  23. package/dist/posthog-api.js.map +1 -1
  24. package/dist/server/agent-server.d.ts +8 -1
  25. package/dist/server/agent-server.js +11362 -4894
  26. package/dist/server/agent-server.js.map +1 -1
  27. package/dist/server/bin.cjs +11423 -4954
  28. package/dist/server/bin.cjs.map +1 -1
  29. package/dist/types.d.ts +11 -1
  30. package/package.json +7 -6
  31. package/src/adapters/acp-connection.ts +14 -1
  32. package/src/adapters/claude/UPSTREAM.md +24 -4
  33. package/src/adapters/claude/claude-agent.ts +161 -14
  34. package/src/adapters/claude/conversion/acp-to-sdk.test.ts +49 -0
  35. package/src/adapters/claude/conversion/acp-to-sdk.ts +23 -6
  36. package/src/adapters/claude/conversion/sdk-to-acp.ts +14 -2
  37. package/src/adapters/claude/conversion/tool-use-to-acp.ts +18 -1
  38. package/src/adapters/claude/hooks.test.ts +189 -0
  39. package/src/adapters/claude/hooks.ts +93 -3
  40. package/src/adapters/claude/permissions/permission-handlers.ts +2 -1
  41. package/src/adapters/claude/permissions/permission-options.ts +5 -0
  42. package/src/adapters/claude/session/models.ts +11 -5
  43. package/src/adapters/claude/session/options.ts +19 -3
  44. package/src/adapters/claude/session/settings.ts +17 -9
  45. package/src/adapters/claude/tools.ts +1 -1
  46. package/src/adapters/claude/types.ts +8 -1
  47. package/src/adapters/codex/codex-agent.ts +15 -2
  48. package/src/adapters/codex/codex-client.test.ts +112 -0
  49. package/src/adapters/codex/codex-client.ts +14 -1
  50. package/src/adapters/reasoning-effort.ts +6 -1
  51. package/src/agent.ts +6 -0
  52. package/src/enrichment/file-enricher.test.ts +163 -0
  53. package/src/enrichment/file-enricher.ts +82 -0
  54. package/src/execution-mode.test.ts +1 -0
  55. package/src/execution-mode.ts +13 -11
  56. package/src/posthog-api.test.ts +32 -0
  57. package/src/posthog-api.ts +13 -30
  58. package/src/server/agent-server.test.ts +96 -0
  59. package/src/server/agent-server.ts +207 -11
  60. package/src/server/bin.ts +1 -1
  61. package/src/server/schemas.test.ts +10 -0
  62. package/src/server/schemas.ts +25 -6
  63. package/src/server/types.ts +1 -1
  64. package/src/test/mocks/msw-handlers.ts +4 -1
  65. package/src/types.ts +10 -1
@@ -0,0 +1,112 @@
1
+ import type {
2
+ AgentSideConnection,
3
+ ReadTextFileRequest,
4
+ ReadTextFileResponse,
5
+ } from "@agentclientprotocol/sdk";
6
+ import { describe, expect, test, vi } from "vitest";
7
+ import type { FileEnrichmentDeps } from "../../enrichment/file-enricher";
8
+ import { Logger } from "../../utils/logger";
9
+
10
+ const enrichFileMock = vi.hoisted(() => vi.fn());
11
+ vi.mock("../../enrichment/file-enricher", () => ({
12
+ enrichFileForAgent: enrichFileMock,
13
+ }));
14
+
15
+ import { createCodexClient } from "./codex-client";
16
+ import { createSessionState } from "./session-state";
17
+
18
+ function makeUpstream(response: ReadTextFileResponse): AgentSideConnection & {
19
+ readTextFile: ReturnType<typeof vi.fn>;
20
+ } {
21
+ const mock = {
22
+ readTextFile: vi.fn(async (_: ReadTextFileRequest) => response),
23
+ writeTextFile: vi.fn(),
24
+ requestPermission: vi.fn(),
25
+ sessionUpdate: vi.fn(),
26
+ createTerminal: vi.fn(),
27
+ terminalOutput: vi.fn(),
28
+ releaseTerminal: vi.fn(),
29
+ waitForTerminalExit: vi.fn(),
30
+ killTerminal: vi.fn(),
31
+ extMethod: vi.fn(),
32
+ extNotification: vi.fn(),
33
+ };
34
+ return mock as unknown as AgentSideConnection & {
35
+ readTextFile: ReturnType<typeof vi.fn>;
36
+ };
37
+ }
38
+
39
+ describe("createCodexClient readTextFile", () => {
40
+ const logger = new Logger({ debug: false, prefix: "[test]" });
41
+ const sessionState = createSessionState("", "/tmp");
42
+
43
+ test("returns upstream response unchanged when enrichmentDeps is absent", async () => {
44
+ enrichFileMock.mockReset();
45
+ const upstream = makeUpstream({ content: "const x = 1;" });
46
+ const client = createCodexClient(upstream, logger, sessionState);
47
+
48
+ const result = await client.readTextFile?.({
49
+ sessionId: "s",
50
+ path: "/tmp/a.ts",
51
+ });
52
+ expect(result?.content).toBe("const x = 1;");
53
+ expect(enrichFileMock).not.toHaveBeenCalled();
54
+ });
55
+
56
+ test("returns enriched content when helper returns a string", async () => {
57
+ enrichFileMock.mockReset();
58
+ enrichFileMock.mockResolvedValueOnce("const x = 1; // [PostHog] Flag ...");
59
+
60
+ const upstream = makeUpstream({ content: "const x = 1;" });
61
+ const deps = {} as FileEnrichmentDeps;
62
+ const client = createCodexClient(upstream, logger, sessionState, {
63
+ enrichmentDeps: deps,
64
+ });
65
+
66
+ const result = await client.readTextFile?.({
67
+ sessionId: "s",
68
+ path: "/tmp/a.ts",
69
+ });
70
+ expect(result?.content).toBe("const x = 1; // [PostHog] Flag ...");
71
+ expect(enrichFileMock).toHaveBeenCalledWith(
72
+ deps,
73
+ "/tmp/a.ts",
74
+ "const x = 1;",
75
+ );
76
+ });
77
+
78
+ test("falls back to upstream response when helper returns null", async () => {
79
+ enrichFileMock.mockReset();
80
+ enrichFileMock.mockResolvedValueOnce(null);
81
+
82
+ const upstream = makeUpstream({ content: "no posthog here" });
83
+ const client = createCodexClient(upstream, logger, sessionState, {
84
+ enrichmentDeps: {} as FileEnrichmentDeps,
85
+ });
86
+
87
+ const result = await client.readTextFile?.({
88
+ sessionId: "s",
89
+ path: "/tmp/a.ts",
90
+ });
91
+ expect(result?.content).toBe("no posthog here");
92
+ });
93
+
94
+ test("calls upstream.readTextFile with original params (UI sees original)", async () => {
95
+ enrichFileMock.mockReset();
96
+ enrichFileMock.mockResolvedValueOnce("enriched");
97
+
98
+ const upstream = makeUpstream({ content: "original" });
99
+ const client = createCodexClient(upstream, logger, sessionState, {
100
+ enrichmentDeps: {} as FileEnrichmentDeps,
101
+ });
102
+
103
+ const params = {
104
+ sessionId: "s",
105
+ path: "/tmp/a.ts",
106
+ line: 10,
107
+ limit: 5,
108
+ };
109
+ await client.readTextFile?.(params);
110
+ expect(upstream.readTextFile).toHaveBeenCalledWith(params);
111
+ });
112
+ });
@@ -29,6 +29,10 @@ import type {
29
29
  WriteTextFileRequest,
30
30
  WriteTextFileResponse,
31
31
  } from "@agentclientprotocol/sdk";
32
+ import {
33
+ enrichFileForAgent,
34
+ type FileEnrichmentDeps,
35
+ } from "../../enrichment/file-enricher";
32
36
  import type { PermissionMode } from "../../execution-mode";
33
37
  import type { Logger } from "../../utils/logger";
34
38
  import type { CodexSessionState } from "./session-state";
@@ -36,6 +40,8 @@ import type { CodexSessionState } from "./session-state";
36
40
  export interface CodexClientCallbacks {
37
41
  /** Called when a usage_update session notification is received */
38
42
  onUsageUpdate?: (update: Record<string, unknown>) => void;
43
+ /** When set, Read responses are annotated with PostHog enrichment before reaching codex-acp. */
44
+ enrichmentDeps?: FileEnrichmentDeps;
39
45
  }
40
46
 
41
47
  const AUTO_APPROVED_KINDS: Record<PermissionMode, Set<ToolKind>> = {
@@ -152,7 +158,14 @@ export function createCodexClient(
152
158
  async readTextFile(
153
159
  params: ReadTextFileRequest,
154
160
  ): Promise<ReadTextFileResponse> {
155
- return upstreamClient.readTextFile(params);
161
+ const response = await upstreamClient.readTextFile(params);
162
+ if (!callbacks?.enrichmentDeps) return response;
163
+ const enriched = await enrichFileForAgent(
164
+ callbacks.enrichmentDeps,
165
+ params.path,
166
+ response.content,
167
+ );
168
+ return enriched ? { ...response, content: enriched } : response;
156
169
  },
157
170
 
158
171
  async writeTextFile(
@@ -3,7 +3,12 @@ import { getReasoningEffortOptions as getCodexReasoningEffortOptions } from "./c
3
3
 
4
4
  export type RuntimeAdapter = "claude" | "codex";
5
5
 
6
- export type SupportedReasoningEffort = "low" | "medium" | "high" | "max";
6
+ export type SupportedReasoningEffort =
7
+ | "low"
8
+ | "medium"
9
+ | "high"
10
+ | "xhigh"
11
+ | "max";
7
12
 
8
13
  export interface ReasoningEffortOption {
9
14
  value: SupportedReasoningEffort;
package/src/agent.ts CHANGED
@@ -19,6 +19,8 @@ export class Agent {
19
19
  private acpConnection?: InProcessAcpConnection;
20
20
  private taskRunId?: string;
21
21
  private sessionLogWriter?: SessionLogWriter;
22
+ private posthogApiConfig?: AgentConfig["posthog"];
23
+ private enricherEnabled: boolean;
22
24
 
23
25
  constructor(config: AgentConfig) {
24
26
  this.logger = new Logger({
@@ -29,7 +31,9 @@ export class Agent {
29
31
 
30
32
  if (config.posthog) {
31
33
  this.posthogAPI = new PostHogAPIClient(config.posthog);
34
+ this.posthogApiConfig = config.posthog;
32
35
  }
36
+ this.enricherEnabled = config.enricher?.enabled !== false;
33
37
 
34
38
  if (config.posthog && !config.skipLogPersistence) {
35
39
  this.sessionLogWriter = new SessionLogWriter({
@@ -121,6 +125,8 @@ export class Agent {
121
125
  processCallbacks: options.processCallbacks,
122
126
  onStructuredOutput: options.onStructuredOutput,
123
127
  allowedModelIds,
128
+ posthogApiConfig: this.posthogApiConfig,
129
+ enricherEnabled: this.enricherEnabled,
124
130
  codexOptions:
125
131
  options.adapter === "codex" && gatewayConfig
126
132
  ? {
@@ -0,0 +1,163 @@
1
+ import { describe, expect, test, vi } from "vitest";
2
+ import { enrichFileForAgent, type FileEnrichmentDeps } from "./file-enricher";
3
+
4
+ function makeDeps(overrides: {
5
+ toInlineCommentsReturn?: string;
6
+ callsCount?: number;
7
+ initCallsCount?: number;
8
+ parseRejects?: Error;
9
+ isSupported?: boolean;
10
+ getApiKey?: () => string | Promise<string>;
11
+ }): {
12
+ deps: FileEnrichmentDeps;
13
+ parseSpy: ReturnType<typeof vi.fn>;
14
+ enrichFromApiSpy: ReturnType<typeof vi.fn>;
15
+ getApiKeySpy: ReturnType<typeof vi.fn>;
16
+ } {
17
+ const enrichFromApiSpy = vi.fn(async () => ({
18
+ toInlineComments: () =>
19
+ overrides.toInlineCommentsReturn ?? "enriched content",
20
+ }));
21
+
22
+ const parseSpy = vi.fn(async () => {
23
+ if (overrides.parseRejects) throw overrides.parseRejects;
24
+ return {
25
+ calls: Array.from({ length: overrides.callsCount ?? 1 }),
26
+ initCalls: Array.from({ length: overrides.initCallsCount ?? 0 }),
27
+ enrichFromApi: enrichFromApiSpy,
28
+ };
29
+ });
30
+
31
+ const getApiKeySpy = vi.fn(overrides.getApiKey ?? (() => "phx_test"));
32
+
33
+ const deps: FileEnrichmentDeps = {
34
+ enricher: {
35
+ isSupported: vi.fn(() => overrides.isSupported ?? true),
36
+ parse: parseSpy,
37
+ } as unknown as FileEnrichmentDeps["enricher"],
38
+ apiConfig: {
39
+ apiUrl: "https://test.posthog.com",
40
+ projectId: 1,
41
+ getApiKey: getApiKeySpy,
42
+ },
43
+ };
44
+
45
+ return { deps, parseSpy, enrichFromApiSpy, getApiKeySpy };
46
+ }
47
+
48
+ describe("enrichFileForAgent", () => {
49
+ test("returns null for unsupported extension", async () => {
50
+ const { deps, parseSpy } = makeDeps({});
51
+ const result = await enrichFileForAgent(
52
+ deps,
53
+ "/tmp/notes.txt",
54
+ "some text",
55
+ );
56
+ expect(result).toBeNull();
57
+ expect(parseSpy).not.toHaveBeenCalled();
58
+ });
59
+
60
+ test("returns null for empty content", async () => {
61
+ const { deps, parseSpy } = makeDeps({});
62
+ const result = await enrichFileForAgent(deps, "/tmp/code.ts", "");
63
+ expect(result).toBeNull();
64
+ expect(parseSpy).not.toHaveBeenCalled();
65
+ });
66
+
67
+ test("returns null for content > 1MB", async () => {
68
+ const { deps, parseSpy } = makeDeps({});
69
+ const huge = "x".repeat(1_000_001);
70
+ const result = await enrichFileForAgent(deps, "/tmp/code.ts", huge);
71
+ expect(result).toBeNull();
72
+ expect(parseSpy).not.toHaveBeenCalled();
73
+ });
74
+
75
+ test("returns null when language not supported by enricher", async () => {
76
+ const { deps, parseSpy } = makeDeps({ isSupported: false });
77
+ const result = await enrichFileForAgent(
78
+ deps,
79
+ "/tmp/code.ts",
80
+ "posthog.capture('x');",
81
+ );
82
+ expect(result).toBeNull();
83
+ expect(parseSpy).not.toHaveBeenCalled();
84
+ });
85
+
86
+ test("returns null when no PostHog calls detected", async () => {
87
+ const { deps, enrichFromApiSpy } = makeDeps({
88
+ callsCount: 0,
89
+ initCallsCount: 0,
90
+ });
91
+ const result = await enrichFileForAgent(
92
+ deps,
93
+ "/tmp/code.ts",
94
+ "posthog.capture('x');",
95
+ );
96
+ expect(result).toBeNull();
97
+ expect(enrichFromApiSpy).not.toHaveBeenCalled();
98
+ });
99
+
100
+ test("returns null and skips parse when content has no posthog reference", async () => {
101
+ const { deps, parseSpy } = makeDeps({});
102
+ const result = await enrichFileForAgent(
103
+ deps,
104
+ "/tmp/code.ts",
105
+ "const x = 1;\nfunction foo() {}",
106
+ );
107
+ expect(result).toBeNull();
108
+ expect(parseSpy).not.toHaveBeenCalled();
109
+ });
110
+
111
+ test("returns null when getApiKey yields empty string", async () => {
112
+ const { deps, enrichFromApiSpy } = makeDeps({ getApiKey: () => "" });
113
+ const result = await enrichFileForAgent(
114
+ deps,
115
+ "/tmp/code.ts",
116
+ "posthog.capture('x');",
117
+ );
118
+ expect(result).toBeNull();
119
+ expect(enrichFromApiSpy).not.toHaveBeenCalled();
120
+ });
121
+
122
+ test("returns null when toInlineComments produces no change", async () => {
123
+ const original = "posthog.capture('x');";
124
+ const { deps } = makeDeps({ toInlineCommentsReturn: original });
125
+ const result = await enrichFileForAgent(deps, "/tmp/code.ts", original);
126
+ expect(result).toBeNull();
127
+ });
128
+
129
+ test("returns null and logs debug when enricher throws", async () => {
130
+ const logger = { debug: vi.fn() };
131
+ const { deps } = makeDeps({ parseRejects: new Error("boom") });
132
+ deps.logger = logger as unknown as FileEnrichmentDeps["logger"];
133
+ const result = await enrichFileForAgent(
134
+ deps,
135
+ "/tmp/code.ts",
136
+ "posthog.capture('x');",
137
+ );
138
+ expect(result).toBeNull();
139
+ expect(logger.debug).toHaveBeenCalledWith(
140
+ "File enrichment failed",
141
+ expect.objectContaining({ filePath: "/tmp/code.ts" }),
142
+ );
143
+ });
144
+
145
+ test("returns enriched string when happy path completes", async () => {
146
+ const { deps, enrichFromApiSpy } = makeDeps({
147
+ toInlineCommentsReturn: "posthog.capture('x'); // [PostHog] Event: \"x\"",
148
+ });
149
+ const result = await enrichFileForAgent(
150
+ deps,
151
+ "/tmp/code.ts",
152
+ "posthog.capture('x');",
153
+ );
154
+ expect(result).toBe("posthog.capture('x'); // [PostHog] Event: \"x\"");
155
+ expect(enrichFromApiSpy).toHaveBeenCalledWith(
156
+ expect.objectContaining({
157
+ apiKey: "phx_test",
158
+ host: "https://test.posthog.com",
159
+ projectId: 1,
160
+ }),
161
+ );
162
+ });
163
+ });
@@ -0,0 +1,82 @@
1
+ import * as path from "node:path";
2
+ import { EXT_TO_LANG_ID, PostHogEnricher } from "@posthog/enricher";
3
+ import type { PostHogAPIConfig } from "../types";
4
+ import type { Logger } from "../utils/logger";
5
+
6
+ export interface FileEnrichmentDeps {
7
+ enricher: PostHogEnricher;
8
+ apiConfig: PostHogAPIConfig;
9
+ logger?: Logger;
10
+ }
11
+
12
+ export interface Enrichment {
13
+ deps: FileEnrichmentDeps;
14
+ dispose(): void;
15
+ }
16
+
17
+ export function createEnrichment(
18
+ apiConfig: PostHogAPIConfig | undefined,
19
+ logger?: Logger,
20
+ ): Enrichment | undefined {
21
+ if (!apiConfig) return undefined;
22
+ const enricher = new PostHogEnricher();
23
+ return {
24
+ deps: { enricher, apiConfig, logger },
25
+ dispose: () => enricher.dispose(),
26
+ };
27
+ }
28
+
29
+ const MAX_ENRICHMENT_BYTES = 1_000_000;
30
+
31
+ export async function enrichFileForAgent(
32
+ deps: FileEnrichmentDeps,
33
+ filePath: string,
34
+ content: string,
35
+ ): Promise<string | null> {
36
+ if (!content || content.length > MAX_ENRICHMENT_BYTES) return null;
37
+
38
+ // Skip the tree-sitter parse for files with no PostHog references.
39
+ if (!/posthog/i.test(content)) return null;
40
+
41
+ const ext = path.extname(filePath).toLowerCase();
42
+ const langId = EXT_TO_LANG_ID[ext];
43
+ if (!langId || !deps.enricher.isSupported(langId)) return null;
44
+
45
+ try {
46
+ const parsed = await deps.enricher.parse(content, langId);
47
+ if (parsed.calls.length === 0 && parsed.initCalls.length === 0) {
48
+ return null;
49
+ }
50
+
51
+ const apiKey = await deps.apiConfig.getApiKey();
52
+ if (!apiKey) return null;
53
+
54
+ const enriched = await parsed.enrichFromApi({
55
+ apiKey,
56
+ host: deps.apiConfig.apiUrl,
57
+ projectId: deps.apiConfig.projectId,
58
+ timeoutMs: 5_000,
59
+ });
60
+
61
+ const annotated = enriched.toInlineComments();
62
+ if (annotated === content) {
63
+ deps.logger?.debug("File enrichment produced no changes", {
64
+ filePath,
65
+ calls: parsed.calls.length,
66
+ });
67
+ return null;
68
+ }
69
+ deps.logger?.debug("File enriched", {
70
+ filePath,
71
+ calls: parsed.calls.length,
72
+ });
73
+ return annotated;
74
+ } catch (err) {
75
+ const detail =
76
+ err instanceof Error
77
+ ? { message: err.message, name: err.name, stack: err.stack }
78
+ : { value: String(err) };
79
+ deps.logger?.debug("File enrichment failed", { filePath, ...detail });
80
+ return null;
81
+ }
82
+ }
@@ -8,6 +8,7 @@ describe("execution modes", () => {
8
8
  "acceptEdits",
9
9
  "plan",
10
10
  "bypassPermissions",
11
+ "auto",
11
12
  ]);
12
13
  });
13
14
 
@@ -25,19 +25,21 @@ const availableModes: ModeInfo[] = [
25
25
  name: "Plan Mode",
26
26
  description: "Planning mode, no actual tool execution",
27
27
  },
28
- // {
29
- // id: "dontAsk",
30
- // name: "Don't Ask",
31
- // description: "Don't prompt for permissions, deny if not pre-approved",
32
- // },
33
28
  ];
34
29
 
35
30
  if (ALLOW_BYPASS) {
36
- availableModes.push({
37
- id: "bypassPermissions",
38
- name: "Auto-accept Permissions",
39
- description: "Auto-accept all permission requests",
40
- });
31
+ availableModes.push(
32
+ {
33
+ id: "bypassPermissions",
34
+ name: "Bypass Permissions",
35
+ description: "Auto-accept all permission requests",
36
+ },
37
+ {
38
+ id: "auto",
39
+ name: "Auto Mode",
40
+ description: "Use a model classifier to approve/deny permission prompts",
41
+ },
42
+ );
41
43
  }
42
44
 
43
45
  // Expose execution mode IDs in type-safe order for type checks
@@ -45,8 +47,8 @@ export const CODE_EXECUTION_MODES = [
45
47
  "default",
46
48
  "acceptEdits",
47
49
  "plan",
48
- // "dontAsk",
49
50
  "bypassPermissions",
51
+ "auto",
50
52
  ] as const;
51
53
 
52
54
  export type CodeExecutionMode = (typeof CODE_EXECUTION_MODES)[number];
@@ -45,4 +45,36 @@ describe("PostHogAPIClient", () => {
45
45
  expect(refreshApiKey).toHaveBeenCalledTimes(1);
46
46
  expect(mockFetch).toHaveBeenCalledTimes(2);
47
47
  });
48
+
49
+ it("downloads artifacts through the backend endpoint", async () => {
50
+ const client = new PostHogAPIClient({
51
+ apiUrl: "https://app.posthog.com",
52
+ getApiKey: vi.fn().mockResolvedValue("token"),
53
+ projectId: 7,
54
+ });
55
+ const bytes = new TextEncoder().encode("hello world");
56
+
57
+ mockFetch.mockResolvedValueOnce({
58
+ ok: true,
59
+ arrayBuffer: vi.fn().mockResolvedValue(bytes.buffer),
60
+ });
61
+
62
+ const artifact = await client.downloadArtifact(
63
+ "task-1",
64
+ "run-1",
65
+ "tasks/artifacts/team_1/task_task-1/run_run-1/file.txt",
66
+ );
67
+
68
+ expect(artifact).toEqual(bytes.buffer);
69
+ expect(mockFetch).toHaveBeenCalledWith(
70
+ "https://app.posthog.com/api/projects/7/tasks/task-1/runs/run-1/artifacts/download/",
71
+ expect.objectContaining({
72
+ method: "POST",
73
+ body: JSON.stringify({
74
+ storage_path: "tasks/artifacts/team_1/task_task-1/run_run-1/file.txt",
75
+ }),
76
+ headers: expect.any(Headers),
77
+ }),
78
+ );
79
+ });
48
80
  });
@@ -31,7 +31,9 @@ export type TaskRunUpdate = Partial<
31
31
  | "state"
32
32
  | "environment"
33
33
  >
34
- >;
34
+ > & {
35
+ state_remove_keys?: string[];
36
+ };
35
37
 
36
38
  export class PostHogAPIClient {
37
39
  private config: PostHogAPIConfig;
@@ -223,45 +225,26 @@ export class PostHogAPIClient {
223
225
  return response.artifacts ?? [];
224
226
  }
225
227
 
226
- async getArtifactPresignedUrl(
227
- taskId: string,
228
- runId: string,
229
- storagePath: string,
230
- ): Promise<string | null> {
231
- const teamId = this.getTeamId();
232
- try {
233
- const response = await this.apiRequest<{
234
- url: string;
235
- expires_in: number;
236
- }>(
237
- `/api/projects/${teamId}/tasks/${taskId}/runs/${runId}/artifacts/presign/`,
238
- {
239
- method: "POST",
240
- body: JSON.stringify({ storage_path: storagePath }),
241
- },
242
- );
243
- return response.url;
244
- } catch {
245
- return null;
246
- }
247
- }
248
-
249
228
  /**
250
229
  * Download artifact content by storage path
251
- * Gets a presigned URL and fetches the content
230
+ * Streams the file through the PostHog backend so the sandbox does not need
231
+ * direct access to object storage.
252
232
  */
253
233
  async downloadArtifact(
254
234
  taskId: string,
255
235
  runId: string,
256
236
  storagePath: string,
257
237
  ): Promise<ArrayBuffer | null> {
258
- const url = await this.getArtifactPresignedUrl(taskId, runId, storagePath);
259
- if (!url) {
260
- return null;
261
- }
238
+ const teamId = this.getTeamId();
262
239
 
263
240
  try {
264
- const response = await fetch(url);
241
+ const response = await this.performRequestWithRetry(
242
+ `/api/projects/${teamId}/tasks/${taskId}/runs/${runId}/artifacts/download/`,
243
+ {
244
+ method: "POST",
245
+ body: JSON.stringify({ storage_path: storagePath }),
246
+ },
247
+ );
265
248
  if (!response.ok) {
266
249
  throw new Error(`Failed to download artifact: ${response.status}`);
267
250
  }