@cursor/july 0.1.20 → 0.1.21

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 (212) hide show
  1. package/dist/bin/agent-serve.js +0 -0
  2. package/dist/channels/github/instrument.d.ts +20 -0
  3. package/dist/channels/github/instrument.d.ts.map +1 -0
  4. package/dist/channels/slack/api.d.ts +2 -0
  5. package/dist/channels/slack/api.d.ts.map +1 -1
  6. package/dist/channels/slack/api.js +3 -0
  7. package/dist/channels/slack/bot-mentions.d.ts +106 -0
  8. package/dist/channels/slack/bot-mentions.d.ts.map +1 -0
  9. package/dist/channels/slack/bot-mentions.js +243 -0
  10. package/dist/channels/slack/dispatch.d.ts +6 -0
  11. package/dist/channels/slack/dispatch.d.ts.map +1 -1
  12. package/dist/channels/slack/dispatch.js +24 -3
  13. package/dist/channels/slack/inbound.d.ts +10 -1
  14. package/dist/channels/slack/inbound.d.ts.map +1 -1
  15. package/dist/channels/slack/inbound.js +12 -4
  16. package/dist/channels/slack/index.d.ts +1 -0
  17. package/dist/channels/slack/index.d.ts.map +1 -1
  18. package/dist/channels/slack/index.js +1 -0
  19. package/dist/channels/slack/slack-channel.d.ts.map +1 -1
  20. package/dist/channels/slack/slack-channel.js +61 -22
  21. package/dist/channels/slack/types.d.ts +58 -0
  22. package/dist/channels/slack/types.d.ts.map +1 -1
  23. package/dist/docs/404.html +1 -1
  24. package/dist/docs/ab.html +2 -2
  25. package/dist/docs/assets/{app.T-5ImnM3.js → app.jDxLzWv4.js} +1 -1
  26. package/dist/docs/assets/chunks/@localSearchIndexroot.DoJHJjqF.js +1 -0
  27. package/dist/docs/assets/chunks/{VPLocalSearchBox.OMGMWH6H.js → VPLocalSearchBox.Y6bDR1-a.js} +1 -1
  28. package/dist/docs/assets/chunks/{theme.DLg4yB4D.js → theme.CLazCWlJ.js} +2 -2
  29. package/dist/docs/building-with-agents.html +2 -2
  30. package/dist/docs/concepts.html +2 -2
  31. package/dist/docs/deployment.html +2 -2
  32. package/dist/docs/evals.html +2 -2
  33. package/dist/docs/example-agents/approval-buddy.html +2 -2
  34. package/dist/docs/example-agents/benny.html +2 -2
  35. package/dist/docs/example-agents/bugbot.html +2 -2
  36. package/dist/docs/example-agents/codebase-wiki.html +2 -2
  37. package/dist/docs/example-agents/codeowners-review.html +2 -2
  38. package/dist/docs/example-agents/concierge.html +2 -2
  39. package/dist/docs/example-agents/fsd.html +2 -2
  40. package/dist/docs/example-agents/index.html +2 -2
  41. package/dist/docs/example-agents/knowledge-base.html +2 -2
  42. package/dist/docs/example-agents/oncall.html +2 -2
  43. package/dist/docs/example-agents/security-reviewer.html +2 -2
  44. package/dist/docs/example-agents/slack-agent.html +2 -2
  45. package/dist/docs/example-agents/weather-agent.html +2 -2
  46. package/dist/docs/guides/agent-to-agent.html +2 -2
  47. package/dist/docs/guides/cloud-runtime.html +2 -2
  48. package/dist/docs/guides/github.html +2 -2
  49. package/dist/docs/guides/human-in-the-loop.html +2 -2
  50. package/dist/docs/guides/mcp-oauth.html +2 -2
  51. package/dist/docs/guides/slack.html +2 -2
  52. package/dist/docs/guides/webhooks.html +2 -2
  53. package/dist/docs/hillclimbing.html +2 -2
  54. package/dist/docs/index.html +2 -2
  55. package/dist/docs/quickstart.html +2 -2
  56. package/dist/docs/reference/agent-config.html +2 -2
  57. package/dist/docs/reference/channels.html +2 -2
  58. package/dist/docs/reference/cli.html +2 -2
  59. package/dist/docs/reference/connections.html +2 -2
  60. package/dist/docs/reference/hooks.html +2 -2
  61. package/dist/docs/reference/http-api.html +2 -2
  62. package/dist/docs/reference/instructions.html +2 -2
  63. package/dist/docs/reference/playground.html +2 -2
  64. package/dist/docs/reference/project-layout.html +2 -2
  65. package/dist/docs/reference/prompt.html +2 -2
  66. package/dist/docs/reference/schedules.html +2 -2
  67. package/dist/docs/reference/sessions.html +2 -2
  68. package/dist/docs/reference/skills.html +2 -2
  69. package/dist/docs/reference/subagents.html +2 -2
  70. package/dist/docs/reference/tools.html +2 -2
  71. package/dist/docs/scaffolding-agents.html +2 -2
  72. package/dist/docs/storage.html +2 -2
  73. package/dist/docs/troubleshooting.html +2 -2
  74. package/dist/internal/json-dir-store.d.ts +32 -0
  75. package/dist/internal/json-dir-store.d.ts.map +1 -0
  76. package/dist/internal/json-dir-store.js +100 -0
  77. package/dist/internal/resolved-connections.d.ts +1 -1
  78. package/dist/internal/resolved-connections.d.ts.map +1 -1
  79. package/dist/internal/resolved-connections.js +10 -0
  80. package/dist/internal/session-engine.d.ts +4 -1
  81. package/dist/internal/session-engine.d.ts.map +1 -1
  82. package/dist/internal/session-engine.js +13 -2
  83. package/dist/playground/assets/index-CjOQ4hN9.css +1 -0
  84. package/dist/playground/assets/index-dshZQJCp.js +85 -0
  85. package/dist/playground/index.html +2 -2
  86. package/dist/types.d.ts +7 -0
  87. package/dist/types.d.ts.map +1 -1
  88. package/dist/types.js +9 -0
  89. package/package.json +24 -24
  90. package/src/bin/agent-serve.version.test.ts +62 -0
  91. package/src/channels/github/api.test.ts +64 -0
  92. package/src/channels/github/auth.test.ts +105 -0
  93. package/src/channels/github/cursor-account.test.ts +204 -0
  94. package/src/channels/github/forward.test.ts +457 -0
  95. package/src/channels/github/github.test.ts +937 -0
  96. package/src/channels/github/replay.test.ts +179 -0
  97. package/src/channels/slack/api.post-message.test.ts +148 -0
  98. package/src/channels/slack/api.ts +5 -0
  99. package/src/channels/slack/approvals.test.ts +328 -0
  100. package/src/channels/slack/block-actions.test.ts +452 -0
  101. package/src/channels/slack/bot-mentions.test.ts +217 -0
  102. package/src/channels/slack/bot-mentions.ts +315 -0
  103. package/src/channels/slack/channel-watch.test.ts +363 -0
  104. package/src/channels/slack/cursor-account.test.ts +253 -0
  105. package/src/channels/slack/defaults.final-post.test.ts +182 -0
  106. package/src/channels/slack/dispatch.test.ts +795 -0
  107. package/src/channels/slack/dispatch.ts +27 -1
  108. package/src/channels/slack/eval-directive.test.ts +273 -0
  109. package/src/channels/slack/inbound.ts +25 -4
  110. package/src/channels/slack/index.ts +1 -0
  111. package/src/channels/slack/message-body.test.ts +54 -0
  112. package/src/channels/slack/nudge-store.test.ts +143 -0
  113. package/src/channels/slack/slack-channel.ts +66 -8
  114. package/src/channels/slack/slack.test.ts +391 -0
  115. package/src/channels/slack/stop.test.ts +23 -0
  116. package/src/channels/slack/thread-context.test.ts +202 -0
  117. package/src/channels/slack/types.ts +60 -0
  118. package/src/evals/assertions.test.ts +580 -0
  119. package/src/evals/expect.test.ts +144 -0
  120. package/src/evals/judge.test.ts +181 -0
  121. package/src/evals/loaders.test.ts +132 -0
  122. package/src/evals/matchers.test.ts +95 -0
  123. package/src/evals/reporters.test.ts +303 -0
  124. package/src/evals/run-facts.test.ts +259 -0
  125. package/src/internal/ab-snapshot.test.ts +325 -0
  126. package/src/internal/approval-gate.test.ts +49 -0
  127. package/src/internal/approvals.integration.test.ts +383 -0
  128. package/src/internal/authored-loaders.test.ts +31 -0
  129. package/src/internal/builtin-tools/reminders.test.ts +201 -0
  130. package/src/internal/channel-route-schema.test.ts +294 -0
  131. package/src/internal/chat-attach.test.ts +262 -0
  132. package/src/internal/cli-deploy.test.ts +1991 -0
  133. package/src/internal/cli-mcp.test.ts +789 -0
  134. package/src/internal/cli-skills.test.ts +133 -0
  135. package/src/internal/cli-slack.test.ts +1647 -0
  136. package/src/internal/cloud-merge.test.ts +74 -0
  137. package/src/internal/cron.test.ts +22 -0
  138. package/src/internal/cursor/account-mcp.test.ts +807 -0
  139. package/src/internal/cursor/backend-client.test.ts +591 -0
  140. package/src/internal/cursor/credentials.test.ts +351 -0
  141. package/src/internal/cursor/github-credentials.test.ts +136 -0
  142. package/src/internal/cursor-account-mcp-auth.test.ts +310 -0
  143. package/src/internal/cursor-account.integration.test.ts +441 -0
  144. package/src/internal/cursor-event-relay.test.ts +746 -0
  145. package/src/internal/cursor-github-credentials.integration.test.ts +271 -0
  146. package/src/internal/cursor-slack-relay.test.ts +525 -0
  147. package/src/internal/deploy-source.test.ts +111 -0
  148. package/src/internal/discovery.builtin-tools.test.ts +94 -0
  149. package/src/internal/discovery.concurrency.test.ts +60 -0
  150. package/src/internal/discovery.cursor-account.test.ts +133 -0
  151. package/src/internal/discovery.cwd.test.ts +83 -0
  152. package/src/internal/discovery.hosting.test.ts +80 -0
  153. package/src/internal/discovery.identity.test.ts +44 -0
  154. package/src/internal/docs-site.test.ts +66 -0
  155. package/src/internal/duration.test.ts +29 -0
  156. package/src/internal/eval-judge-model.test.ts +187 -0
  157. package/src/internal/eval-run-store.cancel.test.ts +142 -0
  158. package/src/internal/eval-run-store.storage.test.ts +211 -0
  159. package/src/internal/eval-runner.http.test.ts +403 -0
  160. package/src/internal/eval-runner.run.test.ts +928 -0
  161. package/src/internal/evals-client.test.ts +307 -0
  162. package/src/internal/event-mapper.test.ts +243 -0
  163. package/src/internal/github-fanout.test.ts +213 -0
  164. package/src/internal/handleAgentServeTrigger.test.ts +179 -0
  165. package/src/internal/host-kv.test.ts +82 -0
  166. package/src/internal/host-platforms.test.ts +126 -0
  167. package/src/internal/http-channel.test.ts +402 -0
  168. package/src/internal/init-project.test.ts +269 -0
  169. package/src/internal/install-cursor-skills.test.ts +262 -0
  170. package/src/internal/local-env.test.ts +100 -0
  171. package/src/internal/log-ring.test.ts +31 -0
  172. package/src/internal/logs-client.test.ts +350 -0
  173. package/src/internal/mcp-endpoint.test.ts +436 -0
  174. package/src/internal/mcp-host.test.ts +298 -0
  175. package/src/internal/mcp-oauth.test.ts +148 -0
  176. package/src/internal/net.test.ts +17 -0
  177. package/src/internal/peer-connections.test.ts +128 -0
  178. package/src/internal/peer-mcp.integration.test.ts +289 -0
  179. package/src/internal/playground/toolchain.test.ts +53 -0
  180. package/src/internal/playground-cli.test.ts +187 -0
  181. package/src/internal/playground-proxy.test.ts +376 -0
  182. package/src/internal/prompt-context.integration.test.ts +232 -0
  183. package/src/internal/prompt-context.test.ts +127 -0
  184. package/src/internal/reminder-runner.test.ts +390 -0
  185. package/src/internal/reminder-store.test.ts +53 -0
  186. package/src/internal/request-headers.test.ts +27 -0
  187. package/src/internal/resolve-prod-target.test.ts +787 -0
  188. package/src/internal/resolved-connections.test.ts +295 -0
  189. package/src/internal/resolved-connections.ts +18 -1
  190. package/src/internal/router.test.ts +57 -0
  191. package/src/internal/sdk-runner.test.ts +290 -0
  192. package/src/internal/session-engine.coalesce.test.ts +169 -0
  193. package/src/internal/session-engine.concurrency.test.ts +250 -0
  194. package/src/internal/session-engine.host-oauth-mcp.test.ts +110 -0
  195. package/src/internal/session-engine.interrupt.test.ts +577 -0
  196. package/src/internal/session-engine.storage.test.ts +547 -0
  197. package/src/internal/session-engine.ts +15 -1
  198. package/src/internal/session-urls.test.ts +28 -0
  199. package/src/internal/sessions-client.test.ts +518 -0
  200. package/src/internal/storage-coordinator.test.ts +517 -0
  201. package/src/internal/tool-call.test.ts +458 -0
  202. package/src/internal/tool-result.test.ts +52 -0
  203. package/src/internal/trajectory.approvals.test.ts +83 -0
  204. package/src/internal/trajectory.subagents.test.ts +198 -0
  205. package/src/internal/turn-governor.test.ts +137 -0
  206. package/src/internal/update-check.test.ts +485 -0
  207. package/src/internal/workspace.test.ts +81 -0
  208. package/src/storage-backends/cursor-hosted.test.ts +121 -0
  209. package/src/types.ts +12 -0
  210. package/dist/docs/assets/chunks/@localSearchIndexroot.CvejISXF.js +0 -1
  211. package/dist/playground/assets/index-CidizGZv.css +0 -1
  212. package/dist/playground/assets/index-CuiAKZaP.js +0 -85
@@ -0,0 +1,746 @@
1
+ import { mkdtemp, readFile, rm } from "node:fs/promises";
2
+ import { tmpdir } from "node:os";
3
+ import { join } from "node:path";
4
+ import { afterEach, beforeEach, describe, expect, it } from "vitest";
5
+ import { httpChannel } from "../channels.js";
6
+ import type { AgentProject, AgentServeHandle } from "../types.js";
7
+ import {
8
+ CursorEventRelay,
9
+ type CursorScmEvent,
10
+ formatCursorScmEventLog,
11
+ RelayOffsetStore,
12
+ scmMetadataToGitHubPayload,
13
+ } from "./cursor-event-relay.js";
14
+ import type { AgentRunner } from "./sdk-runner.js";
15
+ import { startServer } from "./server.js";
16
+
17
+ interface RecordedRequest {
18
+ method: string;
19
+ url: string;
20
+ body?: unknown;
21
+ }
22
+
23
+ /**
24
+ * Minimal fake of the /v0/scm-events surface: records requests and serves a
25
+ * scripted queue of poll responses (after the queue drains, polls return
26
+ * empty pages). Registration echoes the requested repos as subscribed;
27
+ * `registrationFailures` makes the first N registration attempts fail with
28
+ * a 500.
29
+ */
30
+ function fakeBackend(script: {
31
+ anchorOffset?: string;
32
+ polls?: Array<{
33
+ events: CursorScmEvent[];
34
+ nextOffset: string;
35
+ gapDetected?: boolean;
36
+ }>;
37
+ registrationFailures?: number;
38
+ }) {
39
+ const requests: RecordedRequest[] = [];
40
+ const polls = [...(script.polls ?? [])];
41
+ let registrationFailures = script.registrationFailures ?? 0;
42
+ const fetchImpl: typeof fetch = async (input, init) => {
43
+ const url = String(input);
44
+ const method = init?.method ?? "GET";
45
+ const body =
46
+ typeof init?.body === "string"
47
+ ? (JSON.parse(init.body) as unknown)
48
+ : undefined;
49
+ requests.push({ method, url, ...(body === undefined ? {} : { body }) });
50
+
51
+ if (url.includes("/v0/scm-events/subscriptions")) {
52
+ if (registrationFailures > 0) {
53
+ registrationFailures -= 1;
54
+ return new Response("storage unavailable", { status: 503 });
55
+ }
56
+ return jsonResponse({
57
+ repos: (
58
+ (body as { subscriptions?: Array<{ repo: string }> })
59
+ ?.subscriptions ?? []
60
+ ).map((subscription) => ({
61
+ provider: "github",
62
+ repo: subscription.repo,
63
+ isSubscribed: true,
64
+ })),
65
+ expiresAt: new Date().toISOString(),
66
+ });
67
+ }
68
+ if (url.includes("after=%24") || url.includes("after=$")) {
69
+ return jsonResponse({
70
+ events: [],
71
+ nextOffset: script.anchorOffset ?? "",
72
+ gapDetected: false,
73
+ });
74
+ }
75
+ const page = polls.shift() ?? {
76
+ events: [],
77
+ nextOffset: "",
78
+ gapDetected: false,
79
+ };
80
+ return jsonResponse(page);
81
+ };
82
+ return { requests, fetchImpl };
83
+ }
84
+
85
+ function jsonResponse(body: unknown): Response {
86
+ return new Response(JSON.stringify(body), {
87
+ status: 200,
88
+ headers: { "content-type": "application/json" },
89
+ });
90
+ }
91
+
92
+ function event(
93
+ offset: string,
94
+ eventName: string,
95
+ repo = "acme/repo"
96
+ ): CursorScmEvent {
97
+ return {
98
+ offset,
99
+ provider: "github",
100
+ eventName,
101
+ deliveryId: `delivery-${offset}`,
102
+ repo,
103
+ receivedAtMs: Date.now(),
104
+ };
105
+ }
106
+
107
+ describe("formatCursorScmEventLog", () => {
108
+ it("includes PR url, action, and timestamps", () => {
109
+ expect(
110
+ formatCursorScmEventLog({
111
+ eventName: "issue_comment",
112
+ action: "created",
113
+ repo: "anysphere/everysphere",
114
+ prNumber: 167588,
115
+ prUrl: "https://github.com/anysphere/everysphere/pull/167588",
116
+ receivedAtMs: Date.parse("2026-07-13T16:00:00.000Z"),
117
+ occurredAtMs: Date.parse("2026-07-13T15:59:00.000Z"),
118
+ deliveryId: "abc",
119
+ })
120
+ ).toBe(
121
+ "issue_comment.created on https://github.com/anysphere/everysphere/pull/167588 received=2026-07-13T16:00:00.000Z occurred=2026-07-13T15:59:00.000Z (abc)"
122
+ );
123
+ });
124
+ });
125
+
126
+ describe("scmMetadataToGitHubPayload", () => {
127
+ it("stubs a Zod-complete pull_request when rich meta is present", () => {
128
+ const payload = scmMetadataToGitHubPayload({
129
+ repo: "acme/repo",
130
+ action: "opened",
131
+ prNumber: 12,
132
+ prUrl: "https://github.com/acme/repo/pull/12",
133
+ pullRequest: {
134
+ number: 12,
135
+ url: "https://github.com/acme/repo/pull/12",
136
+ title: "Add widgets",
137
+ body: null,
138
+ draft: false,
139
+ merged: false,
140
+ authorLogin: "bob",
141
+ headRef: "feature",
142
+ headSha: "aaa111",
143
+ baseRef: "main",
144
+ baseSha: "bbb222",
145
+ },
146
+ });
147
+ expect(payload).toEqual({
148
+ action: "opened",
149
+ repository: {
150
+ full_name: "acme/repo",
151
+ name: "repo",
152
+ html_url: "https://github.com/acme/repo",
153
+ owner: { login: "acme" },
154
+ },
155
+ pull_request: {
156
+ number: 12,
157
+ html_url: "https://github.com/acme/repo/pull/12",
158
+ title: "Add widgets",
159
+ body: null,
160
+ draft: false,
161
+ merged: false,
162
+ user: { login: "bob" },
163
+ head: { ref: "feature", sha: "aaa111" },
164
+ base: { ref: "main", sha: "bbb222" },
165
+ },
166
+ issue: {
167
+ number: 12,
168
+ html_url: "https://github.com/acme/repo/pull/12",
169
+ pull_request: {},
170
+ },
171
+ });
172
+ });
173
+
174
+ it("stubs merged=true so closed+merged handlers can fire", () => {
175
+ const payload = scmMetadataToGitHubPayload({
176
+ repo: "acme/repo",
177
+ action: "closed",
178
+ prNumber: 99,
179
+ pullRequest: {
180
+ number: 99,
181
+ url: "https://github.com/acme/repo/pull/99",
182
+ title: "Ship it",
183
+ draft: false,
184
+ merged: true,
185
+ authorLogin: "alice",
186
+ headRef: "ship",
187
+ headSha: "aaa",
188
+ baseRef: "main",
189
+ baseSha: "bbb",
190
+ },
191
+ });
192
+ expect(payload.action).toBe("closed");
193
+ expect(
194
+ (payload.pull_request as { merged?: boolean } | undefined)?.merged
195
+ ).toBe(true);
196
+ });
197
+
198
+ it("falls back to identity-only stubs without head/base", () => {
199
+ expect(
200
+ scmMetadataToGitHubPayload({
201
+ repo: "acme/repo",
202
+ action: "opened",
203
+ prNumber: 12,
204
+ prUrl: "https://github.com/acme/repo/pull/12",
205
+ pullRequest: {
206
+ number: 12,
207
+ url: "https://github.com/acme/repo/pull/12",
208
+ },
209
+ })
210
+ ).toEqual({
211
+ action: "opened",
212
+ repository: {
213
+ full_name: "acme/repo",
214
+ name: "repo",
215
+ html_url: "https://github.com/acme/repo",
216
+ owner: { login: "acme" },
217
+ },
218
+ pull_request: {
219
+ number: 12,
220
+ html_url: "https://github.com/acme/repo/pull/12",
221
+ },
222
+ issue: {
223
+ number: 12,
224
+ html_url: "https://github.com/acme/repo/pull/12",
225
+ pull_request: {},
226
+ },
227
+ });
228
+ });
229
+
230
+ it("stubs a Zod-complete comment for issue_comment envelopes", () => {
231
+ const payload = scmMetadataToGitHubPayload({
232
+ repo: "acme/repo",
233
+ action: "created",
234
+ prNumber: 12,
235
+ prUrl: "https://github.com/acme/repo/pull/12",
236
+ comment: {
237
+ id: 5551212,
238
+ body: "@my-agent rereview",
239
+ htmlUrl: "https://github.com/acme/repo/pull/12#issuecomment-5551212",
240
+ authorLogin: "alice",
241
+ authorType: "User",
242
+ },
243
+ });
244
+ expect(payload.comment).toEqual({
245
+ id: 5551212,
246
+ body: "@my-agent rereview",
247
+ html_url: "https://github.com/acme/repo/pull/12#issuecomment-5551212",
248
+ user: { login: "alice", type: "User" },
249
+ });
250
+ expect(payload.sender).toEqual({ login: "alice", type: "User" });
251
+ expect(payload.issue).toEqual({
252
+ number: 12,
253
+ html_url: "https://github.com/acme/repo/pull/12",
254
+ pull_request: {},
255
+ });
256
+ });
257
+
258
+ it("carries review-thread anchors for pull_request_review_comment", () => {
259
+ const payload = scmMetadataToGitHubPayload({
260
+ repo: "acme/repo",
261
+ action: "created",
262
+ prNumber: 12,
263
+ comment: {
264
+ id: 901,
265
+ body: "nit",
266
+ htmlUrl: "https://github.com/acme/repo/pull/12#discussion_r901",
267
+ authorLogin: "bob",
268
+ path: "src/index.ts",
269
+ line: 3,
270
+ pullRequestReviewId: 77,
271
+ inReplyToId: 900,
272
+ },
273
+ });
274
+ expect(payload.comment).toEqual({
275
+ id: 901,
276
+ body: "nit",
277
+ html_url: "https://github.com/acme/repo/pull/12#discussion_r901",
278
+ user: { login: "bob" },
279
+ path: "src/index.ts",
280
+ line: 3,
281
+ pull_request_review_id: 77,
282
+ in_reply_to_id: 900,
283
+ });
284
+ });
285
+ });
286
+
287
+ async function waitFor(
288
+ predicate: () => boolean,
289
+ timeoutMs = 2_000
290
+ ): Promise<void> {
291
+ const deadline = Date.now() + timeoutMs;
292
+ while (!predicate()) {
293
+ if (Date.now() > deadline) {
294
+ throw new Error("waitFor timed out");
295
+ }
296
+ await new Promise((resolve) => setTimeout(resolve, 10));
297
+ }
298
+ }
299
+
300
+ describe("RelayOffsetStore", () => {
301
+ let dir: string;
302
+
303
+ afterEach(async () => {
304
+ if (dir !== undefined) {
305
+ await rm(dir, { recursive: true, force: true });
306
+ }
307
+ });
308
+
309
+ it("round-trips offsets and tolerates a missing file", async () => {
310
+ dir = await mkdtemp(join(tmpdir(), "cursor-events-"));
311
+ const store = new RelayOffsetStore(dir);
312
+ expect(await store.load()).toBeUndefined();
313
+ await store.save("123-4");
314
+ expect(await store.load()).toBe("123-4");
315
+ const raw = JSON.parse(await readFile(join(dir, "offset.json"), "utf8"));
316
+ expect(raw.version).toBe(1);
317
+ });
318
+ });
319
+
320
+ describe("CursorEventRelay", () => {
321
+ let dir: string;
322
+ let relay: CursorEventRelay | undefined;
323
+
324
+ afterEach(async () => {
325
+ await relay?.stop();
326
+ relay = undefined;
327
+ if (dir !== undefined) {
328
+ await rm(dir, { recursive: true, force: true });
329
+ }
330
+ });
331
+
332
+ function makeRelay(
333
+ backend: ReturnType<typeof fakeBackend>,
334
+ options?: {
335
+ dispatch?: (e: CursorScmEvent) => Promise<void>;
336
+ logger?: (line: string) => void;
337
+ repos?: string[];
338
+ events?: string[];
339
+ }
340
+ ): { dispatched: CursorScmEvent[] } {
341
+ const dispatched: CursorScmEvent[] = [];
342
+ relay = new CursorEventRelay({
343
+ baseUrl: "https://api2.example",
344
+ apiKey: "key",
345
+ repos: options?.repos ?? ["acme/repo"],
346
+ events: options?.events ?? ["pull_request", "issue_comment", "push"],
347
+ stateDir: dir,
348
+ dispatch:
349
+ options?.dispatch ??
350
+ (async (e) => {
351
+ dispatched.push(e);
352
+ }),
353
+ logger: options?.logger ?? (() => {}),
354
+ fetchImpl: backend.fetchImpl,
355
+ pollWaitMs: 0,
356
+ });
357
+ relay.start();
358
+ return { dispatched };
359
+ }
360
+
361
+ it("registers before consuming, anchors at now, dispatches in order, persists offset and consumer id", async () => {
362
+ dir = await mkdtemp(join(tmpdir(), "cursor-events-"));
363
+ const backend = fakeBackend({
364
+ anchorOffset: "100-0",
365
+ polls: [
366
+ {
367
+ events: [
368
+ event("101-0", "pull_request"),
369
+ event("102-0", "issue_comment"),
370
+ ],
371
+ nextOffset: "102-0",
372
+ },
373
+ ],
374
+ });
375
+ const { dispatched } = makeRelay(backend);
376
+
377
+ await waitFor(() => dispatched.length === 2);
378
+ expect(dispatched.map((e) => e.eventName)).toEqual([
379
+ "pull_request",
380
+ "issue_comment",
381
+ ]);
382
+ await waitFor(() =>
383
+ backend.requests.some((r) => r.url.includes("after=102-0"))
384
+ );
385
+ await relay?.stop();
386
+ relay = undefined;
387
+
388
+ const store = new RelayOffsetStore(dir);
389
+ expect(await store.load()).toBe("102-0");
390
+
391
+ // Consumption is registration-gated: the first request is the PUT, and
392
+ // no GET happens before it.
393
+ expect(backend.requests[0]?.method).toBe("PUT");
394
+ const registration = backend.requests[0];
395
+ expect(registration?.body).toMatchObject({
396
+ subscriptions: [
397
+ {
398
+ provider: "github",
399
+ repo: "acme/repo",
400
+ events: ["pull_request", "issue_comment", "push"],
401
+ },
402
+ ],
403
+ });
404
+ const consumerId = (registration?.body as { consumerId?: string })
405
+ ?.consumerId;
406
+ expect(typeof consumerId).toBe("string");
407
+ expect(consumerId?.length).toBeGreaterThan(0);
408
+ // The consumer id is durable in the state dir.
409
+ expect((await readFile(join(dir, "consumer-id"), "utf8")).trim()).toBe(
410
+ consumerId
411
+ );
412
+ // The first poll after anchoring resumes from the anchor offset.
413
+ expect(
414
+ backend.requests.some(
415
+ (r) => r.method === "GET" && r.url.includes("after=100-0")
416
+ )
417
+ ).toBe(true);
418
+ });
419
+
420
+ it("retries registration until it succeeds before any consumption", async () => {
421
+ dir = await mkdtemp(join(tmpdir(), "cursor-events-"));
422
+ const backend = fakeBackend({
423
+ anchorOffset: "10-0",
424
+ polls: [{ events: [event("11-0", "pull_request")], nextOffset: "11-0" }],
425
+ registrationFailures: 1,
426
+ });
427
+ const { dispatched } = makeRelay(backend);
428
+
429
+ await waitFor(() => dispatched.length === 1, 10_000);
430
+ const firstGetIndex = backend.requests.findIndex((r) => r.method === "GET");
431
+ const successfulPutIndex = backend.requests.findIndex(
432
+ (r, index) => r.method === "PUT" && index > 0
433
+ );
434
+ // Two PUTs (one failed, one ok) happened before the first GET.
435
+ expect(firstGetIndex).toBeGreaterThan(successfulPutIndex);
436
+ expect(
437
+ backend.requests.filter((r) => r.method === "PUT").length
438
+ ).toBeGreaterThanOrEqual(2);
439
+ }, 15_000);
440
+
441
+ it("deregisters on clean stop so the registration does not linger by TTL", async () => {
442
+ dir = await mkdtemp(join(tmpdir(), "cursor-events-"));
443
+ const backend = fakeBackend({ anchorOffset: "1-0" });
444
+ makeRelay(backend);
445
+ await waitFor(() => backend.requests.some((r) => r.method === "GET"));
446
+ const consumerId = (backend.requests[0]?.body as { consumerId?: string })
447
+ ?.consumerId;
448
+
449
+ await relay?.stop();
450
+ relay = undefined;
451
+
452
+ const lastPut = backend.requests.filter((r) => r.method === "PUT").at(-1);
453
+ expect(lastPut?.body).toEqual({ consumerId, subscriptions: [] });
454
+ });
455
+
456
+ it("does not deregister when registration never succeeded", async () => {
457
+ dir = await mkdtemp(join(tmpdir(), "cursor-events-"));
458
+ // Registration keeps failing; stop before it ever succeeds.
459
+ const backend = fakeBackend({ registrationFailures: 1_000 });
460
+ makeRelay(backend);
461
+ await waitFor(() => backend.requests.some((r) => r.method === "PUT"));
462
+
463
+ await relay?.stop();
464
+ relay = undefined;
465
+
466
+ expect(
467
+ backend.requests.some(
468
+ (r) =>
469
+ r.method === "PUT" &&
470
+ Array.isArray(
471
+ (r.body as { subscriptions?: unknown[] })?.subscriptions
472
+ ) &&
473
+ (r.body as { subscriptions: unknown[] }).subscriptions.length === 0
474
+ )
475
+ ).toBe(false);
476
+ });
477
+
478
+ it("retries a transiently failing dispatch before advancing the offset", async () => {
479
+ dir = await mkdtemp(join(tmpdir(), "cursor-events-"));
480
+ const backend = fakeBackend({
481
+ anchorOffset: "1-0",
482
+ polls: [{ events: [event("2-0", "pull_request")], nextOffset: "2-0" }],
483
+ });
484
+ const logs: string[] = [];
485
+ const delivered: CursorScmEvent[] = [];
486
+ let attempts = 0;
487
+ makeRelay(backend, {
488
+ logger: (line) => logs.push(line),
489
+ dispatch: async (e) => {
490
+ attempts += 1;
491
+ if (attempts === 1) {
492
+ throw new Error("read ECONNRESET");
493
+ }
494
+ delivered.push(e);
495
+ },
496
+ });
497
+
498
+ await waitFor(() => delivered.length === 1, 5_000);
499
+ expect(attempts).toBe(2);
500
+ expect(
501
+ logs.some(
502
+ (line) =>
503
+ line.includes("dispatch attempt 1") && line.includes("retrying")
504
+ )
505
+ ).toBe(true);
506
+ await relay?.stop();
507
+ relay = undefined;
508
+ const store = new RelayOffsetStore(dir);
509
+ expect(await store.load()).toBe("2-0");
510
+ });
511
+
512
+ it("resumes from a persisted offset and dedupes replayed events", async () => {
513
+ dir = await mkdtemp(join(tmpdir(), "cursor-events-"));
514
+ const store = new RelayOffsetStore(dir);
515
+ await store.save("200-0");
516
+
517
+ const backend = fakeBackend({
518
+ polls: [
519
+ {
520
+ // 200-0 replayed (at-least-once); only 201-0 must dispatch.
521
+ events: [event("200-0", "pull_request"), event("201-0", "push")],
522
+ nextOffset: "201-0",
523
+ },
524
+ ],
525
+ });
526
+ const { dispatched } = makeRelay(backend);
527
+
528
+ await waitFor(() => dispatched.length >= 1);
529
+ // No anchor request: the persisted offset is the resume point.
530
+ expect(
531
+ backend.requests.some(
532
+ (r) => r.method === "GET" && r.url.includes("after=%24")
533
+ )
534
+ ).toBe(false);
535
+ expect(
536
+ backend.requests.some(
537
+ (r) => r.method === "GET" && r.url.includes("after=200-0")
538
+ )
539
+ ).toBe(true);
540
+ expect(dispatched.map((e) => e.offset)).toEqual(["201-0"]);
541
+ });
542
+
543
+ it("skips events from sibling consumers (foreign repos and event kinds) while advancing the offset", async () => {
544
+ dir = await mkdtemp(join(tmpdir(), "cursor-events-"));
545
+ const backend = fakeBackend({
546
+ anchorOffset: "0-1",
547
+ polls: [
548
+ {
549
+ events: [
550
+ // Foreign repo: another consumer of the same key subscribed it.
551
+ event("1-0", "pull_request", "other/repo"),
552
+ // Foreign event kind: not declared by this host's channels.
553
+ event("2-0", "workflow_run"),
554
+ // Ours.
555
+ event("3-0", "pull_request"),
556
+ ],
557
+ nextOffset: "3-0",
558
+ },
559
+ ],
560
+ });
561
+ const logs: string[] = [];
562
+ const { dispatched } = makeRelay(backend, {
563
+ logger: (line) => logs.push(line),
564
+ });
565
+
566
+ await waitFor(() => dispatched.length === 1);
567
+ expect(dispatched[0]?.offset).toBe("3-0");
568
+ await relay?.stop();
569
+ relay = undefined;
570
+ // The skipped events still advanced the durable offset.
571
+ const store = new RelayOffsetStore(dir);
572
+ expect(await store.load()).toBe("3-0");
573
+ expect(logs.some((line) => line.includes("other/repo"))).toBe(true);
574
+ });
575
+
576
+ it("keeps consuming when a dispatch throws", async () => {
577
+ dir = await mkdtemp(join(tmpdir(), "cursor-events-"));
578
+ const backend = fakeBackend({
579
+ anchorOffset: "0-1",
580
+ polls: [
581
+ {
582
+ events: [event("1-0", "pull_request"), event("2-0", "push")],
583
+ nextOffset: "2-0",
584
+ },
585
+ ],
586
+ });
587
+ const dispatched: string[] = [];
588
+ const logs: string[] = [];
589
+ makeRelay(backend, {
590
+ logger: (line) => logs.push(line),
591
+ dispatch: async (e) => {
592
+ if (e.eventName === "pull_request") {
593
+ throw new Error("hook exploded");
594
+ }
595
+ dispatched.push(e.eventName);
596
+ },
597
+ });
598
+
599
+ await waitFor(() => dispatched.length === 1);
600
+ expect(dispatched).toEqual(["push"]);
601
+ expect(logs.some((line) => line.includes("dispatch failed"))).toBe(true);
602
+ await relay?.stop();
603
+ relay = undefined;
604
+ const store = new RelayOffsetStore(dir);
605
+ // The failed event still advances the offset (webhook semantics: no
606
+ // infinite retry on a poison delivery).
607
+ expect(await store.load()).toBe("2-0");
608
+ });
609
+
610
+ it("logs subscription success on a clean first registration", async () => {
611
+ dir = await mkdtemp(join(tmpdir(), "cursor-events-"));
612
+ const backend = fakeBackend({ anchorOffset: "1-0" });
613
+ const logs: string[] = [];
614
+ makeRelay(backend, { logger: (line) => logs.push(line) });
615
+ await waitFor(() =>
616
+ logs.some((line) => line.includes("subscribed to acme/repo"))
617
+ );
618
+ });
619
+
620
+ it("reuses the persisted consumer id across restarts", async () => {
621
+ dir = await mkdtemp(join(tmpdir(), "cursor-events-"));
622
+ const backendA = fakeBackend({ anchorOffset: "1-0" });
623
+ makeRelay(backendA);
624
+ await waitFor(() => backendA.requests.some((r) => r.method === "PUT"));
625
+ await relay?.stop();
626
+ relay = undefined;
627
+ const firstConsumerId = (
628
+ backendA.requests.find((r) => r.method === "PUT")?.body as {
629
+ consumerId?: string;
630
+ }
631
+ )?.consumerId;
632
+
633
+ const backendB = fakeBackend({ anchorOffset: "1-0" });
634
+ makeRelay(backendB);
635
+ await waitFor(() => backendB.requests.some((r) => r.method === "PUT"));
636
+ const secondConsumerId = (
637
+ backendB.requests.find((r) => r.method === "PUT")?.body as {
638
+ consumerId?: string;
639
+ }
640
+ )?.consumerId;
641
+ expect(firstConsumerId).toBeDefined();
642
+ expect(secondConsumerId).toBe(firstConsumerId);
643
+ });
644
+ });
645
+
646
+ describe("serve({ cursorEvents }) sign-in requirement", () => {
647
+ const savedEnv: Record<string, string | undefined> = {};
648
+ let stateRoot: string | undefined;
649
+ let handle: AgentServeHandle | undefined;
650
+
651
+ const runner: AgentRunner = {
652
+ async runTurn() {
653
+ return { status: "finished", result: "unused" };
654
+ },
655
+ };
656
+
657
+ function fixture(): AgentProject {
658
+ return {
659
+ rootDir: "/tmp/cursor-events-fixture",
660
+ agentDir: "/tmp/cursor-events-fixture/agent",
661
+ name: "relay-fixture",
662
+ agent: {
663
+ name: "relay-fixture",
664
+ runtime: "local",
665
+ tools: [],
666
+ skills: [],
667
+ connections: [],
668
+ subagents: [],
669
+ seedFiles: [],
670
+ },
671
+ httpChannel: httpChannel(),
672
+ channels: [],
673
+ schedules: [],
674
+ hooks: [],
675
+ abs: [],
676
+ diagnostics: [],
677
+ };
678
+ }
679
+
680
+ beforeEach(() => {
681
+ for (const key of [
682
+ "CURSOR_API_KEY",
683
+ "CURSOR_API_BASE_URL",
684
+ "AGENT_SERVE_CONFIG_DIR",
685
+ ]) {
686
+ savedEnv[key] = process.env[key];
687
+ delete process.env[key];
688
+ }
689
+ // No stored login can leak in from the developer's machine.
690
+ process.env.AGENT_SERVE_CONFIG_DIR = "/nonexistent-agent-serve-config";
691
+ });
692
+
693
+ afterEach(async () => {
694
+ await handle?.close();
695
+ handle = undefined;
696
+ if (stateRoot !== undefined) {
697
+ await rm(stateRoot, { recursive: true, force: true });
698
+ stateRoot = undefined;
699
+ }
700
+ for (const [key, value] of Object.entries(savedEnv)) {
701
+ if (value === undefined) {
702
+ delete process.env[key];
703
+ } else {
704
+ process.env[key] = value;
705
+ }
706
+ }
707
+ });
708
+
709
+ it("refuses to serve when the host is not signed in to Cursor", async () => {
710
+ const root = await mkdtemp(join(tmpdir(), "agent-serve-cursor-events-"));
711
+ stateRoot = root;
712
+ await expect(
713
+ startServer([{ slug: "", project: fixture() }], {
714
+ port: 0,
715
+ stateRoot: root,
716
+ playground: false,
717
+ runner,
718
+ cursorEvents: { repos: ["acme/repo"] },
719
+ })
720
+ ).rejects.toThrow(
721
+ /--cursor-events[\s\S]*signed-in Cursor account[\s\S]*agentkit login/
722
+ );
723
+ });
724
+
725
+ it("accepts CURSOR_API_KEY instead of a stored login (CI, hosted hosts)", async () => {
726
+ const root = await mkdtemp(join(tmpdir(), "agent-serve-cursor-events-"));
727
+ stateRoot = root;
728
+ process.env.CURSOR_API_KEY = "crsr_env";
729
+ const logs: string[] = [];
730
+ handle = await startServer([{ slug: "", project: fixture() }], {
731
+ port: 0,
732
+ stateRoot: root,
733
+ playground: false,
734
+ runner,
735
+ logger: (line) => logs.push(line),
736
+ cursorEvents: { repos: ["acme/repo"] },
737
+ });
738
+ // Credential present, so startup proceeds. This fixture declares no github
739
+ // channel, so the relay stays off (warn-only path) without polling.
740
+ expect(
741
+ logs.some((line) =>
742
+ line.includes("no mounted agent declares a github channel")
743
+ )
744
+ ).toBe(true);
745
+ });
746
+ });