@cursor/july 0.1.19 → 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 (217) hide show
  1. package/AGENTS.md +10 -0
  2. package/dist/bin/agent-serve.js +0 -0
  3. package/dist/channels/github/instrument.d.ts +20 -0
  4. package/dist/channels/github/instrument.d.ts.map +1 -0
  5. package/dist/channels/slack/api.d.ts +2 -0
  6. package/dist/channels/slack/api.d.ts.map +1 -1
  7. package/dist/channels/slack/api.js +3 -0
  8. package/dist/channels/slack/bot-mentions.d.ts +106 -0
  9. package/dist/channels/slack/bot-mentions.d.ts.map +1 -0
  10. package/dist/channels/slack/bot-mentions.js +243 -0
  11. package/dist/channels/slack/dispatch.d.ts +6 -0
  12. package/dist/channels/slack/dispatch.d.ts.map +1 -1
  13. package/dist/channels/slack/dispatch.js +24 -3
  14. package/dist/channels/slack/inbound.d.ts +10 -1
  15. package/dist/channels/slack/inbound.d.ts.map +1 -1
  16. package/dist/channels/slack/inbound.js +12 -4
  17. package/dist/channels/slack/index.d.ts +1 -0
  18. package/dist/channels/slack/index.d.ts.map +1 -1
  19. package/dist/channels/slack/index.js +1 -0
  20. package/dist/channels/slack/slack-channel.d.ts.map +1 -1
  21. package/dist/channels/slack/slack-channel.js +61 -22
  22. package/dist/channels/slack/types.d.ts +58 -0
  23. package/dist/channels/slack/types.d.ts.map +1 -1
  24. package/dist/docs/404.html +1 -1
  25. package/dist/docs/ab.html +2 -2
  26. package/dist/docs/assets/{app.CrsWMchO.js → app.jDxLzWv4.js} +1 -1
  27. package/dist/docs/assets/chunks/@localSearchIndexroot.DoJHJjqF.js +1 -0
  28. package/dist/docs/assets/chunks/{VPLocalSearchBox.D1JqzSh8.js → VPLocalSearchBox.Y6bDR1-a.js} +1 -1
  29. package/dist/docs/assets/chunks/{theme.DaBvZYwl.js → theme.CLazCWlJ.js} +2 -2
  30. package/dist/docs/building-with-agents.html +2 -2
  31. package/dist/docs/concepts.html +2 -2
  32. package/dist/docs/deployment.html +2 -2
  33. package/dist/docs/evals.html +2 -2
  34. package/dist/docs/example-agents/approval-buddy.html +2 -2
  35. package/dist/docs/example-agents/benny.html +2 -2
  36. package/dist/docs/example-agents/bugbot.html +2 -2
  37. package/dist/docs/example-agents/codebase-wiki.html +2 -2
  38. package/dist/docs/example-agents/codeowners-review.html +2 -2
  39. package/dist/docs/example-agents/concierge.html +2 -2
  40. package/dist/docs/example-agents/fsd.html +2 -2
  41. package/dist/docs/example-agents/index.html +2 -2
  42. package/dist/docs/example-agents/knowledge-base.html +2 -2
  43. package/dist/docs/example-agents/oncall.html +2 -2
  44. package/dist/docs/example-agents/security-reviewer.html +2 -2
  45. package/dist/docs/example-agents/slack-agent.html +2 -2
  46. package/dist/docs/example-agents/weather-agent.html +2 -2
  47. package/dist/docs/guides/agent-to-agent.html +2 -2
  48. package/dist/docs/guides/cloud-runtime.html +2 -2
  49. package/dist/docs/guides/github.html +2 -2
  50. package/dist/docs/guides/human-in-the-loop.html +2 -2
  51. package/dist/docs/guides/mcp-oauth.html +2 -2
  52. package/dist/docs/guides/slack.html +2 -2
  53. package/dist/docs/guides/webhooks.html +2 -2
  54. package/dist/docs/hillclimbing.html +2 -2
  55. package/dist/docs/index.html +2 -2
  56. package/dist/docs/quickstart.html +2 -2
  57. package/dist/docs/reference/agent-config.html +2 -2
  58. package/dist/docs/reference/channels.html +2 -2
  59. package/dist/docs/reference/cli.html +2 -2
  60. package/dist/docs/reference/connections.html +2 -2
  61. package/dist/docs/reference/hooks.html +2 -2
  62. package/dist/docs/reference/http-api.html +2 -2
  63. package/dist/docs/reference/instructions.html +2 -2
  64. package/dist/docs/reference/playground.html +2 -2
  65. package/dist/docs/reference/project-layout.html +2 -2
  66. package/dist/docs/reference/prompt.html +2 -2
  67. package/dist/docs/reference/schedules.html +2 -2
  68. package/dist/docs/reference/sessions.html +2 -2
  69. package/dist/docs/reference/skills.html +2 -2
  70. package/dist/docs/reference/subagents.html +2 -2
  71. package/dist/docs/reference/tools.html +2 -2
  72. package/dist/docs/scaffolding-agents.html +2 -2
  73. package/dist/docs/storage.html +2 -2
  74. package/dist/docs/troubleshooting.html +2 -2
  75. package/dist/internal/json-dir-store.d.ts +32 -0
  76. package/dist/internal/json-dir-store.d.ts.map +1 -0
  77. package/dist/internal/json-dir-store.js +100 -0
  78. package/dist/internal/resolved-connections.d.ts +1 -1
  79. package/dist/internal/resolved-connections.d.ts.map +1 -1
  80. package/dist/internal/resolved-connections.js +10 -0
  81. package/dist/internal/session-engine.d.ts +4 -1
  82. package/dist/internal/session-engine.d.ts.map +1 -1
  83. package/dist/internal/session-engine.js +13 -2
  84. package/dist/playground/assets/index-CjOQ4hN9.css +1 -0
  85. package/dist/playground/assets/{index-C2SU2xV5.js → index-dshZQJCp.js} +46 -46
  86. package/dist/playground/index.html +2 -2
  87. package/dist/types.d.ts +7 -0
  88. package/dist/types.d.ts.map +1 -1
  89. package/dist/types.js +9 -0
  90. package/package.json +25 -25
  91. package/skills/create-agent/SKILL.md +36 -4
  92. package/skills/evals/SKILL.md +3 -0
  93. package/skills/framework-map/SKILL.md +14 -0
  94. package/skills/github/SKILL.md +6 -1
  95. package/skills/hillclimb/SKILL.md +28 -7
  96. package/src/bin/agent-serve.version.test.ts +62 -0
  97. package/src/channels/github/api.test.ts +64 -0
  98. package/src/channels/github/auth.test.ts +105 -0
  99. package/src/channels/github/cursor-account.test.ts +204 -0
  100. package/src/channels/github/forward.test.ts +457 -0
  101. package/src/channels/github/github.test.ts +937 -0
  102. package/src/channels/github/replay.test.ts +179 -0
  103. package/src/channels/slack/api.post-message.test.ts +148 -0
  104. package/src/channels/slack/api.ts +5 -0
  105. package/src/channels/slack/approvals.test.ts +328 -0
  106. package/src/channels/slack/block-actions.test.ts +452 -0
  107. package/src/channels/slack/bot-mentions.test.ts +217 -0
  108. package/src/channels/slack/bot-mentions.ts +315 -0
  109. package/src/channels/slack/channel-watch.test.ts +363 -0
  110. package/src/channels/slack/cursor-account.test.ts +253 -0
  111. package/src/channels/slack/defaults.final-post.test.ts +182 -0
  112. package/src/channels/slack/dispatch.test.ts +795 -0
  113. package/src/channels/slack/dispatch.ts +27 -1
  114. package/src/channels/slack/eval-directive.test.ts +273 -0
  115. package/src/channels/slack/inbound.ts +25 -4
  116. package/src/channels/slack/index.ts +1 -0
  117. package/src/channels/slack/message-body.test.ts +54 -0
  118. package/src/channels/slack/nudge-store.test.ts +143 -0
  119. package/src/channels/slack/slack-channel.ts +66 -8
  120. package/src/channels/slack/slack.test.ts +391 -0
  121. package/src/channels/slack/stop.test.ts +23 -0
  122. package/src/channels/slack/thread-context.test.ts +202 -0
  123. package/src/channels/slack/types.ts +60 -0
  124. package/src/evals/assertions.test.ts +580 -0
  125. package/src/evals/expect.test.ts +144 -0
  126. package/src/evals/judge.test.ts +181 -0
  127. package/src/evals/loaders.test.ts +132 -0
  128. package/src/evals/matchers.test.ts +95 -0
  129. package/src/evals/reporters.test.ts +303 -0
  130. package/src/evals/run-facts.test.ts +259 -0
  131. package/src/internal/ab-snapshot.test.ts +325 -0
  132. package/src/internal/approval-gate.test.ts +49 -0
  133. package/src/internal/approvals.integration.test.ts +383 -0
  134. package/src/internal/authored-loaders.test.ts +31 -0
  135. package/src/internal/builtin-tools/reminders.test.ts +201 -0
  136. package/src/internal/channel-route-schema.test.ts +294 -0
  137. package/src/internal/chat-attach.test.ts +262 -0
  138. package/src/internal/cli-deploy.test.ts +1991 -0
  139. package/src/internal/cli-mcp.test.ts +789 -0
  140. package/src/internal/cli-skills.test.ts +133 -0
  141. package/src/internal/cli-slack.test.ts +1647 -0
  142. package/src/internal/cloud-merge.test.ts +74 -0
  143. package/src/internal/cron.test.ts +22 -0
  144. package/src/internal/cursor/account-mcp.test.ts +807 -0
  145. package/src/internal/cursor/backend-client.test.ts +591 -0
  146. package/src/internal/cursor/credentials.test.ts +351 -0
  147. package/src/internal/cursor/github-credentials.test.ts +136 -0
  148. package/src/internal/cursor-account-mcp-auth.test.ts +310 -0
  149. package/src/internal/cursor-account.integration.test.ts +441 -0
  150. package/src/internal/cursor-event-relay.test.ts +746 -0
  151. package/src/internal/cursor-github-credentials.integration.test.ts +271 -0
  152. package/src/internal/cursor-slack-relay.test.ts +525 -0
  153. package/src/internal/deploy-source.test.ts +111 -0
  154. package/src/internal/discovery.builtin-tools.test.ts +94 -0
  155. package/src/internal/discovery.concurrency.test.ts +60 -0
  156. package/src/internal/discovery.cursor-account.test.ts +133 -0
  157. package/src/internal/discovery.cwd.test.ts +83 -0
  158. package/src/internal/discovery.hosting.test.ts +80 -0
  159. package/src/internal/discovery.identity.test.ts +44 -0
  160. package/src/internal/docs-site.test.ts +66 -0
  161. package/src/internal/duration.test.ts +29 -0
  162. package/src/internal/eval-judge-model.test.ts +187 -0
  163. package/src/internal/eval-run-store.cancel.test.ts +142 -0
  164. package/src/internal/eval-run-store.storage.test.ts +211 -0
  165. package/src/internal/eval-runner.http.test.ts +403 -0
  166. package/src/internal/eval-runner.run.test.ts +928 -0
  167. package/src/internal/evals-client.test.ts +307 -0
  168. package/src/internal/event-mapper.test.ts +243 -0
  169. package/src/internal/github-fanout.test.ts +213 -0
  170. package/src/internal/handleAgentServeTrigger.test.ts +179 -0
  171. package/src/internal/host-kv.test.ts +82 -0
  172. package/src/internal/host-platforms.test.ts +126 -0
  173. package/src/internal/http-channel.test.ts +402 -0
  174. package/src/internal/init-project.test.ts +269 -0
  175. package/src/internal/install-cursor-skills.test.ts +262 -0
  176. package/src/internal/local-env.test.ts +100 -0
  177. package/src/internal/log-ring.test.ts +31 -0
  178. package/src/internal/logs-client.test.ts +350 -0
  179. package/src/internal/mcp-endpoint.test.ts +436 -0
  180. package/src/internal/mcp-host.test.ts +298 -0
  181. package/src/internal/mcp-oauth.test.ts +148 -0
  182. package/src/internal/net.test.ts +17 -0
  183. package/src/internal/peer-connections.test.ts +128 -0
  184. package/src/internal/peer-mcp.integration.test.ts +289 -0
  185. package/src/internal/playground/toolchain.test.ts +53 -0
  186. package/src/internal/playground-cli.test.ts +187 -0
  187. package/src/internal/playground-proxy.test.ts +376 -0
  188. package/src/internal/prompt-context.integration.test.ts +232 -0
  189. package/src/internal/prompt-context.test.ts +127 -0
  190. package/src/internal/reminder-runner.test.ts +390 -0
  191. package/src/internal/reminder-store.test.ts +53 -0
  192. package/src/internal/request-headers.test.ts +27 -0
  193. package/src/internal/resolve-prod-target.test.ts +787 -0
  194. package/src/internal/resolved-connections.test.ts +295 -0
  195. package/src/internal/resolved-connections.ts +18 -1
  196. package/src/internal/router.test.ts +57 -0
  197. package/src/internal/sdk-runner.test.ts +290 -0
  198. package/src/internal/session-engine.coalesce.test.ts +169 -0
  199. package/src/internal/session-engine.concurrency.test.ts +250 -0
  200. package/src/internal/session-engine.host-oauth-mcp.test.ts +110 -0
  201. package/src/internal/session-engine.interrupt.test.ts +577 -0
  202. package/src/internal/session-engine.storage.test.ts +547 -0
  203. package/src/internal/session-engine.ts +15 -1
  204. package/src/internal/session-urls.test.ts +28 -0
  205. package/src/internal/sessions-client.test.ts +518 -0
  206. package/src/internal/storage-coordinator.test.ts +517 -0
  207. package/src/internal/tool-call.test.ts +458 -0
  208. package/src/internal/tool-result.test.ts +52 -0
  209. package/src/internal/trajectory.approvals.test.ts +83 -0
  210. package/src/internal/trajectory.subagents.test.ts +198 -0
  211. package/src/internal/turn-governor.test.ts +137 -0
  212. package/src/internal/update-check.test.ts +485 -0
  213. package/src/internal/workspace.test.ts +81 -0
  214. package/src/storage-backends/cursor-hosted.test.ts +121 -0
  215. package/src/types.ts +12 -0
  216. package/dist/docs/assets/chunks/@localSearchIndexroot.BnHRjfoe.js +0 -1
  217. package/dist/playground/assets/index-CidizGZv.css +0 -1
@@ -0,0 +1,315 @@
1
+ /**
2
+ * Opt-in bot-to-bot mention wake (`engagement.botMentions`).
3
+ *
4
+ * Layered loop protection, any one of which blocks a runaway chain:
5
+ * 1. Allowlist — only mentions from explicitly trusted bots wake the agent.
6
+ * 2. Own-post filter — the agent's own posts never wake it (always on).
7
+ * 3. Depth budget — delegation metadata carries a hop count; mentions
8
+ * without metadata count as depth 1. Default max is 2.
9
+ * 4. Thread budgets — a sliding-window rate cap plus a lifetime total per
10
+ * thread, so undetected ping-pong between trusted bots terminates
11
+ * instead of merely slowing down.
12
+ */
13
+
14
+ import { postMessage } from "./api.js";
15
+ import { defaultSlackLog, type SlackLog } from "./log.js";
16
+ import type {
17
+ SlackBotMentionsOptions,
18
+ SlackBotToken,
19
+ SlackDelegationInfo,
20
+ SlackMessage,
21
+ } from "./types.js";
22
+
23
+ /** Slack message `metadata.event_type` for agentkit delegation posts. */
24
+ export const SLACK_DELEGATION_METADATA_EVENT_TYPE = "agentkit_delegation";
25
+
26
+ /** Default budgets for {@link SlackBotMentionsOptions}. */
27
+ export const SLACK_BOT_MENTION_DEFAULTS = {
28
+ maxDepth: 2,
29
+ maxPerThreadPerHour: 5,
30
+ maxPerThreadTotal: 20,
31
+ } as const;
32
+
33
+ const THREAD_BUDGET_WINDOW_MS = 60 * 60 * 1000;
34
+ /** Threads idle this long are evicted (their lifetime budget resets). */
35
+ const THREAD_IDLE_EVICT_MS = 24 * 60 * 60 * 1000;
36
+ /** Eviction sweep runs when the thread map grows past this many keys. */
37
+ const THREAD_SWEEP_THRESHOLD = 512;
38
+
39
+ function asRecord(value: unknown): Record<string, unknown> | null {
40
+ if (value === null || typeof value !== "object" || Array.isArray(value)) {
41
+ return null;
42
+ }
43
+ return value as Record<string, unknown>;
44
+ }
45
+
46
+ /**
47
+ * Parse delegation metadata off a raw Slack event (`event.metadata`).
48
+ * Returns undefined for absent, foreign, or malformed metadata.
49
+ */
50
+ export function parseDelegationMetadata(
51
+ event: unknown
52
+ ): SlackDelegationInfo | undefined {
53
+ const root = asRecord(event);
54
+ const metadata = asRecord(root?.metadata);
55
+ if (
56
+ metadata === null ||
57
+ metadata.event_type !== SLACK_DELEGATION_METADATA_EVENT_TYPE
58
+ ) {
59
+ return undefined;
60
+ }
61
+ const payload = asRecord(metadata.event_payload);
62
+ if (payload === null) {
63
+ return undefined;
64
+ }
65
+ const depth =
66
+ typeof payload.depth === "number" && Number.isFinite(payload.depth)
67
+ ? Math.floor(payload.depth)
68
+ : undefined;
69
+ if (depth === undefined || depth < 1) {
70
+ return undefined;
71
+ }
72
+ const info: SlackDelegationInfo = { depth };
73
+ if (typeof payload.from_agent === "string" && payload.from_agent !== "") {
74
+ info.fromAgent = payload.from_agent;
75
+ }
76
+ if (
77
+ typeof payload.origin_session_id === "string" &&
78
+ payload.origin_session_id !== ""
79
+ ) {
80
+ info.originSessionId = payload.origin_session_id;
81
+ }
82
+ return info;
83
+ }
84
+
85
+ /** Build the Slack `metadata` body for an outbound delegation post. */
86
+ export function buildDelegationMetadata(args: {
87
+ depth: number;
88
+ fromAgent?: string;
89
+ originSessionId?: string;
90
+ }): Record<string, unknown> {
91
+ const payload: Record<string, unknown> = { depth: args.depth };
92
+ if (args.fromAgent !== undefined && args.fromAgent !== "") {
93
+ payload.from_agent = args.fromAgent;
94
+ }
95
+ if (args.originSessionId !== undefined && args.originSessionId !== "") {
96
+ payload.origin_session_id = args.originSessionId;
97
+ }
98
+ return {
99
+ event_type: SLACK_DELEGATION_METADATA_EVENT_TYPE,
100
+ event_payload: payload,
101
+ };
102
+ }
103
+
104
+ /**
105
+ * Validate and normalize a `botMentions.allowFrom` list. Shared by channel
106
+ * construction (fail at definition time) and the gate (fail at start).
107
+ */
108
+ export function normalizeBotMentionAllowlist(
109
+ allowFrom: readonly string[]
110
+ ): string[] {
111
+ const entries = allowFrom
112
+ .map((entry) => entry.trim())
113
+ .filter((entry) => entry !== "");
114
+ if (entries.length === 0) {
115
+ throw new Error(
116
+ "slackChannel engagement.botMentions.allowFrom must list at least one bot id (B…) or bot user id (U…); there is no wildcard."
117
+ );
118
+ }
119
+ return entries;
120
+ }
121
+
122
+ /**
123
+ * Read `engagement.botMentions` options from the environment: a
124
+ * comma-separated bot-id allowlist in `AGENT_DELEGATOR_SLACK_BOT_IDS` (or a
125
+ * custom var). Returns undefined when unset, so channels can pass the
126
+ * result straight through and stay bot-mention-free by default:
127
+ *
128
+ * ```ts
129
+ * engagement: { botMentions: botMentionsFromEnv() }
130
+ * ```
131
+ */
132
+ export function botMentionsFromEnv(
133
+ envVar = "AGENT_DELEGATOR_SLACK_BOT_IDS"
134
+ ): SlackBotMentionsOptions | undefined {
135
+ const allowFrom = (process.env[envVar] ?? "")
136
+ .split(",")
137
+ .map((entry) => entry.trim())
138
+ .filter((entry) => entry !== "");
139
+ return allowFrom.length === 0 ? undefined : { allowFrom };
140
+ }
141
+
142
+ export type SlackBotMentionVerdict =
143
+ | { allow: true; depth: number }
144
+ | { allow: false; reason: string };
145
+
146
+ interface ThreadBudget {
147
+ /** Lifetime admitted wakes for this thread (until idle eviction). */
148
+ total: number;
149
+ /** Timestamps of recent admitted wakes (pruned to the window). */
150
+ recent: number[];
151
+ lastAtMs: number;
152
+ }
153
+
154
+ /**
155
+ * Admission gate for bot-authored mentions. One instance per channel;
156
+ * thread budgets are in-memory (reset on restart — acceptable, the depth
157
+ * and allowlist layers do not depend on process state). Threads idle for
158
+ * 24h are evicted, which also resets their lifetime budget.
159
+ */
160
+ export class SlackBotMentionGate {
161
+ private readonly allowFrom: Set<string>;
162
+ private readonly maxDepth: number;
163
+ private readonly maxPerThreadPerHour: number;
164
+ private readonly maxPerThreadTotal: number;
165
+ private readonly windowMs: number;
166
+ private readonly threadBudgets = new Map<string, ThreadBudget>();
167
+ private readonly log: SlackLog;
168
+
169
+ constructor(
170
+ options: SlackBotMentionsOptions,
171
+ private readonly identity: { botUserId?: string; botId?: string },
172
+ args?: { log?: SlackLog; windowMs?: number }
173
+ ) {
174
+ if (identity.botUserId === undefined && identity.botId === undefined) {
175
+ throw new Error(
176
+ "SlackBotMentionGate requires the agent's own bot identity (auth.test) for loop safety."
177
+ );
178
+ }
179
+ this.allowFrom = new Set(normalizeBotMentionAllowlist(options.allowFrom));
180
+ this.maxDepth = options.maxDepth ?? SLACK_BOT_MENTION_DEFAULTS.maxDepth;
181
+ this.maxPerThreadPerHour =
182
+ options.maxPerThreadPerHour ??
183
+ SLACK_BOT_MENTION_DEFAULTS.maxPerThreadPerHour;
184
+ this.maxPerThreadTotal =
185
+ options.maxPerThreadTotal ?? SLACK_BOT_MENTION_DEFAULTS.maxPerThreadTotal;
186
+ this.windowMs = args?.windowMs ?? THREAD_BUDGET_WINDOW_MS;
187
+ this.log = args?.log ?? defaultSlackLog;
188
+ }
189
+
190
+ /**
191
+ * Evaluate a bot-authored mention. Callers must only pass messages whose
192
+ * author `isBot`; human mentions never consult the gate. An `allow`
193
+ * verdict consumes thread budget.
194
+ */
195
+ evaluate(
196
+ message: SlackMessage,
197
+ nowMs: number = Date.now()
198
+ ): SlackBotMentionVerdict {
199
+ const author = message.author;
200
+ if (author === undefined || !author.isBot) {
201
+ return { allow: false, reason: "not bot-authored" };
202
+ }
203
+
204
+ // Own posts never wake the agent, allowlisted or not.
205
+ if (
206
+ (this.identity.botId !== undefined &&
207
+ author.botId === this.identity.botId) ||
208
+ (this.identity.botUserId !== undefined &&
209
+ author.userId !== "" &&
210
+ author.userId === this.identity.botUserId)
211
+ ) {
212
+ return { allow: false, reason: "own post (loop safety)" };
213
+ }
214
+
215
+ const allowed =
216
+ (author.botId !== undefined && this.allowFrom.has(author.botId)) ||
217
+ (author.userId !== "" && this.allowFrom.has(author.userId));
218
+ if (!allowed) {
219
+ return { allow: false, reason: "author not in botMentions.allowFrom" };
220
+ }
221
+
222
+ const depth = message.delegation?.depth ?? 1;
223
+ if (depth > this.maxDepth) {
224
+ return {
225
+ allow: false,
226
+ reason: `delegation depth ${depth} exceeds max ${this.maxDepth}`,
227
+ };
228
+ }
229
+
230
+ this.maybeEvictIdleThreads(nowMs);
231
+ const key = `${message.channelId}:${message.threadTs}`;
232
+ const budget = this.threadBudgets.get(key) ?? {
233
+ total: 0,
234
+ recent: [],
235
+ lastAtMs: nowMs,
236
+ };
237
+ budget.recent = budget.recent.filter(
238
+ (atMs) => atMs > nowMs - this.windowMs
239
+ );
240
+ if (budget.total >= this.maxPerThreadTotal) {
241
+ return {
242
+ allow: false,
243
+ reason: `thread lifetime budget exhausted (${this.maxPerThreadTotal})`,
244
+ };
245
+ }
246
+ if (budget.recent.length >= this.maxPerThreadPerHour) {
247
+ return {
248
+ allow: false,
249
+ reason: `thread rate budget exhausted (${this.maxPerThreadPerHour}/window)`,
250
+ };
251
+ }
252
+ budget.total += 1;
253
+ budget.recent.push(nowMs);
254
+ budget.lastAtMs = nowMs;
255
+ this.threadBudgets.set(key, budget);
256
+ this.log.info("bot mention admitted", {
257
+ channel: message.channelId,
258
+ thread_ts: message.threadTs,
259
+ from_bot: author.botId ?? author.userId,
260
+ depth,
261
+ thread_total: budget.total,
262
+ });
263
+ return { allow: true, depth };
264
+ }
265
+
266
+ private maybeEvictIdleThreads(nowMs: number): void {
267
+ if (this.threadBudgets.size < THREAD_SWEEP_THRESHOLD) {
268
+ return;
269
+ }
270
+ for (const [key, budget] of this.threadBudgets) {
271
+ if (nowMs - budget.lastAtMs > THREAD_IDLE_EVICT_MS) {
272
+ this.threadBudgets.delete(key);
273
+ }
274
+ }
275
+ }
276
+ }
277
+
278
+ /**
279
+ * Post an @mention of another bot with delegation metadata attached, so the
280
+ * target's `engagement.botMentions` gate can read the hop count. Use this
281
+ * from dispatcher tools instead of a bare `chat.postMessage`.
282
+ */
283
+ export async function postDelegationMention(args: {
284
+ botToken: SlackBotToken | undefined;
285
+ channelId: string;
286
+ /** Target bot's Slack *user* id (`U…`) — what `<@…>` mentions require. */
287
+ targetUserId: string;
288
+ text: string;
289
+ threadTs?: string;
290
+ /** Defaults to 1 (dispatcher woken by a human). */
291
+ depth?: number;
292
+ fromAgent?: string;
293
+ originSessionId?: string;
294
+ }): Promise<{ ts: string }> {
295
+ const posted = await postMessage({
296
+ botToken: args.botToken,
297
+ channel: args.channelId,
298
+ text: `<@${args.targetUserId}> ${args.text}`.trim(),
299
+ threadTs: args.threadTs,
300
+ // Mention markup only notifies via mrkdwn `text`, and metadata must
301
+ // ride the same chat.postMessage call.
302
+ plainText: true,
303
+ metadata: buildDelegationMetadata({
304
+ depth: args.depth ?? 1,
305
+ fromAgent: args.fromAgent,
306
+ originSessionId: args.originSessionId,
307
+ }),
308
+ });
309
+ if (posted.ts === "") {
310
+ throw new Error(
311
+ "Slack delegation mention posted without a message ts; cannot track the handoff."
312
+ );
313
+ }
314
+ return { ts: posted.ts };
315
+ }
@@ -0,0 +1,363 @@
1
+ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
2
+ import {
3
+ createChannelPostWatcher,
4
+ SlackChannelAllowlist,
5
+ } from "./channel-watch.js";
6
+ import {
7
+ parseChannelMessageEvent,
8
+ type SlackChannelMessageEvent,
9
+ } from "./inbound.js";
10
+ import type { SlackMessage } from "./types.js";
11
+
12
+ function channelPostBody(args: {
13
+ channel?: string;
14
+ ts?: string;
15
+ threadTs?: string;
16
+ text?: string;
17
+ subtype?: string;
18
+ botId?: string;
19
+ channelType?: string;
20
+ user?: string | null;
21
+ }): unknown {
22
+ return {
23
+ type: "event_callback",
24
+ event_id: "Ev1",
25
+ team_id: "T1",
26
+ event: {
27
+ type: "message",
28
+ channel: args.channel ?? "C123",
29
+ channel_type: args.channelType ?? "channel",
30
+ ts: args.ts ?? "100.1",
31
+ ...(args.threadTs === undefined ? {} : { thread_ts: args.threadTs }),
32
+ text: args.text ?? "new bug report",
33
+ ...(args.user === null ? {} : { user: args.user ?? "U1" }),
34
+ ...(args.subtype === undefined ? {} : { subtype: args.subtype }),
35
+ ...(args.botId === undefined ? {} : { bot_id: args.botId }),
36
+ },
37
+ };
38
+ }
39
+
40
+ describe("parseChannelMessageEvent", () => {
41
+ it("parses a top-level channel post", () => {
42
+ const parsed = parseChannelMessageEvent(channelPostBody({}));
43
+ expect(parsed).not.toBeNull();
44
+ expect(parsed?.kind).toBe("post");
45
+ if (parsed?.kind === "post") {
46
+ expect(parsed.message.channelId).toBe("C123");
47
+ expect(parsed.message.ts).toBe("100.1");
48
+ expect(parsed.message.threadTs).toBe("100.1");
49
+ }
50
+ });
51
+
52
+ it("parses private-channel (group) posts", () => {
53
+ const parsed = parseChannelMessageEvent(
54
+ channelPostBody({ channel: "G9", channelType: "group" })
55
+ );
56
+ expect(parsed?.kind).toBe("post");
57
+ });
58
+
59
+ it("drops bot-authored posts", () => {
60
+ expect(
61
+ parseChannelMessageEvent(channelPostBody({ botId: "B77" }))
62
+ ).toBeNull();
63
+ });
64
+
65
+ it("keeps bot-authored posts when includeBotAuthored is set", () => {
66
+ const parsed = parseChannelMessageEvent(channelPostBody({ botId: "B77" }), {
67
+ includeBotAuthored: true,
68
+ });
69
+ expect(parsed?.kind).toBe("post");
70
+ if (parsed?.kind === "post") {
71
+ expect(parsed.message.author?.isBot).toBe(true);
72
+ expect(parsed.message.author?.botId).toBe("B77");
73
+ }
74
+ });
75
+
76
+ it("keeps userless bot_message webhook posts when includeBotAuthored is set", () => {
77
+ const body = channelPostBody({
78
+ botId: "B77",
79
+ subtype: "bot_message",
80
+ user: null,
81
+ text: "PagerDuty: high error rate on api",
82
+ });
83
+ expect(parseChannelMessageEvent(body)).toBeNull();
84
+ const parsed = parseChannelMessageEvent(body, {
85
+ includeBotAuthored: true,
86
+ });
87
+ expect(parsed?.kind).toBe("post");
88
+ if (parsed?.kind === "post") {
89
+ expect(parsed.message.author).toEqual({
90
+ userId: "",
91
+ isBot: true,
92
+ botId: "B77",
93
+ });
94
+ }
95
+ });
96
+
97
+ it("recovers alert content from attachments when top-level text is empty", () => {
98
+ const parsed = parseChannelMessageEvent(
99
+ {
100
+ type: "event_callback",
101
+ team_id: "T1",
102
+ event: {
103
+ type: "message",
104
+ subtype: "bot_message",
105
+ channel: "C123",
106
+ channel_type: "channel",
107
+ ts: "100.1",
108
+ bot_id: "B77",
109
+ username: "Datadog",
110
+ text: "",
111
+ attachments: [
112
+ {
113
+ title:
114
+ "Triggered: [service-monitor prod:temporal-bugbot] High cache error rate",
115
+ title_link: "https://us5.datadoghq.com/monitors/19610160",
116
+ text: "Service temporal-bugbot has a high cache error rate.",
117
+ },
118
+ ],
119
+ },
120
+ },
121
+ { includeBotAuthored: true }
122
+ );
123
+ expect(parsed?.kind).toBe("post");
124
+ if (parsed?.kind === "post") {
125
+ expect(parsed.message.text).toBe("");
126
+ expect(parsed.message.body).toContain("temporal-bugbot");
127
+ // Raw body keeps the monitor link; markdown is the model-facing view.
128
+ expect(parsed.message.body).toContain(
129
+ "https://us5.datadoghq.com/monitors/19610160"
130
+ );
131
+ expect(parsed.message.markdown).toContain("High cache error rate");
132
+ }
133
+ });
134
+
135
+ it("drops DMs and unknown subtypes", () => {
136
+ expect(
137
+ parseChannelMessageEvent(
138
+ channelPostBody({ channel: "D5", channelType: "im" })
139
+ )
140
+ ).toBeNull();
141
+ expect(
142
+ parseChannelMessageEvent(channelPostBody({ subtype: "channel_join" }))
143
+ ).toBeNull();
144
+ });
145
+
146
+ it("parses message_changed into an edit with the new text", () => {
147
+ const parsed = parseChannelMessageEvent({
148
+ type: "event_callback",
149
+ event: {
150
+ type: "message",
151
+ subtype: "message_changed",
152
+ channel: "C123",
153
+ channel_type: "channel",
154
+ ts: "101.0",
155
+ message: { type: "message", ts: "100.1", text: "edited!", user: "U1" },
156
+ },
157
+ });
158
+ expect(parsed?.kind).toBe("edited");
159
+ if (parsed?.kind === "edited") {
160
+ expect(parsed.message.ts).toBe("100.1");
161
+ expect(parsed.message.text).toBe("edited!");
162
+ }
163
+ });
164
+
165
+ it("parses message_deleted into a deletion", () => {
166
+ const parsed = parseChannelMessageEvent({
167
+ type: "event_callback",
168
+ event: {
169
+ type: "message",
170
+ subtype: "message_deleted",
171
+ channel: "C123",
172
+ channel_type: "channel",
173
+ ts: "101.0",
174
+ deleted_ts: "100.1",
175
+ },
176
+ });
177
+ expect(parsed).toEqual({ kind: "deleted", channelId: "C123", ts: "100.1" });
178
+ });
179
+ });
180
+
181
+ describe("SlackChannelAllowlist", () => {
182
+ it("matches ids directly and names after resolution", () => {
183
+ const allowlist = new SlackChannelAllowlist(["C123", "#issues-glass"]);
184
+ expect(allowlist.matches("C123")).toBe(true);
185
+ expect(allowlist.matches("C999")).toBe(false);
186
+ expect(allowlist.unresolvedNames()).toEqual(["issues-glass"]);
187
+ allowlist.applyResolvedNames(new Map([["issues-glass", "C999"]]));
188
+ expect(allowlist.matches("C999")).toBe(true);
189
+ });
190
+
191
+ it("treats bare names as names, not ids", () => {
192
+ const allowlist = new SlackChannelAllowlist(["issues-glass"]);
193
+ expect(allowlist.unresolvedNames()).toEqual(["issues-glass"]);
194
+ });
195
+ });
196
+
197
+ describe("createChannelPostWatcher", () => {
198
+ const dispatched: Array<{ message: SlackMessage; eventId?: string }> = [];
199
+
200
+ function watcher(args?: {
201
+ posts?: "top-level" | "all";
202
+ debounceMs?: number;
203
+ includeBotPosts?: boolean;
204
+ botUserId?: string;
205
+ botId?: string;
206
+ allow?: string[];
207
+ }) {
208
+ return createChannelPostWatcher({
209
+ options: {
210
+ allow: args?.allow ?? ["C123"],
211
+ ...(args?.posts === undefined ? {} : { posts: args.posts }),
212
+ ...(args?.debounceMs === undefined
213
+ ? {}
214
+ : { debounceMs: args.debounceMs }),
215
+ ...(args?.includeBotPosts === undefined
216
+ ? {}
217
+ : { includeBotPosts: args.includeBotPosts }),
218
+ },
219
+ allowlist: new SlackChannelAllowlist(args?.allow ?? ["C123"]),
220
+ getBotUserId: () => args?.botUserId,
221
+ getBotId: () => args?.botId,
222
+ dispatch: (message, eventId) =>
223
+ dispatched.push(
224
+ eventId === undefined ? { message } : { message, eventId }
225
+ ),
226
+ });
227
+ }
228
+
229
+ function post(args: {
230
+ channel?: string;
231
+ ts?: string;
232
+ threadTs?: string;
233
+ text?: string;
234
+ subtype?: string;
235
+ botId?: string;
236
+ user?: string | null;
237
+ }): SlackChannelMessageEvent {
238
+ const parsed = parseChannelMessageEvent(channelPostBody(args), {
239
+ includeBotAuthored: true,
240
+ });
241
+ if (parsed === null) {
242
+ throw new Error("fixture did not parse");
243
+ }
244
+ return parsed;
245
+ }
246
+
247
+ beforeEach(() => {
248
+ dispatched.length = 0;
249
+ });
250
+ afterEach(() => {
251
+ vi.useRealTimers();
252
+ });
253
+
254
+ it("dispatches allowlisted top-level posts immediately by default", () => {
255
+ const w = watcher();
256
+ expect(w.handle(post({}), "Ev1")).toBe(true);
257
+ expect(dispatched).toHaveLength(1);
258
+ expect(dispatched[0]?.eventId).toBe("Ev1");
259
+ });
260
+
261
+ it("skips channels outside the allowlist", () => {
262
+ const w = watcher();
263
+ w.handle(post({ channel: "C999" }));
264
+ expect(dispatched).toHaveLength(0);
265
+ });
266
+
267
+ it("skips thread replies unless posts is 'all'", () => {
268
+ const w = watcher();
269
+ w.handle(post({ ts: "100.2", threadTs: "100.1" }));
270
+ expect(dispatched).toHaveLength(0);
271
+
272
+ const all = watcher({ posts: "all" });
273
+ all.handle(post({ ts: "100.2", threadTs: "100.1" }));
274
+ expect(dispatched).toHaveLength(1);
275
+ });
276
+
277
+ it("skips posts that mention the bot (app_mention path owns them)", () => {
278
+ const w = watcher({ botUserId: "UBOT" });
279
+ w.handle(post({ text: "<@UBOT> please triage" }));
280
+ expect(dispatched).toHaveLength(0);
281
+ });
282
+
283
+ it("skips bot-authored posts unless includeBotPosts is set", () => {
284
+ const w = watcher();
285
+ w.handle(post({ botId: "B77", user: null, subtype: "bot_message" }));
286
+ expect(dispatched).toHaveLength(0);
287
+
288
+ const withBots = watcher({
289
+ includeBotPosts: true,
290
+ botId: "BSELF",
291
+ botUserId: "UBOT",
292
+ });
293
+ withBots.handle(post({ botId: "B77", user: null, subtype: "bot_message" }));
294
+ expect(dispatched).toHaveLength(1);
295
+ expect(dispatched[0]?.message.author?.botId).toBe("B77");
296
+ });
297
+
298
+ it("skips bot-authored posts when includeBotPosts is on but bot identity is unknown", () => {
299
+ const w = watcher({ includeBotPosts: true });
300
+ w.handle(post({ botId: "B77", user: null, subtype: "bot_message" }));
301
+ expect(dispatched).toHaveLength(0);
302
+ });
303
+
304
+ it("always skips the watching bot's own posts (loop safety)", () => {
305
+ const w = watcher({
306
+ includeBotPosts: true,
307
+ botUserId: "UBOT",
308
+ botId: "BSELF",
309
+ });
310
+ w.handle(post({ botId: "BSELF", user: null, subtype: "bot_message" }));
311
+ expect(dispatched).toHaveLength(0);
312
+
313
+ // Same app posting with its bot user id attached.
314
+ w.handle(post({ botId: "B99", user: "UBOT" }));
315
+ expect(dispatched).toHaveLength(0);
316
+
317
+ // A different bot still dispatches.
318
+ w.handle(post({ botId: "B77", user: null, subtype: "bot_message" }));
319
+ expect(dispatched).toHaveLength(1);
320
+ });
321
+
322
+ it("debounces: edits update the pending text, deletes cancel", () => {
323
+ vi.useFakeTimers();
324
+ const w = watcher({ debounceMs: 1_000 });
325
+
326
+ w.handle(post({ ts: "100.1", text: "first" }));
327
+ expect(dispatched).toHaveLength(0);
328
+ const edit = parseChannelMessageEvent({
329
+ type: "event_callback",
330
+ event: {
331
+ type: "message",
332
+ subtype: "message_changed",
333
+ channel: "C123",
334
+ channel_type: "channel",
335
+ ts: "101.0",
336
+ message: { type: "message", ts: "100.1", text: "edited", user: "U1" },
337
+ },
338
+ });
339
+ if (edit === null) {
340
+ throw new Error("edit fixture did not parse");
341
+ }
342
+ w.handle(edit);
343
+ vi.advanceTimersByTime(1_000);
344
+ expect(dispatched).toHaveLength(1);
345
+ expect(dispatched[0]?.message.text).toBe("edited");
346
+
347
+ w.handle(post({ ts: "200.1", text: "will be deleted" }));
348
+ w.handle({ kind: "deleted", channelId: "C123", ts: "200.1" });
349
+ vi.advanceTimersByTime(5_000);
350
+ expect(dispatched).toHaveLength(1);
351
+
352
+ w.stop();
353
+ });
354
+
355
+ it("stop() cancels pending dispatches", () => {
356
+ vi.useFakeTimers();
357
+ const w = watcher({ debounceMs: 1_000 });
358
+ w.handle(post({}));
359
+ w.stop();
360
+ vi.advanceTimersByTime(5_000);
361
+ expect(dispatched).toHaveLength(0);
362
+ });
363
+ });