@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,452 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import type { ChannelSession, SendMessageFn } from "../../types.js";
3
+ import { blockActionAuth } from "./auth.js";
4
+ import {
5
+ mrkdwnSections,
6
+ SLACK_SECTION_MRKDWN_MAX,
7
+ SLACK_TABLE_MAX_COLUMNS,
8
+ SLACK_TABLE_MAX_ROWS,
9
+ slackLinkCell,
10
+ slackRawTextCell,
11
+ slackTableBlock,
12
+ } from "./blocks.js";
13
+ import { createSlackLogger } from "./index.js";
14
+ import {
15
+ dispatchBlockAction,
16
+ parseSlackBlockAction,
17
+ resolveBlockActionCard,
18
+ } from "./interactive.js";
19
+ import type { SlackBlockAction } from "./types.js";
20
+
21
+ const testAuth = {
22
+ authenticator: "slack",
23
+ principalId: "slack:T1:C9:5.0",
24
+ principalType: "user" as const,
25
+ attributes: { channel_id: "C9", thread_ts: "5.0", user_id: "U7" },
26
+ };
27
+
28
+ function mockSession(id = "ses_click"): ChannelSession {
29
+ return {
30
+ id,
31
+ continuationToken: null,
32
+ isNew: true,
33
+ info: {
34
+ id,
35
+ channelId: "slack",
36
+ mode: "chat",
37
+ auth: testAuth,
38
+ },
39
+ getEventStream: async () => new ReadableStream(),
40
+ waitForCompletion: async () => ({ status: "finished" }),
41
+ setContinuationToken: async () => undefined,
42
+ };
43
+ }
44
+
45
+ function createSendRecorder(): {
46
+ send: SendMessageFn;
47
+ calls: Array<{ message: string; options: Parameters<SendMessageFn>[1] }>;
48
+ } {
49
+ const calls: Array<{
50
+ message: string;
51
+ options: Parameters<SendMessageFn>[1];
52
+ }> = [];
53
+ const send: SendMessageFn = async (message, sendOptions) => {
54
+ calls.push({ message, options: sendOptions });
55
+ return mockSession();
56
+ };
57
+ return { send, calls };
58
+ }
59
+
60
+ function testHost() {
61
+ return {
62
+ mcp: {
63
+ names: () => [] as string[],
64
+ listTools: async () => [],
65
+ callTool: async () => ({ content: [] }),
66
+ },
67
+ github: {
68
+ getOctokit: async () => {
69
+ throw new Error("github client not configured in test");
70
+ },
71
+ },
72
+ slack: {
73
+ getClient: async () => {
74
+ throw new Error("slack client not configured in test");
75
+ },
76
+ },
77
+ kv: {
78
+ get: async () => undefined,
79
+ put: async () => {},
80
+ delete: async () => {},
81
+ },
82
+ };
83
+ }
84
+
85
+ function blockActionsPayload(
86
+ overrides: { actionId?: string; value?: string; threadTs?: string } = {}
87
+ ): Record<string, unknown> {
88
+ return {
89
+ type: "block_actions",
90
+ team: { id: "T1", domain: "acme" },
91
+ user: { id: "U7", username: "netto", team_id: "T1" },
92
+ channel: { id: "C9", name: "deploys" },
93
+ container: { type: "message", message_ts: "5.0", channel_id: "C9" },
94
+ message: {
95
+ ts: "5.0",
96
+ text: "the ask",
97
+ ...(overrides.threadTs === undefined
98
+ ? {}
99
+ : { thread_ts: overrides.threadTs }),
100
+ blocks: [{ type: "section" }, { type: "actions", block_id: "b1" }],
101
+ },
102
+ actions: [
103
+ {
104
+ type: "button",
105
+ action_id: overrides.actionId ?? "my_button",
106
+ block_id: "b1",
107
+ value: overrides.value ?? '{"pr":"https://example.test/pr/1"}',
108
+ text: { type: "plain_text", text: "Deploy" },
109
+ action_ts: "6.123456",
110
+ },
111
+ ],
112
+ };
113
+ }
114
+
115
+ describe("parseSlackBlockAction", () => {
116
+ it("parses a button click with label, clicker, and message blocks", () => {
117
+ const action = parseSlackBlockAction(blockActionsPayload());
118
+ expect(action).toMatchObject({
119
+ actionId: "my_button",
120
+ blockId: "b1",
121
+ value: '{"pr":"https://example.test/pr/1"}',
122
+ label: "Deploy",
123
+ channelId: "C9",
124
+ // Top-level message: the click stays in that message's thread.
125
+ threadTs: "5.0",
126
+ messageTs: "5.0",
127
+ userId: "U7",
128
+ userName: "netto",
129
+ teamId: "T1",
130
+ actionTs: "6.123456",
131
+ });
132
+ expect(action?.messageBlocks).toHaveLength(2);
133
+ });
134
+
135
+ it("prefers the message thread_ts for replies", () => {
136
+ const action = parseSlackBlockAction(
137
+ blockActionsPayload({ threadTs: "1.0" })
138
+ );
139
+ expect(action?.threadTs).toBe("1.0");
140
+ expect(action?.messageTs).toBe("5.0");
141
+ });
142
+
143
+ it("defaults a missing button value to the empty string", () => {
144
+ const payload = blockActionsPayload();
145
+ delete (
146
+ (payload.actions as Array<Record<string, unknown>>)[0] as Record<
147
+ string,
148
+ unknown
149
+ >
150
+ ).value;
151
+ expect(parseSlackBlockAction(payload)?.value).toBe("");
152
+ });
153
+
154
+ it("returns null for non-block_actions payloads", () => {
155
+ expect(parseSlackBlockAction({ type: "view_submission" })).toBeNull();
156
+ expect(parseSlackBlockAction(null)).toBeNull();
157
+ expect(
158
+ parseSlackBlockAction({ type: "block_actions", actions: [] })
159
+ ).toBeNull();
160
+ });
161
+ });
162
+
163
+ describe("dispatchBlockAction", () => {
164
+ it("dispatches the handler decision on the click's thread", async () => {
165
+ const { send, calls } = createSendRecorder();
166
+ const seen: SlackBlockAction[] = [];
167
+ const handled = await dispatchBlockAction(blockActionsPayload(), {
168
+ handler: (_ctx, action) => {
169
+ seen.push(action);
170
+ return { auth: testAuth, message: "U7 clicked Deploy — ship it." };
171
+ },
172
+ send,
173
+ credentials: undefined,
174
+ host: testHost(),
175
+ log: createSlackLogger(() => undefined),
176
+ });
177
+ expect(handled).toBe(true);
178
+ expect(seen).toHaveLength(1);
179
+ expect(seen[0]?.actionId).toBe("my_button");
180
+ expect(calls).toHaveLength(1);
181
+ expect(calls[0]?.options?.continuationToken).toBe("C9:5.0");
182
+ expect(calls[0]?.message).toContain("<slack_context>");
183
+ expect(calls[0]?.message).toContain("U7 clicked Deploy — ship it.");
184
+ const state = calls[0]?.options?.state as {
185
+ channelId?: string;
186
+ threadTs?: string;
187
+ triggeringUserId?: string;
188
+ };
189
+ expect(state.channelId).toBe("C9");
190
+ expect(state.threadTs).toBe("5.0");
191
+ expect(state.triggeringUserId).toBe("U7");
192
+ });
193
+
194
+ it("skips clicks without an action_ts (would alias dedupe keys)", async () => {
195
+ const { send, calls } = createSendRecorder();
196
+ const payload = blockActionsPayload();
197
+ delete (
198
+ (payload.actions as Array<Record<string, unknown>>)[0] as Record<
199
+ string,
200
+ unknown
201
+ >
202
+ ).action_ts;
203
+ const handled = await dispatchBlockAction(payload, {
204
+ handler: () => ({ auth: testAuth }),
205
+ send,
206
+ credentials: undefined,
207
+ host: testHost(),
208
+ log: createSlackLogger(() => undefined),
209
+ });
210
+ expect(handled).toBe(true);
211
+ expect(calls).toHaveLength(0);
212
+ });
213
+
214
+ it("skips dispatch when the handler returns null", async () => {
215
+ const { send, calls } = createSendRecorder();
216
+ const handled = await dispatchBlockAction(blockActionsPayload(), {
217
+ handler: () => null,
218
+ send,
219
+ credentials: undefined,
220
+ host: testHost(),
221
+ log: createSlackLogger(() => undefined),
222
+ });
223
+ expect(handled).toBe(true);
224
+ expect(calls).toHaveLength(0);
225
+ });
226
+
227
+ it("returns false for payloads that are not block actions", async () => {
228
+ const { send, calls } = createSendRecorder();
229
+ const handled = await dispatchBlockAction(
230
+ { type: "view_submission" },
231
+ {
232
+ handler: () => ({ auth: testAuth }),
233
+ send,
234
+ credentials: undefined,
235
+ host: testHost(),
236
+ log: createSlackLogger(() => undefined),
237
+ }
238
+ );
239
+ expect(handled).toBe(false);
240
+ expect(calls).toHaveLength(0);
241
+ });
242
+ });
243
+
244
+ describe("resolveBlockActionCard", () => {
245
+ function parsedAction(): SlackBlockAction {
246
+ const action = parseSlackBlockAction(blockActionsPayload());
247
+ if (action === null) {
248
+ throw new Error("fixture must parse");
249
+ }
250
+ return action;
251
+ }
252
+
253
+ it("swaps only the clicked actions row for the note", async () => {
254
+ const calls: Array<{ method: string; args: Record<string, unknown> }> = [];
255
+ const action = parsedAction();
256
+ action.messageBlocks = [
257
+ ...(action.messageBlocks ?? []),
258
+ { type: "actions", block_id: "someone_elses_row" },
259
+ ];
260
+ const ok = await resolveBlockActionCard({
261
+ botToken: {
262
+ kind: "api-transport",
263
+ label: "test",
264
+ async call(method: string, args: Record<string, unknown>) {
265
+ calls.push({ method, args });
266
+ return { ok: true };
267
+ },
268
+ },
269
+ action,
270
+ note: "resolved by <@U7>",
271
+ });
272
+ expect(ok).toBe(true);
273
+ expect(calls).toHaveLength(1);
274
+ expect(calls[0]?.method).toBe("chat.update");
275
+ expect(calls[0]?.args.ts).toBe("5.0");
276
+ const updated = JSON.stringify(calls[0]?.args.blocks);
277
+ expect(updated).not.toContain('"block_id":"b1"');
278
+ expect(updated).toContain("someone_elses_row");
279
+ expect(updated).toContain("resolved by <@U7>");
280
+ });
281
+
282
+ it("returns false when the payload carried no message blocks", async () => {
283
+ const action = parsedAction();
284
+ action.messageBlocks = undefined;
285
+ const ok = await resolveBlockActionCard({
286
+ botToken: {
287
+ kind: "api-transport",
288
+ label: "test",
289
+ async call() {
290
+ throw new Error("must not call Slack");
291
+ },
292
+ },
293
+ action,
294
+ note: "resolved",
295
+ });
296
+ expect(ok).toBe(false);
297
+ });
298
+ });
299
+
300
+ describe("blockActionAuth", () => {
301
+ it("builds the click's thread-scoped principal with the clicker", () => {
302
+ const action = parseSlackBlockAction(blockActionsPayload());
303
+ const auth = blockActionAuth(action as SlackBlockAction);
304
+ expect(auth.principalId).toBe("slack:T1:C9:5.0");
305
+ expect(auth.attributes?.user_id).toBe("U7");
306
+ expect(auth.attributes?.user_name).toBe("netto");
307
+ });
308
+ });
309
+
310
+ describe("mrkdwnSections", () => {
311
+ it("packs lines into capped sections preserving order", () => {
312
+ const lines = Array.from(
313
+ { length: 200 },
314
+ (_, i) => `• item-${i}-${"x".repeat(20)}`
315
+ );
316
+ const sections = mrkdwnSections(["Header:", ...lines]) as Array<{
317
+ type: string;
318
+ text: { type: string; text: string };
319
+ }>;
320
+ expect(sections.length).toBeGreaterThan(1);
321
+ for (const section of sections) {
322
+ expect(section.type).toBe("section");
323
+ expect(section.text.text.length).toBeLessThanOrEqual(
324
+ SLACK_SECTION_MRKDWN_MAX
325
+ );
326
+ }
327
+ const joined = sections.map((section) => section.text.text).join("\n");
328
+ expect(joined.startsWith("Header:")).toBe(true);
329
+ for (const line of lines) {
330
+ expect(joined).toContain(line);
331
+ }
332
+ });
333
+
334
+ it("keeps a short list in one section", () => {
335
+ expect(mrkdwnSections(["Header:", "• a", "• b"])).toHaveLength(1);
336
+ });
337
+
338
+ it("hard-splits a single oversized line at the cap", () => {
339
+ const sections = mrkdwnSections([
340
+ "x".repeat(SLACK_SECTION_MRKDWN_MAX + 500),
341
+ ]) as Array<{ text: { text: string } }>;
342
+ expect(sections).toHaveLength(2);
343
+ expect(sections[0]?.text.text.length).toBe(SLACK_SECTION_MRKDWN_MAX);
344
+ expect(sections[1]?.text.text.length).toBe(500);
345
+ });
346
+ });
347
+
348
+ describe("slackTableBlock", () => {
349
+ it("builds a native table with header + rich_text link cells", () => {
350
+ const block = slackTableBlock({
351
+ headers: ["Service", "SHA", "Anyship", "Logs"],
352
+ columnSettings: [
353
+ { is_wrapped: true },
354
+ { align: "left" },
355
+ { align: "left" },
356
+ { align: "left" },
357
+ ],
358
+ rows: [
359
+ [
360
+ slackRawTextCell("portal-website"),
361
+ slackLinkCell({
362
+ url: "https://github.com/anysphere/everysphere/commit/abc",
363
+ text: "abcdef0",
364
+ }),
365
+ slackLinkCell({
366
+ url: "https://anytool.corp.anysphere.co/anyship/deployments?deployment=d1",
367
+ text: "open",
368
+ }),
369
+ slackRawTextCell("—"),
370
+ ],
371
+ ],
372
+ });
373
+ expect(block).toEqual({
374
+ type: "table",
375
+ column_settings: [
376
+ { is_wrapped: true },
377
+ { align: "left" },
378
+ { align: "left" },
379
+ { align: "left" },
380
+ ],
381
+ rows: [
382
+ [
383
+ { type: "raw_text", text: "Service" },
384
+ { type: "raw_text", text: "SHA" },
385
+ { type: "raw_text", text: "Anyship" },
386
+ { type: "raw_text", text: "Logs" },
387
+ ],
388
+ [
389
+ { type: "raw_text", text: "portal-website" },
390
+ {
391
+ type: "rich_text",
392
+ elements: [
393
+ {
394
+ type: "rich_text_section",
395
+ elements: [
396
+ {
397
+ type: "link",
398
+ url: "https://github.com/anysphere/everysphere/commit/abc",
399
+ text: "abcdef0",
400
+ },
401
+ ],
402
+ },
403
+ ],
404
+ },
405
+ {
406
+ type: "rich_text",
407
+ elements: [
408
+ {
409
+ type: "rich_text_section",
410
+ elements: [
411
+ {
412
+ type: "link",
413
+ url: "https://anytool.corp.anysphere.co/anyship/deployments?deployment=d1",
414
+ text: "open",
415
+ },
416
+ ],
417
+ },
418
+ ],
419
+ },
420
+ { type: "raw_text", text: "—" },
421
+ ],
422
+ ],
423
+ });
424
+ });
425
+
426
+ it("returns undefined for empty headers", () => {
427
+ expect(slackTableBlock({ headers: [], rows: [] })).toBeUndefined();
428
+ });
429
+
430
+ it("rejects oversized or jagged tables", () => {
431
+ expect(() =>
432
+ slackTableBlock({
433
+ headers: Array.from({ length: SLACK_TABLE_MAX_COLUMNS + 1 }, () => "c"),
434
+ rows: [],
435
+ })
436
+ ).toThrow(/columns exceeds/);
437
+ expect(() =>
438
+ slackTableBlock({
439
+ headers: ["A"],
440
+ rows: Array.from({ length: SLACK_TABLE_MAX_ROWS }, () => [
441
+ slackRawTextCell("x"),
442
+ ]),
443
+ })
444
+ ).toThrow(/rows exceeds/);
445
+ expect(() =>
446
+ slackTableBlock({
447
+ headers: ["A", "B"],
448
+ rows: [[slackRawTextCell("only-one")]],
449
+ })
450
+ ).toThrow(/expected 2/);
451
+ });
452
+ });
@@ -0,0 +1,217 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import {
3
+ botMentionsFromEnv,
4
+ buildDelegationMetadata,
5
+ normalizeBotMentionAllowlist,
6
+ parseDelegationMetadata,
7
+ SLACK_DELEGATION_METADATA_EVENT_TYPE,
8
+ SlackBotMentionGate,
9
+ } from "./bot-mentions.js";
10
+ import { parseAppMentionEvent } from "./inbound.js";
11
+ import type { SlackMessage } from "./types.js";
12
+
13
+ function botMention(patch: Partial<SlackMessage> = {}): SlackMessage {
14
+ return {
15
+ text: "<@ULASSO> please deploy #123",
16
+ body: "<@ULASSO> please deploy #123",
17
+ markdown: "@ULASSO please deploy #123",
18
+ ts: "2.0",
19
+ threadTs: "1.0",
20
+ channelId: "C9",
21
+ author: { userId: "ULASSO", isBot: true, botId: "BLASSO" },
22
+ ...patch,
23
+ };
24
+ }
25
+
26
+ function gate(
27
+ options: Partial<ConstructorParameters<typeof SlackBotMentionGate>[0]> = {},
28
+ identity: { botId?: string; botUserId?: string } = {
29
+ botId: "BSELF",
30
+ botUserId: "USELF",
31
+ }
32
+ ): SlackBotMentionGate {
33
+ return new SlackBotMentionGate(
34
+ { allowFrom: ["BLASSO"], ...options },
35
+ identity,
36
+ { log: { info() {}, warn() {}, error() {}, debug() {}, write() {} } }
37
+ );
38
+ }
39
+
40
+ describe("delegation metadata", () => {
41
+ it("round-trips build → parse", () => {
42
+ const metadata = buildDelegationMetadata({
43
+ depth: 2,
44
+ fromAgent: "lasso",
45
+ originSessionId: "sess-1",
46
+ });
47
+ expect(metadata.event_type).toBe(SLACK_DELEGATION_METADATA_EVENT_TYPE);
48
+ const parsed = parseDelegationMetadata({ metadata });
49
+ expect(parsed).toEqual({
50
+ depth: 2,
51
+ fromAgent: "lasso",
52
+ originSessionId: "sess-1",
53
+ });
54
+ });
55
+
56
+ it("ignores foreign and malformed metadata", () => {
57
+ expect(parseDelegationMetadata({})).toBeUndefined();
58
+ expect(
59
+ parseDelegationMetadata({
60
+ metadata: { event_type: "other", event_payload: { depth: 1 } },
61
+ })
62
+ ).toBeUndefined();
63
+ expect(
64
+ parseDelegationMetadata({
65
+ metadata: {
66
+ event_type: SLACK_DELEGATION_METADATA_EVENT_TYPE,
67
+ event_payload: { depth: 0 },
68
+ },
69
+ })
70
+ ).toBeUndefined();
71
+ expect(
72
+ parseDelegationMetadata({
73
+ metadata: {
74
+ event_type: SLACK_DELEGATION_METADATA_EVENT_TYPE,
75
+ event_payload: { depth: "two" },
76
+ },
77
+ })
78
+ ).toBeUndefined();
79
+ });
80
+ });
81
+
82
+ describe("botMentionsFromEnv", () => {
83
+ it("returns undefined when unset and options when set", () => {
84
+ const envVar = "BOT_MENTIONS_TEST_VAR";
85
+ delete process.env[envVar];
86
+ expect(botMentionsFromEnv(envVar)).toBeUndefined();
87
+ process.env[envVar] = " B1 ,,U2 ";
88
+ expect(botMentionsFromEnv(envVar)).toEqual({ allowFrom: ["B1", "U2"] });
89
+ delete process.env[envVar];
90
+ });
91
+ });
92
+
93
+ describe("parseAppMentionEvent bot-authored", () => {
94
+ const body = {
95
+ type: "event_callback",
96
+ team_id: "T1",
97
+ event: {
98
+ type: "app_mention",
99
+ user: "ULASSO",
100
+ bot_id: "BLASSO",
101
+ text: "<@UTARGET> look into this",
102
+ ts: "2.0",
103
+ thread_ts: "1.0",
104
+ channel: "C9",
105
+ metadata: buildDelegationMetadata({ depth: 1, fromAgent: "lasso" }),
106
+ },
107
+ };
108
+
109
+ it("drops bot mentions by default", () => {
110
+ expect(parseAppMentionEvent(body)).toBeNull();
111
+ });
112
+
113
+ it("keeps bot mentions with delegation info when opted in", () => {
114
+ const message = parseAppMentionEvent(body, { includeBotAuthored: true });
115
+ expect(message).not.toBeNull();
116
+ expect(message?.author?.isBot).toBe(true);
117
+ expect(message?.author?.botId).toBe("BLASSO");
118
+ expect(message?.delegation).toEqual({ depth: 1, fromAgent: "lasso" });
119
+ });
120
+
121
+ it("still parses human mentions when opted in", () => {
122
+ const message = parseAppMentionEvent(
123
+ {
124
+ type: "event_callback",
125
+ event: {
126
+ type: "app_mention",
127
+ user: "U1",
128
+ text: "<@UTARGET> hi",
129
+ ts: "3.0",
130
+ channel: "C9",
131
+ },
132
+ },
133
+ { includeBotAuthored: true }
134
+ );
135
+ expect(message?.author?.isBot).toBe(false);
136
+ expect(message?.delegation).toBeUndefined();
137
+ });
138
+ });
139
+
140
+ describe("normalizeBotMentionAllowlist", () => {
141
+ it("trims entries and rejects empty lists", () => {
142
+ expect(normalizeBotMentionAllowlist([" B1 ", "U2"])).toEqual(["B1", "U2"]);
143
+ expect(() => normalizeBotMentionAllowlist([" "])).toThrow(/allowFrom/);
144
+ });
145
+ });
146
+
147
+ describe("SlackBotMentionGate", () => {
148
+ it("requires its own bot identity", () => {
149
+ expect(() => gate({}, {})).toThrow(/identity/);
150
+ });
151
+
152
+ it("admits an allowlisted bot mention", () => {
153
+ const verdict = gate().evaluate(botMention());
154
+ expect(verdict).toEqual({ allow: true, depth: 1 });
155
+ });
156
+
157
+ it("matches on bot user id as well as bot_id", () => {
158
+ const verdict = gate({ allowFrom: ["ULASSO"] }).evaluate(botMention());
159
+ expect(verdict.allow).toBe(true);
160
+ });
161
+
162
+ it("denies bots not in the allowlist", () => {
163
+ const verdict = gate().evaluate(
164
+ botMention({ author: { userId: "UOTHER", isBot: true, botId: "BOTHER" } })
165
+ );
166
+ expect(verdict).toMatchObject({ allow: false });
167
+ });
168
+
169
+ it("denies its own posts even when allowlisted", () => {
170
+ const verdict = gate({ allowFrom: ["BSELF"] }).evaluate(
171
+ botMention({ author: { userId: "USELF", isBot: true, botId: "BSELF" } })
172
+ );
173
+ expect(verdict).toMatchObject({ allow: false, reason: /own post/ });
174
+ });
175
+
176
+ it("enforces the depth budget", () => {
177
+ const g = gate();
178
+ expect(g.evaluate(botMention({ delegation: { depth: 2 } })).allow).toBe(
179
+ true
180
+ );
181
+ expect(
182
+ g.evaluate(botMention({ ts: "3.0", delegation: { depth: 3 } }))
183
+ ).toMatchObject({ allow: false, reason: /depth/ });
184
+ });
185
+
186
+ it("enforces the rate budget with a sliding window", () => {
187
+ const g = gate({ maxPerThreadPerHour: 2 });
188
+ expect(g.evaluate(botMention(), 1000).allow).toBe(true);
189
+ expect(g.evaluate(botMention(), 2000).allow).toBe(true);
190
+ expect(g.evaluate(botMention(), 3000)).toMatchObject({
191
+ allow: false,
192
+ reason: /rate budget/,
193
+ });
194
+ // Window slides: an hour later the thread has rate budget again.
195
+ const later = 3000 + 60 * 60 * 1000;
196
+ expect(g.evaluate(botMention(), later).allow).toBe(true);
197
+ // Other threads are unaffected.
198
+ expect(g.evaluate(botMention({ threadTs: "9.0" }), 3000).allow).toBe(true);
199
+ });
200
+
201
+ it("terminates ping-pong with the lifetime thread cap", () => {
202
+ const g = gate({ maxPerThreadPerHour: 100, maxPerThreadTotal: 3 });
203
+ expect(g.evaluate(botMention(), 1000).allow).toBe(true);
204
+ expect(g.evaluate(botMention(), 2000).allow).toBe(true);
205
+ expect(g.evaluate(botMention(), 3000).allow).toBe(true);
206
+ expect(g.evaluate(botMention(), 4000)).toMatchObject({
207
+ allow: false,
208
+ reason: /lifetime/,
209
+ });
210
+ // Sliding the window does NOT refill the lifetime cap.
211
+ const muchLater = 4000 + 5 * 60 * 60 * 1000;
212
+ expect(g.evaluate(botMention(), muchLater)).toMatchObject({
213
+ allow: false,
214
+ reason: /lifetime/,
215
+ });
216
+ });
217
+ });