@posthog/agent 1.30.0 → 2.0.1

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 (144) hide show
  1. package/LICENSE +1 -1
  2. package/README.md +221 -219
  3. package/dist/adapters/claude/conversion/tool-use-to-acp.d.ts +21 -0
  4. package/dist/adapters/claude/conversion/tool-use-to-acp.js +547 -0
  5. package/dist/adapters/claude/conversion/tool-use-to-acp.js.map +1 -0
  6. package/dist/adapters/claude/permissions/permission-options.d.ts +13 -0
  7. package/dist/adapters/claude/permissions/permission-options.js +117 -0
  8. package/dist/adapters/claude/permissions/permission-options.js.map +1 -0
  9. package/dist/adapters/claude/questions/utils.d.ts +132 -0
  10. package/dist/adapters/claude/questions/utils.js +63 -0
  11. package/dist/adapters/claude/questions/utils.js.map +1 -0
  12. package/dist/adapters/claude/tools.d.ts +18 -0
  13. package/dist/adapters/claude/tools.js +95 -0
  14. package/dist/adapters/claude/tools.js.map +1 -0
  15. package/dist/agent-DBQY1BfC.d.ts +123 -0
  16. package/dist/agent.d.ts +5 -0
  17. package/dist/agent.js +3656 -0
  18. package/dist/agent.js.map +1 -0
  19. package/dist/claude-cli/cli.js +3695 -2746
  20. package/dist/claude-cli/vendor/ripgrep/COPYING +3 -0
  21. package/dist/claude-cli/vendor/ripgrep/arm64-darwin/rg +0 -0
  22. package/dist/claude-cli/vendor/ripgrep/arm64-darwin/ripgrep.node +0 -0
  23. package/dist/claude-cli/vendor/ripgrep/arm64-linux/rg +0 -0
  24. package/dist/claude-cli/vendor/ripgrep/arm64-linux/ripgrep.node +0 -0
  25. package/dist/claude-cli/vendor/ripgrep/x64-darwin/rg +0 -0
  26. package/dist/claude-cli/vendor/ripgrep/x64-darwin/ripgrep.node +0 -0
  27. package/dist/claude-cli/vendor/ripgrep/x64-linux/rg +0 -0
  28. package/dist/claude-cli/vendor/ripgrep/x64-linux/ripgrep.node +0 -0
  29. package/dist/claude-cli/vendor/ripgrep/x64-win32/rg.exe +0 -0
  30. package/dist/claude-cli/vendor/ripgrep/x64-win32/ripgrep.node +0 -0
  31. package/dist/gateway-models.d.ts +24 -0
  32. package/dist/gateway-models.js +93 -0
  33. package/dist/gateway-models.js.map +1 -0
  34. package/dist/index.d.ts +172 -1203
  35. package/dist/index.js +3704 -6826
  36. package/dist/index.js.map +1 -1
  37. package/dist/logger-DDBiMOOD.d.ts +24 -0
  38. package/dist/posthog-api.d.ts +40 -0
  39. package/dist/posthog-api.js +175 -0
  40. package/dist/posthog-api.js.map +1 -0
  41. package/dist/server/agent-server.d.ts +41 -0
  42. package/dist/server/agent-server.js +4451 -0
  43. package/dist/server/agent-server.js.map +1 -0
  44. package/dist/server/bin.d.ts +1 -0
  45. package/dist/server/bin.js +4507 -0
  46. package/dist/server/bin.js.map +1 -0
  47. package/dist/types.d.ts +129 -0
  48. package/dist/types.js +1 -0
  49. package/dist/types.js.map +1 -0
  50. package/package.json +66 -14
  51. package/src/acp-extensions.ts +93 -61
  52. package/src/adapters/acp-connection.ts +494 -0
  53. package/src/adapters/base-acp-agent.ts +150 -0
  54. package/src/adapters/claude/claude-agent.ts +596 -0
  55. package/src/adapters/claude/conversion/acp-to-sdk.ts +102 -0
  56. package/src/adapters/claude/conversion/sdk-to-acp.ts +571 -0
  57. package/src/adapters/claude/conversion/tool-use-to-acp.ts +618 -0
  58. package/src/adapters/claude/hooks.ts +64 -0
  59. package/src/adapters/claude/mcp/tool-metadata.ts +102 -0
  60. package/src/adapters/claude/permissions/permission-handlers.ts +433 -0
  61. package/src/adapters/claude/permissions/permission-options.ts +103 -0
  62. package/src/adapters/claude/plan/utils.ts +56 -0
  63. package/src/adapters/claude/questions/utils.ts +92 -0
  64. package/src/adapters/claude/session/commands.ts +38 -0
  65. package/src/adapters/claude/session/mcp-config.ts +37 -0
  66. package/src/adapters/claude/session/models.ts +12 -0
  67. package/src/adapters/claude/session/options.ts +236 -0
  68. package/src/adapters/claude/tool-meta.ts +143 -0
  69. package/src/adapters/claude/tools.ts +53 -611
  70. package/src/adapters/claude/types.ts +61 -0
  71. package/src/adapters/codex/spawn.ts +130 -0
  72. package/src/agent.ts +97 -734
  73. package/src/execution-mode.ts +43 -0
  74. package/src/gateway-models.ts +135 -0
  75. package/src/index.ts +79 -0
  76. package/src/otel-log-writer.test.ts +105 -0
  77. package/src/otel-log-writer.ts +94 -0
  78. package/src/posthog-api.ts +75 -235
  79. package/src/resume.ts +115 -0
  80. package/src/sagas/apply-snapshot-saga.test.ts +690 -0
  81. package/src/sagas/apply-snapshot-saga.ts +88 -0
  82. package/src/sagas/capture-tree-saga.test.ts +892 -0
  83. package/src/sagas/capture-tree-saga.ts +141 -0
  84. package/src/sagas/resume-saga.test.ts +558 -0
  85. package/src/sagas/resume-saga.ts +332 -0
  86. package/src/sagas/test-fixtures.ts +250 -0
  87. package/src/server/agent-server.test.ts +220 -0
  88. package/src/server/agent-server.ts +748 -0
  89. package/src/server/bin.ts +88 -0
  90. package/src/server/jwt.ts +65 -0
  91. package/src/server/schemas.ts +47 -0
  92. package/src/server/types.ts +13 -0
  93. package/src/server/utils/retry.test.ts +122 -0
  94. package/src/server/utils/retry.ts +61 -0
  95. package/src/server/utils/sse-parser.test.ts +93 -0
  96. package/src/server/utils/sse-parser.ts +46 -0
  97. package/src/session-log-writer.test.ts +140 -0
  98. package/src/session-log-writer.ts +137 -0
  99. package/src/test/assertions.ts +114 -0
  100. package/src/test/controllers/sse-controller.ts +107 -0
  101. package/src/test/fixtures/api.ts +111 -0
  102. package/src/test/fixtures/config.ts +33 -0
  103. package/src/test/fixtures/notifications.ts +92 -0
  104. package/src/test/mocks/claude-sdk.ts +251 -0
  105. package/src/test/mocks/msw-handlers.ts +48 -0
  106. package/src/test/setup.ts +114 -0
  107. package/src/test/wait.ts +41 -0
  108. package/src/tree-tracker.ts +173 -0
  109. package/src/types.ts +51 -154
  110. package/src/utils/acp-content.ts +58 -0
  111. package/src/utils/async-mutex.test.ts +104 -0
  112. package/src/utils/async-mutex.ts +31 -0
  113. package/src/utils/common.ts +15 -0
  114. package/src/utils/gateway.ts +9 -6
  115. package/src/utils/logger.ts +0 -30
  116. package/src/utils/streams.ts +220 -0
  117. package/CLAUDE.md +0 -331
  118. package/dist/templates/plan-template.md +0 -41
  119. package/src/adapters/claude/claude.ts +0 -1543
  120. package/src/adapters/claude/mcp-server.ts +0 -810
  121. package/src/adapters/claude/utils.ts +0 -267
  122. package/src/agents/execution.ts +0 -37
  123. package/src/agents/planning.ts +0 -60
  124. package/src/agents/research.ts +0 -160
  125. package/src/file-manager.ts +0 -306
  126. package/src/git-manager.ts +0 -577
  127. package/src/prompt-builder.ts +0 -499
  128. package/src/schemas.ts +0 -241
  129. package/src/session-store.ts +0 -259
  130. package/src/task-manager.ts +0 -163
  131. package/src/template-manager.ts +0 -236
  132. package/src/templates/plan-template.md +0 -41
  133. package/src/todo-manager.ts +0 -180
  134. package/src/tools/registry.ts +0 -129
  135. package/src/tools/types.ts +0 -127
  136. package/src/utils/tapped-stream.ts +0 -60
  137. package/src/workflow/config.ts +0 -53
  138. package/src/workflow/steps/build.ts +0 -135
  139. package/src/workflow/steps/finalize.ts +0 -241
  140. package/src/workflow/steps/plan.ts +0 -167
  141. package/src/workflow/steps/research.ts +0 -223
  142. package/src/workflow/types.ts +0 -62
  143. package/src/workflow/utils.ts +0 -53
  144. package/src/worktree-manager.ts +0 -928
@@ -0,0 +1,88 @@
1
+ #!/usr/bin/env node
2
+ import { Command } from "commander";
3
+ import { z } from "zod";
4
+ import { AgentServer } from "./agent-server.js";
5
+
6
+ const envSchema = z.object({
7
+ JWT_PUBLIC_KEY: z
8
+ .string({
9
+ required_error:
10
+ "JWT_PUBLIC_KEY is required for authenticating client connections",
11
+ })
12
+ .min(1, "JWT_PUBLIC_KEY cannot be empty"),
13
+ POSTHOG_API_URL: z
14
+ .string({
15
+ required_error:
16
+ "POSTHOG_API_URL is required for LLM gateway communication",
17
+ })
18
+ .url("POSTHOG_API_URL must be a valid URL"),
19
+ POSTHOG_PERSONAL_API_KEY: z
20
+ .string({
21
+ required_error:
22
+ "POSTHOG_PERSONAL_API_KEY is required for authenticating with PostHog services",
23
+ })
24
+ .min(1, "POSTHOG_PERSONAL_API_KEY cannot be empty"),
25
+ POSTHOG_PROJECT_ID: z
26
+ .string({
27
+ required_error:
28
+ "POSTHOG_PROJECT_ID is required for routing requests to the correct project",
29
+ })
30
+ .regex(/^\d+$/, "POSTHOG_PROJECT_ID must be a numeric string")
31
+ .transform((val) => parseInt(val, 10)),
32
+ });
33
+
34
+ const program = new Command();
35
+
36
+ program
37
+ .name("agent-server")
38
+ .description("PostHog cloud agent server - runs in sandbox environments")
39
+ .option("--port <port>", "HTTP server port", "3001")
40
+ .option(
41
+ "--mode <mode>",
42
+ "Execution mode: interactive or background",
43
+ "interactive",
44
+ )
45
+ .requiredOption("--repositoryPath <path>", "Path to the repository")
46
+ .requiredOption("--taskId <id>", "Task ID")
47
+ .requiredOption("--runId <id>", "Task run ID")
48
+ .action(async (options) => {
49
+ const envResult = envSchema.safeParse(process.env);
50
+
51
+ if (!envResult.success) {
52
+ const errors = envResult.error.issues
53
+ .map((issue) => ` - ${issue.message}`)
54
+ .join("\n");
55
+ program.error(`Environment validation failed:\n${errors}`);
56
+ return;
57
+ }
58
+
59
+ const env = envResult.data;
60
+
61
+ const mode = options.mode === "background" ? "background" : "interactive";
62
+
63
+ const server = new AgentServer({
64
+ port: parseInt(options.port, 10),
65
+ jwtPublicKey: env.JWT_PUBLIC_KEY,
66
+ repositoryPath: options.repositoryPath,
67
+ apiUrl: env.POSTHOG_API_URL,
68
+ apiKey: env.POSTHOG_PERSONAL_API_KEY,
69
+ projectId: env.POSTHOG_PROJECT_ID,
70
+ mode,
71
+ taskId: options.taskId,
72
+ runId: options.runId,
73
+ });
74
+
75
+ process.on("SIGINT", async () => {
76
+ await server.stop();
77
+ process.exit(0);
78
+ });
79
+
80
+ process.on("SIGTERM", async () => {
81
+ await server.stop();
82
+ process.exit(0);
83
+ });
84
+
85
+ await server.start();
86
+ });
87
+
88
+ program.parse();
@@ -0,0 +1,65 @@
1
+ import jwt from "jsonwebtoken";
2
+ import { z } from "zod";
3
+
4
+ export const SANDBOX_CONNECTION_AUDIENCE = "posthog:sandbox_connection";
5
+
6
+ export const userDataSchema = z.object({
7
+ run_id: z.string(),
8
+ task_id: z.string(),
9
+ team_id: z.number(),
10
+ user_id: z.number(),
11
+ distinct_id: z.string(),
12
+ mode: z.enum(["interactive", "background"]).optional().default("interactive"),
13
+ });
14
+
15
+ const jwtPayloadSchema = userDataSchema.extend({
16
+ exp: z.number(),
17
+ iat: z.number().optional(),
18
+ aud: z.string().optional(),
19
+ });
20
+
21
+ export type JwtPayload = z.infer<typeof userDataSchema>;
22
+
23
+ export class JwtValidationError extends Error {
24
+ constructor(
25
+ message: string,
26
+ public code:
27
+ | "invalid_token"
28
+ | "expired"
29
+ | "invalid_signature"
30
+ | "server_error",
31
+ ) {
32
+ super(message);
33
+ this.name = "JwtValidationError";
34
+ }
35
+ }
36
+
37
+ export function validateJwt(token: string, publicKey: string): JwtPayload {
38
+ try {
39
+ const decoded = jwt.verify(token, publicKey, {
40
+ algorithms: ["RS256"],
41
+ audience: SANDBOX_CONNECTION_AUDIENCE,
42
+ });
43
+
44
+ const result = jwtPayloadSchema.safeParse(decoded);
45
+ if (!result.success) {
46
+ throw new JwtValidationError(
47
+ `Missing required fields: ${result.error.message}`,
48
+ "invalid_token",
49
+ );
50
+ }
51
+
52
+ return result.data;
53
+ } catch (error) {
54
+ if (error instanceof JwtValidationError) {
55
+ throw error;
56
+ }
57
+ if (error instanceof jwt.TokenExpiredError) {
58
+ throw new JwtValidationError("Token expired", "expired");
59
+ }
60
+ if (error instanceof jwt.JsonWebTokenError) {
61
+ throw new JwtValidationError("Invalid signature", "invalid_signature");
62
+ }
63
+ throw new JwtValidationError("Invalid token", "invalid_token");
64
+ }
65
+ }
@@ -0,0 +1,47 @@
1
+ import { z } from "zod";
2
+
3
+ export const jsonRpcRequestSchema = z.object({
4
+ jsonrpc: z.literal("2.0"),
5
+ method: z.string(),
6
+ params: z.record(z.unknown()).optional(),
7
+ id: z.union([z.string(), z.number()]).optional(),
8
+ });
9
+
10
+ export type JsonRpcRequest = z.infer<typeof jsonRpcRequestSchema>;
11
+
12
+ export const userMessageParamsSchema = z.object({
13
+ content: z.string().min(1, "Content is required"),
14
+ });
15
+
16
+ export const commandParamsSchemas = {
17
+ user_message: userMessageParamsSchema,
18
+ "posthog/user_message": userMessageParamsSchema,
19
+ cancel: z.object({}).optional(),
20
+ "posthog/cancel": z.object({}).optional(),
21
+ close: z.object({}).optional(),
22
+ "posthog/close": z.object({}).optional(),
23
+ } as const;
24
+
25
+ export type CommandMethod = keyof typeof commandParamsSchemas;
26
+
27
+ export function validateCommandParams(
28
+ method: string,
29
+ params: unknown,
30
+ ): { success: true; data: unknown } | { success: false; error: string } {
31
+ const schema =
32
+ commandParamsSchemas[method as CommandMethod] ??
33
+ commandParamsSchemas[
34
+ method.replace("posthog/", "") as keyof typeof commandParamsSchemas
35
+ ];
36
+
37
+ if (!schema) {
38
+ return { success: false, error: `Unknown method: ${method}` };
39
+ }
40
+
41
+ const result = schema.safeParse(params);
42
+ if (!result.success) {
43
+ return { success: false, error: result.error.message };
44
+ }
45
+
46
+ return { success: true, data: result.data };
47
+ }
@@ -0,0 +1,13 @@
1
+ import type { AgentMode } from "../types.js";
2
+
3
+ export interface AgentServerConfig {
4
+ port: number;
5
+ repositoryPath: string;
6
+ apiUrl: string;
7
+ apiKey: string;
8
+ projectId: number;
9
+ jwtPublicKey: string; // RS256 public key for JWT verification
10
+ mode: AgentMode;
11
+ taskId: string;
12
+ runId: string;
13
+ }
@@ -0,0 +1,122 @@
1
+ import { describe, expect, it, vi } from "vitest";
2
+ import { retry } from "./retry.js";
3
+
4
+ describe("retry", () => {
5
+ it("returns result on first success", async () => {
6
+ const fn = vi.fn().mockResolvedValue("success");
7
+
8
+ const result = await retry(fn);
9
+
10
+ expect(result).toBe("success");
11
+ expect(fn).toHaveBeenCalledTimes(1);
12
+ });
13
+
14
+ it("retries on transient error and succeeds", async () => {
15
+ const fn = vi
16
+ .fn()
17
+ .mockRejectedValueOnce(new Error("network error"))
18
+ .mockResolvedValue("success");
19
+
20
+ const result = await retry(fn, { baseDelayMs: 1 });
21
+
22
+ expect(result).toBe("success");
23
+ expect(fn).toHaveBeenCalledTimes(2);
24
+ });
25
+
26
+ it("throws after max attempts", async () => {
27
+ const fn = vi.fn().mockRejectedValue(new Error("network error"));
28
+
29
+ await expect(retry(fn, { maxAttempts: 3, baseDelayMs: 1 })).rejects.toThrow(
30
+ "network error",
31
+ );
32
+
33
+ expect(fn).toHaveBeenCalledTimes(3);
34
+ });
35
+
36
+ it("does not retry on non-transient error", async () => {
37
+ const fn = vi.fn().mockRejectedValue(new Error("validation failed"));
38
+
39
+ await expect(retry(fn, { baseDelayMs: 1 })).rejects.toThrow(
40
+ "validation failed",
41
+ );
42
+
43
+ expect(fn).toHaveBeenCalledTimes(1);
44
+ });
45
+
46
+ it("respects custom shouldRetry", async () => {
47
+ const fn = vi
48
+ .fn()
49
+ .mockRejectedValueOnce(new Error("custom error"))
50
+ .mockResolvedValue("success");
51
+
52
+ const result = await retry(fn, {
53
+ baseDelayMs: 1,
54
+ shouldRetry: (err) => err.message === "custom error",
55
+ });
56
+
57
+ expect(result).toBe("success");
58
+ expect(fn).toHaveBeenCalledTimes(2);
59
+ });
60
+
61
+ it("uses exponential backoff", async () => {
62
+ const fn = vi
63
+ .fn()
64
+ .mockRejectedValueOnce(new Error("timeout"))
65
+ .mockRejectedValueOnce(new Error("timeout"))
66
+ .mockResolvedValue("success");
67
+
68
+ const start = Date.now();
69
+ await retry(fn, { baseDelayMs: 50, maxDelayMs: 200 });
70
+ const elapsed = Date.now() - start;
71
+
72
+ // First retry: 50ms, second retry: 100ms = 150ms minimum
73
+ expect(elapsed).toBeGreaterThanOrEqual(100);
74
+ });
75
+
76
+ it("caps delay at maxDelayMs", async () => {
77
+ const fn = vi
78
+ .fn()
79
+ .mockRejectedValueOnce(new Error("timeout"))
80
+ .mockRejectedValueOnce(new Error("timeout"))
81
+ .mockRejectedValueOnce(new Error("timeout"))
82
+ .mockResolvedValue("success");
83
+
84
+ const start = Date.now();
85
+ await retry(fn, { maxAttempts: 4, baseDelayMs: 100, maxDelayMs: 150 });
86
+ const elapsed = Date.now() - start;
87
+
88
+ // With cap: 100 + 150 + 150 = 400ms max (not 100 + 200 + 400 = 700ms)
89
+ expect(elapsed).toBeLessThan(600);
90
+ });
91
+
92
+ it("retries on 429 rate limit", async () => {
93
+ const fn = vi
94
+ .fn()
95
+ .mockRejectedValueOnce(new Error("429 Too Many Requests"))
96
+ .mockResolvedValue("success");
97
+
98
+ const result = await retry(fn, { baseDelayMs: 1 });
99
+
100
+ expect(result).toBe("success");
101
+ expect(fn).toHaveBeenCalledTimes(2);
102
+ });
103
+
104
+ it("retries on 502/503 server errors", async () => {
105
+ const fn = vi
106
+ .fn()
107
+ .mockRejectedValueOnce(new Error("502 Bad Gateway"))
108
+ .mockRejectedValueOnce(new Error("503 Service Unavailable"))
109
+ .mockResolvedValue("success");
110
+
111
+ const result = await retry(fn, { baseDelayMs: 1 });
112
+
113
+ expect(result).toBe("success");
114
+ expect(fn).toHaveBeenCalledTimes(3);
115
+ });
116
+
117
+ it("converts non-Error throws to Error", async () => {
118
+ const fn = vi.fn().mockRejectedValue("string error");
119
+
120
+ await expect(retry(fn, { maxAttempts: 1 })).rejects.toThrow("string error");
121
+ });
122
+ });
@@ -0,0 +1,61 @@
1
+ export interface RetryOptions {
2
+ maxAttempts?: number;
3
+ baseDelayMs?: number;
4
+ maxDelayMs?: number;
5
+ shouldRetry?: (error: Error) => boolean;
6
+ }
7
+
8
+ const DEFAULT_OPTIONS: Required<Omit<RetryOptions, "shouldRetry">> = {
9
+ maxAttempts: 3,
10
+ baseDelayMs: 1000,
11
+ maxDelayMs: 10000,
12
+ };
13
+
14
+ function isTransientError(error: Error): boolean {
15
+ const message = error.message.toLowerCase();
16
+ return (
17
+ message.includes("network") ||
18
+ message.includes("timeout") ||
19
+ message.includes("econnreset") ||
20
+ message.includes("econnrefused") ||
21
+ message.includes("socket") ||
22
+ message.includes("503") ||
23
+ message.includes("502") ||
24
+ message.includes("429")
25
+ );
26
+ }
27
+
28
+ export async function retry<T>(
29
+ fn: () => Promise<T>,
30
+ options: RetryOptions = {},
31
+ ): Promise<T> {
32
+ const opts = { ...DEFAULT_OPTIONS, ...options };
33
+ const shouldRetry = opts.shouldRetry ?? isTransientError;
34
+
35
+ let lastError: Error | null = null;
36
+
37
+ for (let attempt = 1; attempt <= opts.maxAttempts; attempt++) {
38
+ try {
39
+ return await fn();
40
+ } catch (error) {
41
+ lastError = error instanceof Error ? error : new Error(String(error));
42
+
43
+ const isLastAttempt = attempt === opts.maxAttempts;
44
+ if (isLastAttempt || !shouldRetry(lastError)) {
45
+ throw lastError;
46
+ }
47
+
48
+ const delay = Math.min(
49
+ opts.baseDelayMs * 2 ** (attempt - 1),
50
+ opts.maxDelayMs,
51
+ );
52
+ await sleep(delay);
53
+ }
54
+ }
55
+
56
+ throw lastError ?? new Error("Retry failed with no error");
57
+ }
58
+
59
+ function sleep(ms: number): Promise<void> {
60
+ return new Promise((resolve) => setTimeout(resolve, ms));
61
+ }
@@ -0,0 +1,93 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { SseEventParser } from "./sse-parser.js";
3
+
4
+ describe("SseEventParser", () => {
5
+ it("parses complete SSE event", () => {
6
+ const parser = new SseEventParser();
7
+ const events = parser.parse('data: {"message":"hello"}\n\n');
8
+
9
+ expect(events).toHaveLength(1);
10
+ expect(events[0].data).toEqual({ message: "hello" });
11
+ });
12
+
13
+ it("parses event with id", () => {
14
+ const parser = new SseEventParser();
15
+ const events = parser.parse('id: 123\ndata: {"test":true}\n\n');
16
+
17
+ expect(events).toHaveLength(1);
18
+ expect(events[0].id).toBe("123");
19
+ expect(events[0].data).toEqual({ test: true });
20
+ });
21
+
22
+ it("handles chunked data", () => {
23
+ const parser = new SseEventParser();
24
+
25
+ const events1 = parser.parse("id: 1\n");
26
+ expect(events1).toHaveLength(0);
27
+
28
+ const events2 = parser.parse('data: {"part":"one"}');
29
+ expect(events2).toHaveLength(0);
30
+
31
+ const events3 = parser.parse("\n\n");
32
+ expect(events3).toHaveLength(1);
33
+ expect(events3[0].id).toBe("1");
34
+ expect(events3[0].data).toEqual({ part: "one" });
35
+ });
36
+
37
+ it("parses multiple events in one chunk", () => {
38
+ const parser = new SseEventParser();
39
+ const events = parser.parse('data: {"first":1}\n\ndata: {"second":2}\n\n');
40
+
41
+ expect(events).toHaveLength(2);
42
+ expect(events[0].data).toEqual({ first: 1 });
43
+ expect(events[1].data).toEqual({ second: 2 });
44
+ });
45
+
46
+ it("skips malformed JSON", () => {
47
+ const parser = new SseEventParser();
48
+ const events = parser.parse('data: not json\n\ndata: {"valid":true}\n\n');
49
+
50
+ expect(events).toHaveLength(1);
51
+ expect(events[0].data).toEqual({ valid: true });
52
+ });
53
+
54
+ it("handles empty data lines", () => {
55
+ const parser = new SseEventParser();
56
+ const events = parser.parse("data: \n\n");
57
+
58
+ expect(events).toHaveLength(0);
59
+ });
60
+
61
+ it("resets state correctly", () => {
62
+ const parser = new SseEventParser();
63
+
64
+ parser.parse("id: 1\ndata: {");
65
+ parser.reset();
66
+
67
+ const events = parser.parse('data: {"fresh":true}\n\n');
68
+
69
+ expect(events).toHaveLength(1);
70
+ expect(events[0].id).toBeUndefined();
71
+ expect(events[0].data).toEqual({ fresh: true });
72
+ });
73
+
74
+ it("handles events with whitespace in id", () => {
75
+ const parser = new SseEventParser();
76
+ const events = parser.parse('id: abc123 \ndata: {"test":1}\n\n');
77
+
78
+ expect(events).toHaveLength(1);
79
+ expect(events[0].id).toBe("abc123");
80
+ });
81
+
82
+ it("preserves incomplete line in buffer", () => {
83
+ const parser = new SseEventParser();
84
+
85
+ const events1 = parser.parse('data: {"complete":tr');
86
+ expect(events1).toHaveLength(0);
87
+
88
+ const events2 = parser.parse('ue}\n\ndata: {"next":1}\n\n');
89
+ expect(events2).toHaveLength(2);
90
+ expect(events2[0].data).toEqual({ complete: true });
91
+ expect(events2[1].data).toEqual({ next: 1 });
92
+ });
93
+ });
@@ -0,0 +1,46 @@
1
+ export interface SseEvent {
2
+ id?: string;
3
+ data: unknown;
4
+ }
5
+
6
+ export class SseEventParser {
7
+ private buffer = "";
8
+ private currentEventId: string | null = null;
9
+ private currentData: string | null = null;
10
+
11
+ parse(chunk: string): SseEvent[] {
12
+ this.buffer += chunk;
13
+ const lines = this.buffer.split("\n");
14
+ this.buffer = lines.pop() || "";
15
+
16
+ const events: SseEvent[] = [];
17
+
18
+ for (const line of lines) {
19
+ if (line.startsWith("id: ")) {
20
+ this.currentEventId = line.slice(4).trim();
21
+ } else if (line.startsWith("data: ")) {
22
+ this.currentData = line.slice(6);
23
+ } else if (line === "" && this.currentData !== null) {
24
+ try {
25
+ const data = JSON.parse(this.currentData);
26
+ events.push({
27
+ id: this.currentEventId ?? undefined,
28
+ data,
29
+ });
30
+ } catch {
31
+ // Skip malformed data
32
+ }
33
+ this.currentData = null;
34
+ this.currentEventId = null;
35
+ }
36
+ }
37
+
38
+ return events;
39
+ }
40
+
41
+ reset(): void {
42
+ this.buffer = "";
43
+ this.currentEventId = null;
44
+ this.currentData = null;
45
+ }
46
+ }
@@ -0,0 +1,140 @@
1
+ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
2
+ import { OtelLogWriter } from "./otel-log-writer.js";
3
+ import { SessionLogWriter } from "./session-log-writer.js";
4
+
5
+ // Mock the OtelLogWriter
6
+ vi.mock("./otel-log-writer.js", () => ({
7
+ OtelLogWriter: vi.fn(),
8
+ }));
9
+
10
+ describe("SessionLogWriter", () => {
11
+ let logWriter: SessionLogWriter;
12
+ let mockEmit: ReturnType<typeof vi.fn>;
13
+ let mockFlush: ReturnType<typeof vi.fn>;
14
+ let mockShutdown: ReturnType<typeof vi.fn>;
15
+
16
+ beforeEach(() => {
17
+ mockEmit = vi.fn();
18
+ mockFlush = vi.fn().mockResolvedValue(undefined);
19
+ mockShutdown = vi.fn().mockResolvedValue(undefined);
20
+
21
+ vi.mocked(OtelLogWriter).mockImplementation(
22
+ () =>
23
+ ({
24
+ emit: mockEmit,
25
+ flush: mockFlush,
26
+ shutdown: mockShutdown,
27
+ }) as unknown as OtelLogWriter,
28
+ );
29
+
30
+ logWriter = new SessionLogWriter({
31
+ otelConfig: {
32
+ posthogHost: "http://localhost:8000",
33
+ apiKey: "test-api-key",
34
+ logsPath: "/i/v1/agent-logs",
35
+ },
36
+ });
37
+ });
38
+
39
+ afterEach(() => {
40
+ vi.restoreAllMocks();
41
+ });
42
+
43
+ describe("appendRawLine", () => {
44
+ it("emits entries immediately via OtelLogWriter", () => {
45
+ const sessionId = "test-session";
46
+ logWriter.register(sessionId, { taskId: "task-1", runId: sessionId });
47
+
48
+ logWriter.appendRawLine(
49
+ sessionId,
50
+ JSON.stringify({ method: "test", params: {} }),
51
+ );
52
+ logWriter.appendRawLine(
53
+ sessionId,
54
+ JSON.stringify({ method: "test2", params: {} }),
55
+ );
56
+
57
+ expect(mockEmit).toHaveBeenCalledTimes(2);
58
+ });
59
+
60
+ it("wraps raw messages in StoredNotification format", () => {
61
+ const sessionId = "test-session";
62
+ logWriter.register(sessionId, { taskId: "task-1", runId: sessionId });
63
+
64
+ const message = {
65
+ jsonrpc: "2.0",
66
+ method: "session/update",
67
+ params: { foo: "bar" },
68
+ };
69
+ logWriter.appendRawLine(sessionId, JSON.stringify(message));
70
+
71
+ expect(mockEmit).toHaveBeenCalledTimes(1);
72
+ const emitArg = mockEmit.mock.calls[0][0];
73
+ expect(emitArg.notification.type).toBe("notification");
74
+ expect(emitArg.notification.timestamp).toBeDefined();
75
+ expect(emitArg.notification.notification).toEqual(message);
76
+ });
77
+
78
+ it("ignores unregistered sessions", () => {
79
+ logWriter.appendRawLine(
80
+ "unknown-session",
81
+ JSON.stringify({ method: "test" }),
82
+ );
83
+
84
+ expect(mockEmit).not.toHaveBeenCalled();
85
+ });
86
+
87
+ it("ignores invalid JSON", () => {
88
+ const sessionId = "test-session";
89
+ logWriter.register(sessionId, { taskId: "task-1", runId: sessionId });
90
+
91
+ logWriter.appendRawLine(sessionId, "not valid json {{{");
92
+
93
+ expect(mockEmit).not.toHaveBeenCalled();
94
+ });
95
+ });
96
+
97
+ describe("flush", () => {
98
+ it("calls flush on OtelLogWriter", async () => {
99
+ const sessionId = "test-session";
100
+ logWriter.register(sessionId, { taskId: "task-1", runId: sessionId });
101
+
102
+ logWriter.appendRawLine(sessionId, JSON.stringify({ method: "test" }));
103
+ await logWriter.flush(sessionId);
104
+
105
+ expect(mockFlush).toHaveBeenCalledTimes(1);
106
+ });
107
+
108
+ it("does nothing for unregistered sessions", async () => {
109
+ await logWriter.flush("unknown-session");
110
+
111
+ expect(mockFlush).not.toHaveBeenCalled();
112
+ });
113
+ });
114
+
115
+ describe("register", () => {
116
+ it("creates OtelLogWriter with session context", () => {
117
+ const sessionId = "test-session";
118
+ const context = { taskId: "task-1", runId: sessionId };
119
+
120
+ logWriter.register(sessionId, context);
121
+
122
+ expect(OtelLogWriter).toHaveBeenCalledWith(
123
+ expect.objectContaining({
124
+ posthogHost: "http://localhost:8000",
125
+ apiKey: "test-api-key",
126
+ }),
127
+ context,
128
+ expect.anything(),
129
+ );
130
+ });
131
+
132
+ it("does not re-register existing sessions", () => {
133
+ const sessionId = "test-session";
134
+ logWriter.register(sessionId, { taskId: "task-1", runId: sessionId });
135
+ logWriter.register(sessionId, { taskId: "task-2", runId: sessionId });
136
+
137
+ expect(OtelLogWriter).toHaveBeenCalledTimes(1);
138
+ });
139
+ });
140
+ });