@sentry/junior 0.223.0 → 0.225.0

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 (57) hide show
  1. package/dist/{agent-hooks-DZU5nsd1.js → agent-hooks-CRuRcOg9.js} +9 -9
  2. package/dist/{agent-Bzbf92vv.js → agent-runner-Cad1RtWn.js} +1016 -144
  3. package/dist/api/schema.js +2 -2
  4. package/dist/api.js +16 -16
  5. package/dist/app.js +1538 -71
  6. package/dist/{briefs-BJPfCrlT.js → briefs-BtnkPWT5.js} +5 -5
  7. package/dist/{catalog-runtime-BthunUDD.js → catalog-runtime-pv4grNNb.js} +1 -1
  8. package/dist/{checkpoint-Deu-OVb3.js → checkpoint-6fkAdB3g.js} +8 -8
  9. package/dist/cli/chat.js +14 -15
  10. package/dist/cli/check.js +3 -3
  11. package/dist/cli/main.js +1 -1
  12. package/dist/cli/plugins.js +3 -3
  13. package/dist/cli/snapshot-create.js +3 -3
  14. package/dist/cli/upgrade.js +3 -3
  15. package/dist/{client-DjC6-6iU.js → client-ihmkx60d.js} +2 -2
  16. package/dist/{config-CtoUVs72.js → config-DMg679BW.js} +11 -13
  17. package/dist/{conversation-1HUoZr2l.js → conversation-Bygi39Xj.js} +1 -1
  18. package/dist/{conversation-privacy-xS1t92xa.js → conversation-privacy-ZYy05Y3q.js} +1 -1
  19. package/dist/credential-sync-DCB2SwnQ.js +940 -0
  20. package/dist/{execution-stats-CrXs9vSH.js → execution-stats-CR9CYpyj.js} +3 -3
  21. package/dist/{executor-AOhG9X1t.js → executor-B9R4cHte.js} +1 -1
  22. package/dist/instrumentation.js +2 -2
  23. package/dist/{job-queue-CNd9cpIy.js → job-queue-D2SiTrnt.js} +1 -1
  24. package/dist/{logging-DiN3o3nB.js → logging-CcTvYQ8A.js} +2 -2
  25. package/dist/{model-profile-CZmVoGdX.js → model-profile-BwU8mqwD.js} +14 -1
  26. package/dist/nitro.js +3 -3
  27. package/dist/{pending-auth-D7vmttn_.js → oauth-authorization-BS4vPJmu.js} +6 -48
  28. package/dist/{oauth-callback-server-Ca0VLUEw.js → oauth-callback-server-DnlQsGBJ.js} +4 -4
  29. package/dist/{profile-BsNrw8lj.js → profile-CV97YuiL.js} +5 -9
  30. package/dist/{prompt-90miy8VV.js → prompt-CL8zxsmm.js} +1 -1
  31. package/dist/{registry-Dankt-Pl.js → registry-C8I4IejN.js} +3 -3
  32. package/dist/{resolve-B38CpJe2.js → resolve-DXVAzYa3.js} +2 -2
  33. package/dist/{runner-Ck5rEjCm.js → runner-Die1RJ_z.js} +9 -10
  34. package/dist/{sandbox-egress-signals-BOfFkvly.js → sandbox-egress-signals-BZepyVh1.js} +1 -1
  35. package/dist/{schema-RpbW5Od2.js → schema-BE4uso4i.js} +1 -1
  36. package/dist/{short-title-BHd_jUFX.js → short-title-3wYH3ipl.js} +11 -5
  37. package/dist/{skills-DbP4TDft.js → skills-DUcDEgDI.js} +2 -2
  38. package/dist/{spawn-DzXc_PKO.js → spawn-o15i3tJu.js} +2 -2
  39. package/dist/{stats-pNGp3G5q.js → stats-DawHjcTw.js} +6 -6
  40. package/dist/{task-queue-Cy9y1foT.js → task-queue-D3PLY59C.js} +3 -3
  41. package/dist/{task-runner-DI1FBJFJ.js → task-runner-BMl2aM2v.js} +9 -84
  42. package/dist/{thread-state-DXgxOYpZ.js → thread-state-CAR6gdAm.js} +5 -5
  43. package/dist/{auth-pause-state-Bn2vX4PA.js → turn-failure-response-CZyoUMkD.js} +74 -73
  44. package/dist/{turn-lifecycle-Bjf6y2GO.js → turn-lifecycle-DPDHKi2U.js} +1 -1
  45. package/dist/turn-session-routing-Jkmmxpp-.js +77 -0
  46. package/dist/{validation-D5CIslEu.js → validation-BcGGxOa6.js} +1 -1
  47. package/dist/{version-_ZQFlslB.js → version-DYcuOFQb.js} +1 -1
  48. package/dist/version.js +1 -1
  49. package/dist/{web-authorization-6mmfS_o-.js → web-authorization-B09B8A8R.js} +1 -1
  50. package/dist/{web-input-BJpWm6go.js → web-input-CGE58TpK.js} +7 -7
  51. package/dist/{work-DvIlJR3C.js → work-Bm90pR9P.js} +52 -10
  52. package/dist/{worker-D1qidg-Y.js → worker-BvHr6JhE.js} +5 -5
  53. package/package.json +4 -4
  54. package/dist/agent-runner-2VQ9WhkG.js +0 -679
  55. package/dist/credential-sync-CKdXEUDO.js +0 -2811
  56. package/dist/experimental-CIXf7Uye.js +0 -46
  57. package/dist/turn-execution-BNOXrZ-M.js +0 -47
@@ -1,2811 +0,0 @@
1
- import { N as getMcpProviderErrorAttributes, X as createUserTokenStore, a as deleteMcpAuthSession, d as putMcpStoredOAuthCredentials, h as resolvePluginOAuthAccount, i as finalizeMcpAuthorization, l as getMcpAuthSession, u as getMcpStoredOAuthCredentials, y as lookupSlackUserProfile } from "./agent-Bzbf92vv.js";
2
- import { m as escapeXml } from "./client-DjC6-6iU.js";
3
- import { L as toOptionalString, S as withLogContext, d as logInfo, f as logWarn, m as runBestEffort, u as logException, y as setTags } from "./logging-DiN3o3nB.js";
4
- import { a as getChatConfig, h as normalizeSlackEmojiName, n as botConfig, o as getRuntimeMetadata } from "./config-CtoUVs72.js";
5
- import { n as defaultModelId } from "./model-profile-CZmVoGdX.js";
6
- import { _ as parseSlackMessageTs, g as parseSlackUserId, l as isUserActor, s as createSlackResumeActor } from "./context-Bplcgypl.js";
7
- import { G as isDmChannel, H as getHeaderString, Jt as getSqlExecutor, K as normalizeSlackConversationId, Kt as getConversationStore, Nt as buildSentryConversationUrl, U as getSlackClient, ln as upsertIdentity, mt as getDashboardConversationLink, o as getPluginSlackConversationLink, pn as requireSlackDestination, un as upsertLinkedIdentity, z as SlackActionError } from "./agent-hooks-DZU5nsd1.js";
8
- import { i as homeDir } from "./package-discovery-7a9YqLhE.js";
9
- import { n as buildOAuthTokenRequest, r as parseOAuthTokenResponse, u as hasRequiredOAuthScope } from "./registry-Dankt-Pl.js";
10
- import { n as pluginCatalogRuntime } from "./catalog-runtime-BthunUDD.js";
11
- import { t as discoverSkills } from "./skills-DbP4TDft.js";
12
- import { B as recordAuthenticationLinked, F as loadProjection, V as recordAuthorizationCompleted, Y as hydrateConversationMessages, ct as recordDeliveredAssistantMessage, d as persistWithRetry, i as abandonTurnRecord, j as commitAcceptedReply, lt as turnHasReply, m as buildTurnErrorResponse, n as loadTurnCheckpoint, o as failTurnRecord, ot as markConversationMessage, r as saveTurnCheckpoint, s as getTurnRecord, tt as buildConversationContext, u as recordTurnSummary } from "./checkpoint-Deu-OVb3.js";
13
- import { E as acquireActiveLock, w as getStateAdapter } from "./profile-BsNrw8lj.js";
14
- import { Pt as requestConversationWork, c as parseSlackThreadId, d as resolveSlackChannelIdFromThreadId, l as readSlackRawMessageContext, u as resolveSlackChannelIdFromMessage, wt as ensureConversationWake } from "./stats-pNGp3G5q.js";
15
- import { a as isPendingAuthLatestRequest, f as CooperativeTurnYieldError, g as isCooperativeTurnYieldError, i as getConversationPendingAuth, r as clearPendingAuth, s as getAssistantReplyText, x as markTurnFailed } from "./pending-auth-D7vmttn_.js";
16
- import { a as parseOAuthStatePayload, c as escapeSlackMrkdwnText, d as normalizeSlackStatusText, f as truncateStatusText, g as removeReactionFromMessage, h as postSlackMessage, i as formatProviderLabel, l as formatSlackLink, o as resolveBaseUrl, p as addReactionToMessage, u as normalizeSlackReplyMarkdown, v as RetryableDeliveryError } from "./agent-runner-2VQ9WhkG.js";
17
- import { t as getVercelConversationWorkQueue } from "./vercel-queue-DIkVcfK4.js";
18
- import { a as getTurnRequestDeadline } from "./job-queue-CNd9cpIy.js";
19
- import { i as resolveTurnSessionRouting, n as scheduleSessionCompletedPluginTasks } from "./task-runner-DI1FBJFJ.js";
20
- import { n as getTurnLifecycle } from "./turn-lifecycle-Bjf6y2GO.js";
21
- import { n as executeTurn, t as AgentRunError } from "./turn-execution-BNOXrZ-M.js";
22
- import { c as configValueSchema, n as getPersistedSandboxState, o as persistThreadStateById, r as getPersistedThreadState, s as coerceThreadConversationState, t as getLocationConfigurationService } from "./thread-state-DXgxOYpZ.js";
23
- import { a as requireTurnFailureEventId, c as getTurnUserMessage, d as getTurnUserSlackMessageTs, f as buildDeliveredTurnStatePatch, l as getTurnUserMessageId, n as persistAuthPauseTurnState, o as getInterruptionMarker, r as finalizeFailedTurnReplyWithEvent, s as loadPendingMessageCards, u as getTurnUserReplyAttachmentContext } from "./auth-pause-state-Bn2vX4PA.js";
24
- import { n as relayLocalOAuthCallback } from "./oauth-relay-CdCnu80W.js";
25
- import { n as deleteWebAuthorization } from "./web-authorization-6mmfS_o-.js";
26
- import path from "node:path";
27
- import { z } from "zod";
28
- import { createHmac, createSecretKey, timingSafeEqual } from "node:crypto";
29
- import { pluginStoredTokensSchema } from "@sentry/junior-plugin-api";
30
- import fs from "node:fs";
31
- //#region src/chat/slack/assistant-thread/status-render.ts
32
- const DEFAULT_STATUS_CONTEXTS = {
33
- thinking: "…",
34
- searching: "sources",
35
- reading: "task",
36
- reviewing: "results",
37
- drafting: "reply",
38
- running: "tasks"
39
- };
40
- function formatAssistantStatusText(verb, context) {
41
- const normalizedVerb = normalizeSlackStatusText(verb).trim().toLowerCase();
42
- const normalizedContext = normalizeSlackStatusText(context ?? "") || DEFAULT_STATUS_CONTEXTS[normalizedVerb] || "";
43
- if (!normalizedVerb) return truncateStatusText(normalizedContext || "Working");
44
- const displayVerb = `${normalizedVerb[0]?.toUpperCase() ?? ""}${normalizedVerb.slice(1)}`;
45
- return truncateStatusText(normalizedContext ? `${displayVerb} ${normalizedContext}` : displayVerb);
46
- }
47
- /** Build assistant progress text from a verb and optional context. */
48
- function makeAssistantStatus(verb, context) {
49
- return { text: formatAssistantStatusText(verb, context) };
50
- }
51
- /** Normalize a progress update into the visible Slack loading copy. */
52
- function renderAssistantStatus(args) {
53
- const visible = truncateStatusText(normalizeSlackStatusText(args.status.text));
54
- return {
55
- key: visible,
56
- visible
57
- };
58
- }
59
- /** Select and normalize the loading messages used for Slack status rotation. */
60
- function selectAssistantLoadingMessages(args) {
61
- const random = args.random ?? Math.random;
62
- const normalized = Array.from(new Set(args.messages.map((message) => truncateStatusText(normalizeSlackStatusText(message))).filter((message) => message.length > 0)));
63
- if (normalized.length === 0) return;
64
- const shuffled = [...normalized];
65
- for (let index = shuffled.length - 1; index > 0; index -= 1) {
66
- const otherIndex = Math.floor(random() * (index + 1));
67
- [shuffled[index], shuffled[otherIndex]] = [shuffled[otherIndex], shuffled[index]];
68
- }
69
- return shuffled.slice(0, 10);
70
- }
71
- //#endregion
72
- //#region src/chat/slack/assistant-thread/status-scheduler.ts
73
- const STATUS_UPDATE_DEBOUNCE_MS = 1e3;
74
- const STATUS_MIN_VISIBLE_MS = 1200;
75
- const STATUS_ROTATION_INTERVAL_MS = 3e4;
76
- /**
77
- * Pace assistant loading-state writes for a single turn.
78
- *
79
- * This layer owns only local scheduling policy: debounce, minimum visible
80
- * duration, refresh cadence, and write ordering. It deals in user-visible
81
- * progress copy; the transport decides how that maps onto Slack's `status`
82
- * versus `loading_messages` fields.
83
- */
84
- function createAssistantStatusScheduler(args) {
85
- const now = args.now ?? (() => Date.now());
86
- const setTimer = args.setTimer ?? ((callback, delayMs) => setTimeout(callback, delayMs));
87
- const clearTimer = args.clearTimer ?? ((timer) => clearTimeout(timer));
88
- const random = args.random ?? Math.random;
89
- const loadingMessages = selectAssistantLoadingMessages({
90
- messages: args.loadingMessages ?? [],
91
- random
92
- });
93
- const defaultStatus = makeAssistantStatus("thinking");
94
- let active = false;
95
- let currentKey = "";
96
- let currentVisibleStatus = "";
97
- let currentLoadingMessages;
98
- let lastStatusAt = 0;
99
- let pendingStatus = null;
100
- let pendingKey = "";
101
- let pendingTimer = null;
102
- let rotationTimer = null;
103
- let inflightStatusUpdate = Promise.resolve();
104
- const enqueueStatusUpdate = (task) => {
105
- const request = inflightStatusUpdate.catch(() => void 0).then(async () => {
106
- await task();
107
- });
108
- inflightStatusUpdate = request.catch(() => void 0);
109
- return request;
110
- };
111
- const scheduleRotation = () => {
112
- if (rotationTimer) {
113
- clearTimer(rotationTimer);
114
- rotationTimer = null;
115
- }
116
- if (!active || !currentVisibleStatus) return;
117
- rotationTimer = setTimer(() => {
118
- rotationTimer = null;
119
- if (!active || !currentVisibleStatus) return;
120
- postStatus(currentVisibleStatus, currentLoadingMessages);
121
- }, STATUS_ROTATION_INTERVAL_MS);
122
- };
123
- const getLoadingMessagesForVisibleStatus = (visible) => visible ? [visible] : void 0;
124
- const getInitialStatusText = () => {
125
- if (loadingMessages?.length) return loadingMessages[0];
126
- return defaultStatus.text;
127
- };
128
- const haveSameLoadingMessages = (left, right) => {
129
- if (left === right) return true;
130
- if (!left || !right || left.length !== right.length) return false;
131
- return left.every((message, index) => message === right[index]);
132
- };
133
- const postStatus = async (text, nextLoadingMessages) => {
134
- if (!text && !currentVisibleStatus) return;
135
- currentVisibleStatus = text;
136
- currentLoadingMessages = nextLoadingMessages;
137
- lastStatusAt = now();
138
- scheduleRotation();
139
- await enqueueStatusUpdate(async () => {
140
- await args.sendStatus(text, nextLoadingMessages);
141
- });
142
- };
143
- const postRenderedStatus = async (status) => {
144
- const presentation = renderAssistantStatus({ status });
145
- const nextLoadingMessages = getLoadingMessagesForVisibleStatus(presentation.visible);
146
- currentKey = presentation.key;
147
- await postStatus(presentation.visible, nextLoadingMessages);
148
- };
149
- const clearPending = () => {
150
- if (pendingTimer) {
151
- clearTimer(pendingTimer);
152
- pendingTimer = null;
153
- }
154
- pendingStatus = null;
155
- pendingKey = "";
156
- };
157
- const flushPending = async () => {
158
- if (!active || !pendingStatus) {
159
- clearPending();
160
- return;
161
- }
162
- const next = pendingStatus;
163
- clearPending();
164
- if (renderAssistantStatus({ status: next }).key !== currentKey) await postRenderedStatus(next);
165
- };
166
- return {
167
- async clear() {
168
- active = false;
169
- clearPending();
170
- if (rotationTimer) {
171
- clearTimer(rotationTimer);
172
- rotationTimer = null;
173
- }
174
- currentKey = "";
175
- await postStatus("");
176
- },
177
- update(status) {
178
- if (!active) {
179
- active = true;
180
- clearPending();
181
- if (status) {
182
- postRenderedStatus(status);
183
- return;
184
- }
185
- currentKey = "initial";
186
- postStatus(getInitialStatusText(), loadingMessages);
187
- return;
188
- }
189
- if (!status) {
190
- clearPending();
191
- currentKey = "initial";
192
- postStatus(getInitialStatusText(), loadingMessages);
193
- return;
194
- }
195
- const presentation = renderAssistantStatus({ status });
196
- if (!presentation.visible) return;
197
- if (presentation.key === currentKey || presentation.key === pendingKey) return;
198
- if (presentation.visible === currentVisibleStatus) {
199
- clearPending();
200
- currentKey = presentation.key;
201
- const nextLoadingMessages = getLoadingMessagesForVisibleStatus(presentation.visible);
202
- if (!haveSameLoadingMessages(currentLoadingMessages, nextLoadingMessages)) postStatus(presentation.visible, nextLoadingMessages);
203
- return;
204
- }
205
- const elapsed = now() - lastStatusAt;
206
- const waitMs = Math.max(STATUS_UPDATE_DEBOUNCE_MS - elapsed, STATUS_MIN_VISIBLE_MS - elapsed, 0);
207
- if (waitMs <= 0) {
208
- clearPending();
209
- postRenderedStatus(status);
210
- return;
211
- }
212
- pendingStatus = status;
213
- pendingKey = presentation.key;
214
- if (pendingTimer) return;
215
- pendingTimer = setTimer(() => {
216
- pendingTimer = null;
217
- flushPending();
218
- }, Math.max(1, waitMs));
219
- }
220
- };
221
- }
222
- //#endregion
223
- //#region src/chat/slack/assistant-thread/status-send.ts
224
- /**
225
- * Slack's assistant loading UI accepts both `status` and `loading_messages`,
226
- * but product policy keeps `status` stable and generic. User-visible progress
227
- * copy belongs in `loading_messages`.
228
- */
229
- const SLACK_ASSISTANT_ACTIVE_STATUS = "is working on your request...";
230
- /**
231
- * Build a best-effort sender for Slack's assistant loading state.
232
- *
233
- * The `text` argument is internal progress copy. This transport maps it onto
234
- * Slack's loading surface and keeps the raw Slack `status` field fixed.
235
- */
236
- function createSlackAdapterStatusSender(args) {
237
- const adapter = args.getSlackAdapter();
238
- const boundToken = getSlackAdapterRequestToken(adapter);
239
- return async (text, loadingMessages) => {
240
- const channelId = args.channelId;
241
- const threadTs = args.threadTs;
242
- if (!channelId || !threadTs) return;
243
- const normalizedChannelId = normalizeSlackConversationId(channelId);
244
- if (!normalizedChannelId) return;
245
- const nextLoadingMessages = text ? loadingMessages ?? [text] : void 0;
246
- try {
247
- await runWithBoundSlackToken(adapter, boundToken, () => adapter.setAssistantStatus(normalizedChannelId, threadTs, text ? SLACK_ASSISTANT_ACTIVE_STATUS : "", nextLoadingMessages));
248
- } catch (error) {
249
- logAssistantStatusFailure({
250
- status: text,
251
- error,
252
- channelId,
253
- normalizedChannelId,
254
- threadTs
255
- });
256
- }
257
- };
258
- }
259
- /**
260
- * Build a best-effort sender for Slack's assistant loading state over raw Web
261
- * API calls. As with the adapter-backed path, the dynamic copy goes to
262
- * `loading_messages` while `status` stays fixed and generic.
263
- */
264
- function createSlackWebApiStatusSender(args) {
265
- const getClient = args.getSlackClient ?? getSlackClient;
266
- return async (text, loadingMessages) => {
267
- const channelId = args.channelId;
268
- const threadTs = args.threadTs;
269
- if (!channelId || !threadTs) return;
270
- const normalizedChannelId = normalizeSlackConversationId(channelId);
271
- if (!normalizedChannelId) return;
272
- const nextLoadingMessages = text ? loadingMessages ?? [text] : void 0;
273
- try {
274
- await getClient().assistant.threads.setStatus({
275
- channel_id: normalizedChannelId,
276
- thread_ts: threadTs,
277
- status: text ? SLACK_ASSISTANT_ACTIVE_STATUS : "",
278
- ...nextLoadingMessages ? { loading_messages: nextLoadingMessages } : void 0
279
- });
280
- } catch (error) {
281
- logAssistantStatusFailure({
282
- status: text,
283
- error,
284
- channelId,
285
- normalizedChannelId,
286
- threadTs
287
- });
288
- }
289
- };
290
- }
291
- function getSlackAdapterRequestToken(adapter) {
292
- const token = adapter.requestContext?.getStore()?.token;
293
- if (typeof token !== "string") return;
294
- return token.trim() || void 0;
295
- }
296
- async function runWithBoundSlackToken(adapter, token, task) {
297
- if (!token) return await task();
298
- return await adapter.withBotToken(token, task);
299
- }
300
- function logAssistantStatusFailure(args) {
301
- logWarn("assistant.status.update.failed", {
302
- "app.slack.status_text": args.status || "(clear)",
303
- "app.slack.channel_id_raw": args.channelId,
304
- "app.slack.channel_id": args.normalizedChannelId,
305
- "app.slack.thread_ts": args.threadTs,
306
- "exception.message": args.error instanceof Error ? args.error.message : String(args.error)
307
- });
308
- }
309
- //#endregion
310
- //#region src/chat/slack/assistant-thread/status.ts
311
- /**
312
- * Create a Slack adapter-backed session for Slack's assistant loading state.
313
- *
314
- * The session accepts internal progress updates and leaves it to the sender to
315
- * map them onto Slack's fixed generic `status` field plus dynamic
316
- * `loading_messages`.
317
- */
318
- function createSlackAdapterAssistantStatusSession(args) {
319
- return createAssistantStatusScheduler({
320
- sendStatus: createSlackAdapterStatusSender({
321
- channelId: args.channelId,
322
- threadTs: args.threadTs,
323
- getSlackAdapter: args.getSlackAdapter
324
- }),
325
- loadingMessages: args.loadingMessages ?? botConfig.loadingMessages,
326
- now: args.now,
327
- setTimer: args.setTimer,
328
- clearTimer: args.clearTimer,
329
- random: args.random
330
- });
331
- }
332
- /**
333
- * Create a Web API-backed session for Slack's assistant loading state in
334
- * resume/callback flows that do not use the adapter thread object.
335
- */
336
- function createSlackWebApiAssistantStatusSession(args) {
337
- return createAssistantStatusScheduler({
338
- sendStatus: createSlackWebApiStatusSender({
339
- channelId: args.channelId,
340
- threadTs: args.threadTs,
341
- getSlackClient: args.getSlackClient
342
- }),
343
- loadingMessages: args.loadingMessages ?? botConfig.loadingMessages,
344
- now: args.now,
345
- setTimer: args.setTimer,
346
- clearTimer: args.clearTimer,
347
- random: args.random
348
- });
349
- }
350
- //#endregion
351
- //#region src/chat/slack/automation-card.ts
352
- function preview(text, length) {
353
- const compact = text.replace(/\s+/g, " ").trim();
354
- return compact.length > length ? `${compact.slice(0, length - 1)}…` : compact;
355
- }
356
- /** Show an Automation as a native Work Object, not an operation receipt. */
357
- function renderSlackAutomationCard(card) {
358
- const title = preview(card.title, 160);
359
- const trigger = preview(card.trigger, 500);
360
- const warning = card.warning ? preview(card.warning, 500) : null;
361
- const text = [
362
- card.url ? formatSlackLink(card.url, title) : escapeSlackMrkdwnText(title),
363
- escapeSlackMrkdwnText(trigger),
364
- warning ? escapeSlackMrkdwnText(warning) : null
365
- ].filter(Boolean).join("\n");
366
- if (!card.url) return {
367
- entity: null,
368
- text
369
- };
370
- return {
371
- text,
372
- entity: {
373
- entity_type: "slack#/entities/item",
374
- external_ref: {
375
- id: card.id,
376
- type: "automation"
377
- },
378
- url: card.url,
379
- entity_payload: {
380
- attributes: {
381
- title: { text: title },
382
- display_type: "Automation"
383
- },
384
- custom_fields: [{
385
- key: "trigger",
386
- label: "When",
387
- type: "string",
388
- value: trigger,
389
- long: true
390
- }, ...warning ? [{
391
- key: "warning",
392
- label: "Needs attention",
393
- type: "string",
394
- value: warning,
395
- long: true
396
- }] : []]
397
- }
398
- }
399
- };
400
- }
401
- //#endregion
402
- //#region src/chat/slack/object-card.ts
403
- /** Render verified annotation facts as a native Task or Item preview. */
404
- function renderSlackObjectCard(card, conversationId) {
405
- if (card.objectType === "automation" && card.plugin === "junior") return renderSlackAutomationCard({
406
- id: card.key,
407
- title: card.title,
408
- url: card.url,
409
- trigger: card.trigger ?? "",
410
- warning: card.warning ?? null
411
- });
412
- const title = card.title.slice(0, 160);
413
- const text = [
414
- card.url ? formatSlackLink(card.url, title) : escapeSlackMrkdwnText(title),
415
- escapeSlackMrkdwnText(card.label),
416
- card.status ? escapeSlackMrkdwnText(card.status) : null
417
- ].filter(Boolean).join("\n");
418
- if (!card.url) return {
419
- entity: null,
420
- text
421
- };
422
- const task = card.objectType === "task";
423
- return {
424
- text,
425
- entity: {
426
- entity_type: task ? "slack#/entities/task" : "slack#/entities/item",
427
- external_ref: {
428
- id: JSON.stringify([
429
- conversationId,
430
- card.plugin,
431
- card.key
432
- ]),
433
- type: "annotation"
434
- },
435
- url: card.url,
436
- entity_payload: {
437
- attributes: {
438
- title: { text: title },
439
- display_id: card.label,
440
- display_type: card.objectType === "code_change" ? "Pull request" : task ? "Issue" : "Item",
441
- product_name: card.plugin
442
- },
443
- fields: task && card.status ? { status: { value: card.status } } : void 0,
444
- custom_fields: !task && card.status ? [{
445
- key: "status",
446
- label: "Status",
447
- type: "string",
448
- value: card.status
449
- }] : []
450
- }
451
- }
452
- };
453
- }
454
- //#endregion
455
- //#region src/chat/slack/cards.ts
456
- /** Select the Slack object renderer for a built-in card. */
457
- function renderSlackCard(card, conversationId) {
458
- switch (card.kind) {
459
- case "object": return renderSlackObjectCard(card, conversationId);
460
- case "automation": return renderSlackAutomationCard(card);
461
- }
462
- }
463
- //#endregion
464
- //#region src/chat/slack/footer.ts
465
- /** Render compact reply attribution for the Slack footer. */
466
- function formatReplyAttribution(attribution) {
467
- return attribution.detail ? `${attribution.label} · ${attribution.detail}` : attribution.label;
468
- }
469
- /**
470
- * Build the compact conversation footer for visible Slack reply surfaces.
471
- *
472
- * Detailed turn metrics stay in the dashboard instead of Slack-visible copy.
473
- */
474
- function buildSlackReplyFooter(args) {
475
- const items = [];
476
- const conversationId = args.conversationId?.trim();
477
- if (conversationId) {
478
- const idItem = {
479
- label: "ID",
480
- value: conversationId
481
- };
482
- const conversationUrl = getPluginSlackConversationLink(conversationId)?.url ?? getDashboardConversationLink(conversationId) ?? buildSentryConversationUrl(conversationId);
483
- if (conversationUrl) idItem.url = conversationUrl;
484
- items.push(idItem);
485
- }
486
- return items.length > 0 || args.replyAttribution ? {
487
- ...args.replyAttribution ? { attribution: args.replyAttribution } : void 0,
488
- items
489
- } : void 0;
490
- }
491
- /** Build Slack blocks for a reply chunk using the Slack-flavored markdown block for the body. */
492
- function buildSlackReplyBlocks(text, footer) {
493
- const blocks = text.trim() ? [{
494
- type: "markdown",
495
- text
496
- }] : [];
497
- if (footer && (footer.attribution || footer.items.length > 0)) {
498
- const attributionElements = footer.attribution ? [{
499
- type: "plain_text",
500
- text: formatReplyAttribution(footer.attribution)
501
- }] : [];
502
- blocks.push({
503
- type: "context",
504
- elements: [...attributionElements, ...footer.items.map((item) => ({
505
- type: "mrkdwn",
506
- text: item.url ? `*${escapeSlackMrkdwnText(item.label)}:* ${formatSlackLink(item.url, item.value)}` : `*${escapeSlackMrkdwnText(item.label)}:* ${escapeSlackMrkdwnText(item.value)}`
507
- }))]
508
- });
509
- }
510
- return blocks.length ? blocks : void 0;
511
- }
512
- //#endregion
513
- //#region src/chat/slack/output.ts
514
- const MAX_INLINE_CHARS = 2200;
515
- const MAX_INLINE_LINES = 45;
516
- const CONTINUED_MARKER = "\n\n[Continued below]";
517
- function countSlackLines(text) {
518
- if (!text) return 0;
519
- return text.split("\n").length;
520
- }
521
- function fitsInlineBudget(text, maxChars = MAX_INLINE_CHARS, maxLines = MAX_INLINE_LINES) {
522
- return text.length <= maxChars && countSlackLines(text) <= maxLines;
523
- }
524
- function findSplitIndex(text, maxChars) {
525
- if (text.length <= maxChars) return text.length;
526
- const bounded = text.slice(0, maxChars);
527
- const newlineIndex = bounded.lastIndexOf("\n");
528
- if (newlineIndex > 0) return newlineIndex;
529
- const spaceIndex = bounded.lastIndexOf(" ");
530
- if (spaceIndex > 0) return spaceIndex;
531
- return maxChars;
532
- }
533
- function splitByLineBudget(text, maxLines) {
534
- if (maxLines <= 0) return "";
535
- const lines = text.split("\n");
536
- if (lines.length <= maxLines) return text;
537
- return lines.slice(0, maxLines).join("\n");
538
- }
539
- function reserveInlineBudgetForSuffix(suffix, maxChars = MAX_INLINE_CHARS, maxLines = MAX_INLINE_LINES) {
540
- return {
541
- maxChars: Math.max(1, maxChars - suffix.length),
542
- maxLines: Math.max(1, maxLines - Math.max(0, countSlackLines(suffix) - 1))
543
- };
544
- }
545
- function forceSplitBudget(text, budget) {
546
- const lineCount = countSlackLines(text);
547
- return {
548
- maxChars: text.length <= budget.maxChars ? Math.max(1, text.length - 1) : budget.maxChars,
549
- maxLines: lineCount <= budget.maxLines ? Math.max(1, lineCount - 1) : budget.maxLines
550
- };
551
- }
552
- function getFenceContinuation(text) {
553
- let open;
554
- for (const line of text.split("\n")) {
555
- const trimmed = line.trimStart();
556
- const openerMatch = trimmed.match(/^(`{3,}|~{3,})(.*)$/);
557
- if (!openerMatch) continue;
558
- if (!open) {
559
- open = {
560
- fence: openerMatch[1],
561
- openerLine: trimmed
562
- };
563
- continue;
564
- }
565
- if (new RegExp(`^${open.fence}\\s*$`).test(trimmed)) open = void 0;
566
- }
567
- if (!open) return null;
568
- return {
569
- closeSuffix: text.endsWith("\n") ? open.fence : `\n${open.fence}`,
570
- reopenPrefix: `${open.openerLine}\n`
571
- };
572
- }
573
- function appendSlackSuffix(text, marker) {
574
- return `${text}${getFenceContinuation(text)?.closeSuffix ?? ""}${marker}`;
575
- }
576
- function stripTrailingContinuationMarker(text) {
577
- return text.endsWith(CONTINUED_MARKER) ? text.slice(0, -19) : text;
578
- }
579
- function takeSlackContinuationChunk(text, budget) {
580
- let { prefix, rest } = takeSlackInlinePrefix(text, budget);
581
- if (!rest) ({prefix, rest} = takeSlackInlinePrefix(text, forceSplitBudget(text, budget)));
582
- let carryover = rest ? getFenceContinuation(prefix) : null;
583
- if (!carryover) return {
584
- prefix,
585
- rest
586
- };
587
- const carryoverBudget = reserveInlineBudgetForSuffix(`${carryover.closeSuffix}${CONTINUED_MARKER}`);
588
- ({prefix, rest} = takeSlackInlinePrefix(text, carryoverBudget));
589
- if (!rest) ({prefix, rest} = takeSlackInlinePrefix(text, forceSplitBudget(text, carryoverBudget)));
590
- carryover = rest ? getFenceContinuation(prefix) : null;
591
- if (!carryover) return {
592
- prefix,
593
- rest
594
- };
595
- return {
596
- prefix,
597
- rest: `${carryover.reopenPrefix}${rest}`
598
- };
599
- }
600
- /** Return the first continuation-safe chunk plus any remaining text. */
601
- function takeSlackContinuationPrefix(text, options) {
602
- const budget = {
603
- maxChars: options?.maxChars ?? getSlackContinuationBudget().maxChars,
604
- maxLines: options?.maxLines ?? getSlackContinuationBudget().maxLines
605
- };
606
- const { prefix, rest } = (() => {
607
- if (options?.forceSplit) return takeSlackContinuationChunk(text, budget);
608
- const initial = takeSlackInlinePrefix(text, budget);
609
- return initial.rest ? takeSlackContinuationChunk(text, budget) : initial;
610
- })();
611
- return {
612
- prefix,
613
- renderedPrefix: rest ? appendSlackSuffix(prefix, CONTINUED_MARKER) : prefix,
614
- rest
615
- };
616
- }
617
- /**
618
- * Take the largest Slack-safe inline prefix from `text` under the configured
619
- * character and line budgets. Returns the consumed prefix plus remaining text.
620
- */
621
- function takeSlackInlinePrefix(text, options) {
622
- const maxChars = options?.maxChars ?? MAX_INLINE_CHARS;
623
- const maxLines = options?.maxLines ?? MAX_INLINE_LINES;
624
- const normalized = text.replace(/\r\n?/g, "\n");
625
- if (!normalized) return {
626
- prefix: "",
627
- rest: ""
628
- };
629
- if (fitsInlineBudget(normalized, maxChars, maxLines)) return {
630
- prefix: normalized,
631
- rest: ""
632
- };
633
- const lineBounded = splitByLineBudget(normalized, maxLines);
634
- const cutIndex = findSplitIndex(lineBounded, maxChars);
635
- const prefix = lineBounded.slice(0, cutIndex).trimEnd();
636
- if (prefix) return {
637
- prefix,
638
- rest: normalized.slice(prefix.length).trimStart()
639
- };
640
- const hardPrefix = normalized.slice(0, Math.max(1, maxChars)).trimEnd();
641
- return {
642
- prefix: hardPrefix || normalized.slice(0, Math.max(1, maxChars)),
643
- rest: normalized.slice(hardPrefix.length || Math.max(1, maxChars)).trimStart()
644
- };
645
- }
646
- /**
647
- * Split a normalized Slack reply into multiple inline-safe thread messages.
648
- *
649
- * Replies split across more than two messages receive explicit continuation
650
- * markers on non-final chunks. When
651
- * `interrupted` is true, the final chunk receives an interruption marker.
652
- */
653
- function splitSlackReplyText(text, options) {
654
- const normalized = normalizeSlackReplyMarkdown(text);
655
- if (!normalized) return [];
656
- const chunks = [];
657
- const continuationBudget = reserveInlineBudgetForSuffix(CONTINUED_MARKER);
658
- let remaining = normalized;
659
- while (remaining) {
660
- if (options?.interrupted ? fitsInlineBudget(appendSlackSuffix(remaining, getInterruptionMarker())) : fitsInlineBudget(remaining)) {
661
- chunks.push(options?.interrupted ? appendSlackSuffix(remaining, getInterruptionMarker()) : remaining);
662
- break;
663
- }
664
- const { renderedPrefix, rest } = takeSlackContinuationPrefix(remaining, {
665
- ...continuationBudget,
666
- forceSplit: true
667
- });
668
- chunks.push(renderedPrefix);
669
- remaining = rest;
670
- }
671
- if (chunks.length === 2) chunks[0] = stripTrailingContinuationMarker(chunks[0] ?? "");
672
- return chunks;
673
- }
674
- /**
675
- * Reserve enough inline budget for a continuation suffix on the current chunk.
676
- */
677
- function getSlackContinuationBudget() {
678
- return reserveInlineBudgetForSuffix(CONTINUED_MARKER);
679
- }
680
- /** Normalize text for Slack and wrap it as a PostableMessage. */
681
- function buildSlackOutputMessage(text) {
682
- const normalized = normalizeSlackReplyMarkdown(text);
683
- if (!normalized) throw new Error(`Slack output normalized to empty content: original_length=${text.length} parsed_length=${normalized.length}`);
684
- return { markdown: normalized };
685
- }
686
- //#endregion
687
- //#region src/chat/slack/reply.ts
688
- /**
689
- * Send one destination-visible Slack reply.
690
- *
691
- * Chunks oversized text, builds compact attribution and conversation footer
692
- * context, and posts through the shared Slack outbound boundary.
693
- */
694
- async function sendSlackReply(args) {
695
- const posts = splitSlackReplyText(args.text).map((text) => ({
696
- text,
697
- cards: []
698
- }));
699
- const cards = (args.cards ?? []).map((card) => renderSlackCard(card, args.conversationId));
700
- for (let index = 0; index < cards.length; index += 5) {
701
- const group = cards.slice(index, index + 5);
702
- const lastPost = posts.at(-1);
703
- if (index === 0 && lastPost) lastPost.cards = group;
704
- else posts.push({
705
- text: "",
706
- cards: group
707
- });
708
- }
709
- const footer = buildSlackReplyFooter({
710
- conversationId: args.conversationId,
711
- replyAttribution: args.replyAttribution
712
- });
713
- const messageTs = [];
714
- let threadTs = args.threadTs;
715
- for (const [index, post] of posts.entries()) {
716
- const { text, cards } = post;
717
- const isFinalChunk = index === posts.length - 1;
718
- const blocks = buildSlackReplyBlocks(text, void 0) ?? [];
719
- for (const card of cards) if (!card.entity) blocks.push({
720
- type: "section",
721
- text: {
722
- type: "mrkdwn",
723
- text: card.text
724
- }
725
- });
726
- if (isFinalChunk || !text.trim()) blocks.push(...buildSlackReplyBlocks("", footer) ?? []);
727
- const entities = cards.flatMap((card) => card.entity ? [card.entity] : []);
728
- const accessibleText = [text, ...cards.map((card) => card.text)].filter(Boolean).join("\n\n");
729
- const fallbackText = isFinalChunk && args.replyAttribution ? `${accessibleText}\n\n${escapeSlackMrkdwnText(formatReplyAttribution(args.replyAttribution))}` : accessibleText;
730
- const response = await postSlackMessage({
731
- channelId: args.channelId,
732
- threadTs,
733
- text: fallbackText,
734
- ...blocks.length ? { blocks } : void 0,
735
- ...entities.length ? { entities } : void 0
736
- });
737
- if (response.ts) {
738
- messageTs.push(response.ts);
739
- threadTs ??= response.ts;
740
- }
741
- }
742
- return messageTs;
743
- }
744
- //#endregion
745
- //#region src/chat/slack/errors.ts
746
- /** Extract Slack's stable API error code from Web API errors. */
747
- function getSlackApiErrorCode(error) {
748
- if (!error || typeof error !== "object") return;
749
- const candidate = error;
750
- if (typeof candidate.data?.error === "string" && candidate.data.error.trim().length > 0) return candidate.data.error;
751
- if (typeof candidate.code === "string" && candidate.code.trim().length > 0) return candidate.code;
752
- }
753
- /** Convert Slack Web API error details into the repository's trace attributes. */
754
- function getSlackErrorObservabilityAttributes(error) {
755
- if (!error || typeof error !== "object") return {};
756
- const candidate = error;
757
- const attributes = {};
758
- if (typeof candidate.code === "string" && candidate.code.trim().length > 0) attributes["app.slack.error_code"] = candidate.code;
759
- if (typeof candidate.data?.error === "string" && candidate.data.error.trim().length > 0) attributes["app.slack.api_error"] = candidate.data.error;
760
- const requestId = getHeaderString(candidate.headers, "x-slack-req-id");
761
- if (requestId) attributes["app.slack.request_id"] = requestId;
762
- if (typeof candidate.statusCode === "number" && Number.isFinite(candidate.statusCode)) attributes["http.response.status_code"] = candidate.statusCode;
763
- return attributes;
764
- }
765
- const TRANSIENT_SLACK_API_ERRORS = /* @__PURE__ */ new Set([
766
- "fatal_error",
767
- "internal_error",
768
- "rate_limited",
769
- "ratelimited",
770
- "request_timeout",
771
- "service_unavailable"
772
- ]);
773
- /** Recover transient or ambiguous Slack failures, but stop on explicit rejection. */
774
- function isRetryableSlackPostError(error) {
775
- if (!error || typeof error !== "object") return true;
776
- const candidate = error;
777
- if (typeof candidate.statusCode === "number") {
778
- if (candidate.statusCode === 429 || candidate.statusCode >= 500) return true;
779
- if (candidate.statusCode >= 400) return false;
780
- }
781
- const apiError = error instanceof SlackActionError ? error.apiError : typeof candidate.data?.error === "string" ? candidate.data.error : void 0;
782
- if (apiError) return TRANSIENT_SLACK_API_ERRORS.has(apiError);
783
- if (error instanceof SlackActionError) return error.code === "rate_limited" || error.code === "internal_error";
784
- return true;
785
- }
786
- /** Report whether Slack rejected assistant title updates for stable auth reasons. */
787
- function isSlackTitlePermissionError(error) {
788
- const code = getSlackApiErrorCode(error);
789
- return code === "no_permission" || code === "missing_scope" || code === "not_allowed_token_type";
790
- }
791
- //#endregion
792
- //#region src/chat/runtime/thread-context.ts
793
- function escapeRegExp(value) {
794
- return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
795
- }
796
- function readOptionalStringField(value, key) {
797
- if (!(key in value)) return;
798
- return toOptionalString(value[key]);
799
- }
800
- function stripLeadingBotMention(text, options = {}) {
801
- if (!text.trim()) return text;
802
- let next = text;
803
- if (options.stripLeadingSlackMentionToken) {
804
- if (options.botUserId) {
805
- const botUserId = escapeRegExp(options.botUserId);
806
- const mentionByBotUserIdRe = new RegExp(`^\\s*(?:<@${botUserId}(?:\\|[^>]+)?>|@${botUserId})[\\s,:-]*`, "i");
807
- next = next.replace(mentionByBotUserIdRe, "").trim();
808
- } else next = next.replace(/^\s*<@[^>]+>[\s,:-]*/, "").trim();
809
- }
810
- const mentionByNameRe = new RegExp(`^\\s*@${escapeRegExp(botConfig.userName)}\\b[\\s,:-]*`, "i");
811
- next = next.replace(mentionByNameRe, "").trim();
812
- const mentionByLabeledEntityRe = new RegExp(`^\\s*<@[^>|]+\\|${escapeRegExp(botConfig.userName)}>[\\s,:-]*`, "i");
813
- next = next.replace(mentionByLabeledEntityRe, "").trim();
814
- return next;
815
- }
816
- function getThreadId(thread, _message) {
817
- return toOptionalString(thread.id);
818
- }
819
- function getRunId(thread, message) {
820
- return readOptionalStringField(thread, "runId") ?? readOptionalStringField(message, "runId");
821
- }
822
- function getChannelId(thread, message) {
823
- return resolveSlackChannelIdFromThreadId(toOptionalString(thread.id)) ?? normalizeSlackConversationId(toOptionalString(thread.channelId)) ?? resolveSlackChannelIdFromMessage(message);
824
- }
825
- function getThreadTs(threadId) {
826
- return parseSlackThreadId(threadId)?.threadTs;
827
- }
828
- /**
829
- * Resolve Slack assistant-thread API context for the current turn.
830
- *
831
- * Slack assistant-thread methods must use the live inbound thread context
832
- * Slack provided on the current message. Slack's assistant utilities build
833
- * `setStatus`/`setTitle` from `message.channel` plus `message.thread_ts ?? message.ts`
834
- * for non-DM message events, while `message.im` still requires an explicit
835
- * `thread_ts`. Do not synthesize assistant-thread roots from persisted state.
836
- */
837
- function getAssistantThreadContext(message) {
838
- const rawContext = readSlackRawMessageContext(message);
839
- const channelId = rawContext?.channelId;
840
- if (channelId) {
841
- const threadTs = isDmChannel(channelId) ? rawContext.threadTs : rawContext.threadTs ?? rawContext.messageTs;
842
- if (threadTs) return {
843
- channelId,
844
- threadTs
845
- };
846
- }
847
- const parsedThreadId = parseSlackThreadId(readOptionalStringField(message, "threadId"));
848
- if (!parsedThreadId || isDmChannel(parsedThreadId.channelId)) return;
849
- return parsedThreadId;
850
- }
851
- /** Resolve the native Slack timestamp for a message that can target Slack APIs. */
852
- function getMessageTs(message) {
853
- const directTs = readOptionalStringField(message, "ts");
854
- const parsedDirectTs = parseSlackMessageTs(directTs);
855
- if (parsedDirectTs) return parsedDirectTs;
856
- const rawContext = readSlackRawMessageContext(message);
857
- if (!rawContext) return;
858
- const candidates = [rawContext.messageTs, rawContext.nestedMessageTs];
859
- for (const candidate of candidates) {
860
- const ts = parseSlackMessageTs(candidate);
861
- if (ts) return ts;
862
- }
863
- }
864
- //#endregion
865
- //#region src/chat/providers/slack/processing-reaction.ts
866
- const noProcessingReaction = {
867
- complete: async () => void 0,
868
- keep: () => void 0,
869
- stop: async () => void 0
870
- };
871
- function isProcessingReactionEmoji(value) {
872
- return typeof value === "string" && normalizeSlackEmojiName(value) === getChatConfig().slack.processingReactionEmoji;
873
- }
874
- /** Return true when a Slack reaction tool invocation keeps the processing reaction. */
875
- function shouldKeepProcessingReactionForToolInvocation(input) {
876
- return input.toolName === "addReaction" && isProcessingReactionEmoji(input.params.emoji);
877
- }
878
- /** Start Junior's automatic Slack processing reaction for one inbound message. */
879
- async function startProcessingReaction(args) {
880
- if (args.message.author.isMe) return noProcessingReaction;
881
- const channelId = getChannelId(args.thread, args.message);
882
- const messageTs = getMessageTs(args.message);
883
- if (!channelId || !messageTs) return noProcessingReaction;
884
- return startProcessingReactionForMessage({
885
- channelId,
886
- timestamp: messageTs
887
- });
888
- }
889
- /** Start Junior's automatic Slack processing reaction for a known Slack message. */
890
- async function startProcessingReactionForMessage(args) {
891
- try {
892
- await addReactionToMessage({
893
- channelId: args.channelId,
894
- timestamp: args.timestamp,
895
- emoji: getChatConfig().slack.processingReactionEmoji
896
- });
897
- } catch (error) {
898
- logException(error, "slack.processing.reaction_add.failed", {
899
- "app.slack.action": "reactions.add",
900
- "messaging.message.id": args.timestamp,
901
- ...getSlackErrorObservabilityAttributes(error)
902
- });
903
- return noProcessingReaction;
904
- }
905
- let shouldRemove = true;
906
- const removeProcessingReaction = async () => {
907
- if (!shouldRemove) return false;
908
- try {
909
- await removeReactionFromMessage({
910
- channelId: args.channelId,
911
- timestamp: args.timestamp,
912
- emoji: getChatConfig().slack.processingReactionEmoji
913
- });
914
- return true;
915
- } catch (error) {
916
- logException(error, "slack.processing.reaction_remove.failed", {
917
- "app.slack.action": "reactions.remove",
918
- "messaging.message.id": args.timestamp,
919
- ...getSlackErrorObservabilityAttributes(error)
920
- });
921
- return false;
922
- }
923
- };
924
- return {
925
- complete: async () => {
926
- const shouldAddCompleted = shouldRemove;
927
- await removeProcessingReaction();
928
- if (!shouldAddCompleted) return;
929
- try {
930
- await addReactionToMessage({
931
- channelId: args.channelId,
932
- timestamp: args.timestamp,
933
- emoji: getChatConfig().slack.completedReactionEmoji
934
- });
935
- } catch (error) {
936
- logException(error, "slack.processing.reaction_complete.failed", {
937
- "app.slack.action": "reactions.add",
938
- "messaging.message.id": args.timestamp,
939
- ...getSlackErrorObservabilityAttributes(error)
940
- });
941
- }
942
- },
943
- keep: () => {
944
- shouldRemove = false;
945
- },
946
- stop: async () => {
947
- await removeProcessingReaction();
948
- }
949
- };
950
- }
951
- //#endregion
952
- //#region src/chat/services/auth-pause-response.ts
953
- const MAX_AUTH_REQUEST_LENGTH = 200;
954
- function formatAuthRequest(requestText) {
955
- const normalized = requestText.replace(/\s+/g, " ").trim();
956
- if (!normalized) return;
957
- return (normalized.length > MAX_AUTH_REQUEST_LENGTH ? `${normalized.slice(0, 199).trimEnd()}…` : normalized).replaceAll("&", "&amp;").replaceAll("<", "&lt;").replaceAll(">", "&gt;");
958
- }
959
- /** Build the visible Slack thread note for an auth-paused turn. */
960
- function buildAuthPauseResponse(slackUserId, providerDisplayName, requestText) {
961
- const mention = slackUserId ? `<@${slackUserId}> ` : "";
962
- const request = requestText ? formatAuthRequest(requestText) : void 0;
963
- if (!request) return `${mention}I'll need you to authorize ${providerDisplayName}. Use the link in the message above in this thread.`;
964
- return `${mention}I need access to ${providerDisplayName} to continue.\n\n*Why:* ${request}\n\nUse the link in the message above in this thread.`;
965
- }
966
- //#endregion
967
- //#region src/chat/providers/slack/resume.ts
968
- /**
969
- * Resume a Slack Turn from saved Run state.
970
- *
971
- * Resumed Turns load their saved Run while holding the Slack thread lock.
972
- * Status notices are best effort. Completed assistant replies and
973
- * authorization pause notices use `sendSlackReply` so the footer stays
974
- * consistent.
975
- */
976
- function resolveReplyTimeoutMs() {
977
- const raw = process.env.EVAL_AGENT_REPLY_TIMEOUT_MS?.trim();
978
- if (!raw) return;
979
- const parsed = Number.parseInt(raw, 10);
980
- return Number.isFinite(parsed) && parsed > 0 ? parsed : void 0;
981
- }
982
- async function postSlackMessageBestEffort(channelId, threadTs, text, conversationId, replyAttribution) {
983
- try {
984
- if (conversationId) {
985
- await sendSlackReply({
986
- channelId,
987
- conversationId,
988
- replyAttribution,
989
- text,
990
- threadTs
991
- });
992
- return;
993
- }
994
- await postSlackMessage({
995
- channelId,
996
- threadTs,
997
- text
998
- });
999
- } catch {}
1000
- }
1001
- /** Create a read-only configuration service from persisted values. */
1002
- function createReadOnlyConfigService(rawValues) {
1003
- const values = Object.fromEntries(Object.entries(rawValues).map(([key, value]) => [key, configValueSchema.parse(value)]));
1004
- const entries = Object.entries(values).map(([key, value]) => ({
1005
- key,
1006
- value,
1007
- scope: "location",
1008
- updatedAt: (/* @__PURE__ */ new Date()).toISOString()
1009
- }));
1010
- return {
1011
- get: async (key) => entries.find((entry) => entry.key === key),
1012
- set: async () => {
1013
- throw new Error("Configuration is read-only for a resumed Run");
1014
- },
1015
- unset: async () => false,
1016
- list: async ({ prefix } = {}) => entries.filter((entry) => !prefix || entry.key.startsWith(prefix)),
1017
- resolve: async (key) => values[key],
1018
- resolveValues: async ({ keys, prefix } = {}) => {
1019
- const filtered = {};
1020
- for (const [key, value] of Object.entries(values)) {
1021
- if (prefix && !key.startsWith(prefix)) continue;
1022
- if (keys && !keys.includes(key)) continue;
1023
- filtered[key] = value;
1024
- }
1025
- return filtered;
1026
- }
1027
- };
1028
- }
1029
- /** Error raised when another worker is already resuming the Slack Turn. */
1030
- var ResumeTurnBusyError = class extends Error {
1031
- constructor(lockKey) {
1032
- super(`Another worker is already resuming Slack Turn "${lockKey}"`);
1033
- this.name = "ResumeTurnBusyError";
1034
- }
1035
- };
1036
- function getDefaultLockKey(channelId, threadTs) {
1037
- return threadTs ? `slack:${channelId}:${threadTs}` : `slack:${channelId}`;
1038
- }
1039
- function getResumeLogContext(args, lockKey) {
1040
- const actor = args.run?.actor;
1041
- return {
1042
- conversationId: args.conversationId,
1043
- messageConversationId: lockKey,
1044
- userId: isUserActor(actor) ? actor.userId : void 0,
1045
- userName: isUserActor(actor) ? actor.userName : void 0,
1046
- destinationName: args.channelId,
1047
- assistantUserName: botConfig.userName,
1048
- modelId: defaultModelId(botConfig)
1049
- };
1050
- }
1051
- async function postResumeFailureReply(args) {
1052
- await postSlackMessage({
1053
- channelId: args.channelId,
1054
- threadTs: args.threadTs,
1055
- text: buildTurnErrorResponse(args.error, args.eventId)
1056
- });
1057
- }
1058
- async function handleResumeFailure(args) {
1059
- const turnLifecycle = getTurnLifecycle();
1060
- const capturedEventId = logException(args.error, args.eventName);
1061
- const eventId = requireTurnFailureEventId(capturedEventId, args.eventName);
1062
- let failureStatePersistError;
1063
- try {
1064
- await args.resume.onFailure?.(args.error);
1065
- } catch (persistError) {
1066
- const persistEventId = logException(persistError, "slack.resume.failure_state_persist.failed", { "app.error.original_event_id": eventId });
1067
- try {
1068
- await turnLifecycle.fail({
1069
- conversationId: args.resume.conversationId,
1070
- turnId: args.resume.turnId,
1071
- createdAtMs: Date.now(),
1072
- failureCode: "persistence_failed",
1073
- ...persistEventId ? { eventId: persistEventId } : void 0
1074
- });
1075
- } catch (lifecycleError) {
1076
- logException(lifecycleError, "slack.resume.failure_lifecycle_persist.failed", { "app.error.original_event_id": eventId });
1077
- }
1078
- failureStatePersistError = persistError;
1079
- }
1080
- try {
1081
- await postResumeFailureReply({
1082
- channelId: args.resume.channelId,
1083
- threadTs: args.resume.threadTs,
1084
- eventId,
1085
- error: args.error
1086
- });
1087
- } catch (deliveryError) {
1088
- const deliveryEventId = logException(deliveryError, "slack.resume.failure_delivery.failed", { "app.error.original_event_id": eventId });
1089
- try {
1090
- await turnLifecycle.fail({
1091
- conversationId: args.resume.conversationId,
1092
- turnId: args.resume.turnId,
1093
- createdAtMs: Date.now(),
1094
- failureCode: "delivery_failed",
1095
- ...deliveryEventId ? { eventId: deliveryEventId } : void 0
1096
- });
1097
- } catch (lifecycleError) {
1098
- logException(lifecycleError, "slack.resume.failure_lifecycle_persist.failed", { "app.error.original_event_id": eventId });
1099
- }
1100
- throw deliveryError;
1101
- }
1102
- if (failureStatePersistError) throw failureStatePersistError;
1103
- await turnLifecycle.fail({
1104
- conversationId: args.resume.conversationId,
1105
- turnId: args.resume.turnId,
1106
- createdAtMs: Date.now(),
1107
- failureCode: args.failureCode,
1108
- eventId
1109
- });
1110
- }
1111
- function buildResumedRun(args, statusSession, delivery) {
1112
- const savedRun = args.run;
1113
- if (!savedRun) throw new TypeError("Slack resume requires a saved Run");
1114
- if (!savedRun.source) throw new TypeError("Slack resume requires a Run Source");
1115
- const source = savedRun.source;
1116
- if (savedRun.destination.platform !== "slack") throw new TypeError("Slack resume requires a Slack Destination");
1117
- const requestDeadline = getTurnRequestDeadline();
1118
- const threadId = args.lockKey ?? getDefaultLockKey(args.channelId, args.threadTs);
1119
- const persistedLocationConfiguration = savedRun.environment?.locationConfiguration ?? (savedRun.environment?.configuration ? createReadOnlyConfigService(savedRun.environment.configuration) : void 0);
1120
- const priorOnEvent = savedRun.onEvent;
1121
- return {
1122
- ...savedRun,
1123
- conversationId: args.conversationId,
1124
- turnId: args.turnId,
1125
- instruction: {
1126
- ...savedRun.instruction ?? { text: args.messageText },
1127
- text: args.messageText
1128
- },
1129
- source: source.kind === "slack" ? {
1130
- ...source,
1131
- channelId: args.channelId,
1132
- ...args.threadTs ? { threadTs: args.threadTs } : void 0
1133
- } : source,
1134
- deadlineAtMs: savedRun.deadlineAtMs ?? requestDeadline?.deadlineAtMs,
1135
- environment: {
1136
- ...savedRun.environment,
1137
- locationConfiguration: persistedLocationConfiguration
1138
- },
1139
- onEvent: async (event) => {
1140
- if (event.type === "status") statusSession.update({ text: event.text });
1141
- await priorOnEvent?.(event);
1142
- },
1143
- ...savedRun.dispatch?.outcomes?.length === 0 ? void 0 : { delivery },
1144
- durability: {
1145
- ...savedRun.durability,
1146
- onSandboxRefChanged: async (sandboxRef) => {
1147
- await persistThreadStateById(threadId, { sandboxRef });
1148
- await savedRun.durability?.onSandboxRefChanged?.(sandboxRef);
1149
- }
1150
- }
1151
- };
1152
- }
1153
- /**
1154
- * Resume a paused Slack Turn.
1155
- *
1156
- * Queue continuations pass `ownsConversationLease` and skip the second lock
1157
- * because the worker lease is already held. Authorization callbacks and other
1158
- * independent resumes still take the thread lock. A started resume owns its
1159
- * completion work.
1160
- * Returns false only when `beforeStart` proves the resume is stale before
1161
- * generation begins.
1162
- */
1163
- async function resumeSlackTurn(args) {
1164
- const lockKey = args.lockKey ?? getDefaultLockKey(args.channelId, args.threadTs);
1165
- return withLogContext(getResumeLogContext(args, lockKey), () => resumeSlackTurnInContext(args));
1166
- }
1167
- async function resumeSlackTurnInContext(args) {
1168
- const stateAdapter = getStateAdapter();
1169
- await stateAdapter.connect();
1170
- const lockKey = args.lockKey ?? getDefaultLockKey(args.channelId, args.threadTs);
1171
- const lock = args.ownsConversationLease ? void 0 : await acquireActiveLock(stateAdapter, lockKey);
1172
- if (!args.ownsConversationLease && !lock) throw new ResumeTurnBusyError(lockKey);
1173
- const status = createSlackWebApiAssistantStatusSession({
1174
- channelId: args.channelId,
1175
- threadTs: args.threadTs
1176
- });
1177
- let processingReaction;
1178
- let deferredAuthInfo;
1179
- let deferredPauseHandler;
1180
- let deferredFailureHandler;
1181
- let runResultHandled = false;
1182
- let assistantMessageDelivered = false;
1183
- let shouldScheduleCompletedPluginTasks = false;
1184
- let postDeliveryCommitError;
1185
- const turnLifecycle = getTurnLifecycle();
1186
- let failureCode = "agent_run_failed";
1187
- let runArgs = args;
1188
- try {
1189
- const preparedArgs = await args.beforeStart?.();
1190
- if (preparedArgs === false) return false;
1191
- if (preparedArgs) runArgs = {
1192
- ...args,
1193
- ...preparedArgs
1194
- };
1195
- setTags(getResumeLogContext(runArgs, lockKey));
1196
- const savedRun = runArgs.run;
1197
- if (!savedRun) throw new Error("Resumed Turn requires a saved Run");
1198
- const credentialContext = savedRun.credentialContext;
1199
- if (!credentialContext) throw new Error("Resumed Run requires a credential context");
1200
- const routingActor = savedRun.actor;
1201
- let resumeActor;
1202
- if ("type" in credentialContext.actor) {
1203
- if (!isUserActor(routingActor) || credentialContext.actor.userId !== routingActor.userId) throw new Error("Resumed Run credential actor must match its Actor");
1204
- resumeActor = routingActor;
1205
- } else {
1206
- if (routingActor && (routingActor.platform !== "system" || routingActor.name !== credentialContext.actor.name)) throw new Error("Resumed Run system credential actor must match its Actor");
1207
- resumeActor = credentialContext.actor;
1208
- }
1209
- if (runArgs.messageTs) processingReaction = await startProcessingReactionForMessage({
1210
- channelId: runArgs.channelId,
1211
- timestamp: runArgs.messageTs
1212
- });
1213
- if (runArgs.initialText) await postSlackMessageBestEffort(runArgs.channelId, runArgs.threadTs, runArgs.initialText);
1214
- status.update(runArgs.initialStatus);
1215
- const conversationId = runArgs.conversationId;
1216
- const visibility = (await getConversationStore().get({ conversationId }))?.visibility;
1217
- const visibleConversationId = lockKey;
1218
- const sessionId = runArgs.turnId;
1219
- let deliveryConversation;
1220
- let acceptedDeliveryId;
1221
- /** Load the resumed conversation once for ordered delivery recording. */
1222
- const getDeliveryConversation = async () => {
1223
- if (deliveryConversation) {
1224
- const userMessageId = getTurnUserMessage(deliveryConversation, sessionId)?.id;
1225
- return {
1226
- conversation: deliveryConversation,
1227
- sessionId,
1228
- ...userMessageId ? { userMessageId } : void 0
1229
- };
1230
- }
1231
- const persistedState = await getPersistedThreadState(visibleConversationId);
1232
- const conversation = coerceThreadConversationState(persistedState);
1233
- await hydrateConversationMessages({
1234
- conversation,
1235
- conversationId
1236
- });
1237
- deliveryConversation = conversation;
1238
- const userMessageId = getTurnUserMessage(conversation, sessionId)?.id;
1239
- return {
1240
- conversation,
1241
- sessionId,
1242
- ...userMessageId ? { userMessageId } : void 0
1243
- };
1244
- };
1245
- /** Post and record one completed assistant message for the resumed turn. */
1246
- const deliverAssistantMessage = async (reply) => {
1247
- const message = typeof reply === "string" ? void 0 : reply;
1248
- const text = typeof reply === "string" ? reply : getAssistantReplyText(reply);
1249
- if (!text?.trim()) return;
1250
- failureCode = "delivery_failed";
1251
- const cards = await loadPendingMessageCards(conversationId);
1252
- const deliveryState = await getDeliveryConversation();
1253
- let slackMessageTs = [];
1254
- try {
1255
- const outcomes = runArgs.run?.dispatch?.outcomes;
1256
- if (outcomes) for (const outcome of outcomes) slackMessageTs.push(...await sendSlackReply({
1257
- cards,
1258
- channelId: outcome.destination.channelId,
1259
- conversationId: runArgs.conversationId,
1260
- replyAttribution: runArgs.run?.dispatch?.replyAttribution,
1261
- text,
1262
- threadTs: outcome.destination.threadTs
1263
- }));
1264
- else slackMessageTs = await sendSlackReply({
1265
- cards,
1266
- channelId: runArgs.channelId,
1267
- conversationId: runArgs.conversationId,
1268
- replyAttribution: runArgs.run?.dispatch?.replyAttribution,
1269
- text,
1270
- threadTs: runArgs.threadTs
1271
- });
1272
- } catch (error) {
1273
- if (isRetryableSlackPostError(error)) throw new RetryableDeliveryError(error);
1274
- throw error;
1275
- }
1276
- const messageTs = slackMessageTs.at(-1);
1277
- assistantMessageDelivered = true;
1278
- acceptedDeliveryId = messageTs;
1279
- const recordedMessageId = recordDeliveredAssistantMessage({
1280
- cards,
1281
- conversation: deliveryState.conversation,
1282
- sessionId: deliveryState.sessionId,
1283
- source: "slack",
1284
- text,
1285
- userMessageId: deliveryState.userMessageId
1286
- });
1287
- if (messageTs) markConversationMessage(deliveryState.conversation, recordedMessageId, { slackTs: messageTs });
1288
- try {
1289
- const destination = runArgs.run?.destination;
1290
- const providerConversationIds = runArgs.threadTs ? [runArgs.threadTs] : slackMessageTs;
1291
- await persistWithRetry(() => commitAcceptedReply({
1292
- ...message ? { agentMessage: message } : void 0,
1293
- conversation: deliveryState.conversation,
1294
- conversationMessageId: recordedMessageId,
1295
- conversationId,
1296
- ...runArgs.run?.dispatch && destination?.platform === "slack" && providerConversationIds.length > 0 ? { providerConversationBindings: providerConversationIds.map((providerConversationId) => ({
1297
- provider: "slack",
1298
- providerDestinationId: destination.channelId,
1299
- providerTenantId: destination.teamId,
1300
- providerConversationId
1301
- })) } : void 0
1302
- }));
1303
- } catch (error) {
1304
- logException(error, "slack.resume.assistant_message_post_delivery_persist.failed");
1305
- }
1306
- const dispatchId = savedRun.dispatch?.id;
1307
- if (messageTs && dispatchId) try {
1308
- await persistWithRetry(() => recordTurnSummary({
1309
- conversationId: runArgs.conversationId,
1310
- destination: savedRun.destination,
1311
- destinationVisibility: visibility,
1312
- dispatchId,
1313
- resultMessageId: messageTs,
1314
- turnId: runArgs.turnId,
1315
- sliceId: runArgs.sliceId ?? 1,
1316
- source: savedRun.source,
1317
- state: "running",
1318
- surface: savedRun.surface ?? "slack"
1319
- }));
1320
- } catch (error) {
1321
- logException(error, "agent.turn.delivery_receipt_persist.failed");
1322
- }
1323
- failureCode = "agent_run_failed";
1324
- };
1325
- const deliveryState = await getDeliveryConversation();
1326
- assistantMessageDelivered = turnHasReply(deliveryState.conversation, sessionId);
1327
- const run = buildResumedRun(runArgs, status, deliverAssistantMessage);
1328
- if (runArgs.inputMessageIds?.length) await turnLifecycle.start({
1329
- conversationId: runArgs.conversationId,
1330
- turnId: runArgs.turnId,
1331
- createdAtMs: Date.now(),
1332
- inputMessageIds: [...new Set(runArgs.inputMessageIds)],
1333
- surface: "slack"
1334
- });
1335
- const replyTimeoutMs = resolveReplyTimeoutMs();
1336
- const outcome = await runArgs.executeTurn(run, async (result) => {
1337
- const finalized = finalizeFailedTurnReplyWithEvent({
1338
- reply: result,
1339
- logException
1340
- });
1341
- const reply = finalized.reply;
1342
- const dispatchErrorMessage = run.dispatch && reply.diagnostics.outcome !== "success" ? reply.diagnostics.errorMessage ?? `Agent turn ended with ${reply.diagnostics.outcome}.` : void 0;
1343
- if (reply.diagnostics.outcome !== "success") await deliverAssistantMessage(reply.text);
1344
- runResultHandled = true;
1345
- await status.clear();
1346
- failureCode = "persistence_failed";
1347
- if (reply.piMessages?.length) await saveTurnCheckpoint({
1348
- mode: "completed",
1349
- conversationId: runArgs.conversationId,
1350
- turnId: runArgs.turnId,
1351
- messages: reply.piMessages,
1352
- durationMs: reply.diagnostics.durationMs,
1353
- usage: reply.diagnostics.usage,
1354
- destination: run.destination,
1355
- destinationVisibility: visibility,
1356
- dispatchId: run.dispatch?.id,
1357
- dispatchOutcome: reply.diagnostics.outcome === "success" ? "completed" : "failed",
1358
- ...dispatchErrorMessage ? { errorMessage: dispatchErrorMessage } : void 0,
1359
- ...acceptedDeliveryId ? { resultMessageId: acceptedDeliveryId } : void 0,
1360
- source: run.source,
1361
- actor: resumeActor,
1362
- surface: run.surface ?? "slack",
1363
- sliceId: runArgs.sliceId
1364
- });
1365
- else if (run.dispatch?.id) await recordTurnSummary({
1366
- conversationId: runArgs.conversationId,
1367
- destination: run.destination,
1368
- destinationVisibility: visibility,
1369
- dispatchId: run.dispatch?.id,
1370
- dispatchOutcome: reply.diagnostics.outcome === "success" ? "completed" : "failed",
1371
- ...acceptedDeliveryId ? { resultMessageId: acceptedDeliveryId } : void 0,
1372
- turnId: runArgs.turnId,
1373
- sliceId: runArgs.sliceId ?? 1,
1374
- source: run.source,
1375
- state: reply.diagnostics.outcome === "success" ? "completed" : "failed",
1376
- surface: run.surface ?? "slack"
1377
- });
1378
- await runArgs.commitResult?.(reply);
1379
- if (reply.diagnostics.outcome === "success") {
1380
- shouldScheduleCompletedPluginTasks = true;
1381
- return { outcome: assistantMessageDelivered ? "success" : "no_reply" };
1382
- }
1383
- return {
1384
- outcome: "failed",
1385
- failureCode: "model_execution_failed",
1386
- ...finalized.eventId ? { eventId: finalized.eventId } : void 0,
1387
- ...finalized.failureReason ? { failureReason: finalized.failureReason } : void 0
1388
- };
1389
- }, replyTimeoutMs);
1390
- if (outcome.status !== "completed") {
1391
- await status.clear();
1392
- const onAuthPause = runArgs.onAuthPause;
1393
- const onSuspend = runArgs.onSuspend;
1394
- if (outcome.status === "awaiting_auth" && onAuthPause) {
1395
- deferredAuthInfo = {
1396
- providerDisplayName: outcome.providerDisplayName,
1397
- actorId: isUserActor(resumeActor) ? resumeActor.userId : void 0,
1398
- ...outcome.requestText ? { requestText: outcome.requestText } : void 0
1399
- };
1400
- deferredPauseHandler = async () => {
1401
- await onAuthPause({ providerDisplayName: outcome.providerDisplayName });
1402
- };
1403
- } else if (outcome.status === "suspended") {
1404
- if (runArgs.ownsConversationLease && (outcome.reason === "timeout" || savedRun.durability?.shouldYield?.())) throw new CooperativeTurnYieldError();
1405
- if (onSuspend) deferredPauseHandler = async () => {
1406
- await onSuspend(outcome.resumeVersion);
1407
- };
1408
- else deferredFailureHandler = async () => {
1409
- await handleResumeFailure({
1410
- error: /* @__PURE__ */ new Error("Resumed Run ended suspended without onSuspend"),
1411
- eventName: "slack.resume.turn.failed",
1412
- failureCode: "agent_run_failed",
1413
- resume: runArgs
1414
- });
1415
- };
1416
- } else deferredFailureHandler = async () => {
1417
- await handleResumeFailure({
1418
- error: /* @__PURE__ */ new Error(`Resumed Run ended ${outcome.status} without its required callback`),
1419
- eventName: "slack.resume.turn.failed",
1420
- failureCode: "agent_run_failed",
1421
- resume: runArgs
1422
- });
1423
- };
1424
- }
1425
- if (shouldScheduleCompletedPluginTasks) try {
1426
- const params = {
1427
- conversationId: runArgs.conversationId,
1428
- sessionId: runArgs.turnId
1429
- };
1430
- await (runArgs.scheduleSessionCompletedPluginTasks ?? scheduleSessionCompletedPluginTasks)(params);
1431
- } catch (scheduleError) {
1432
- logException(scheduleError, "plugin.session.completed_task_schedule.failed");
1433
- }
1434
- } catch (error) {
1435
- await status.clear();
1436
- const runError = error instanceof AgentRunError ? error.cause : error;
1437
- if (isCooperativeTurnYieldError(runError)) {
1438
- if (lock) await stateAdapter.releaseLock(lock);
1439
- await processingReaction?.stop();
1440
- throw runError;
1441
- }
1442
- if (runResultHandled) {
1443
- postDeliveryCommitError = error;
1444
- try {
1445
- await runArgs.onPostDeliveryCommitFailure?.(error);
1446
- } catch (terminalizeError) {
1447
- logException(terminalizeError, "slack.resume.post_delivery_terminalize.failed");
1448
- }
1449
- } else deferredFailureHandler = async () => {
1450
- await handleResumeFailure({
1451
- error: runError,
1452
- eventName: "slack.resume.turn.failed",
1453
- failureCode,
1454
- resume: runArgs
1455
- });
1456
- };
1457
- } finally {
1458
- if (runResultHandled) await processingReaction?.complete();
1459
- else await processingReaction?.stop();
1460
- if (lock) await stateAdapter.releaseLock(lock);
1461
- }
1462
- if (postDeliveryCommitError) {
1463
- const eventId = logException(postDeliveryCommitError, "slack.resume.success_handler.failed");
1464
- await turnLifecycle.fail({
1465
- conversationId: runArgs.conversationId,
1466
- turnId: runArgs.turnId,
1467
- createdAtMs: Date.now(),
1468
- failureCode: "persistence_failed",
1469
- ...eventId ? { eventId } : void 0
1470
- });
1471
- throw postDeliveryCommitError;
1472
- }
1473
- if (deferredPauseHandler) try {
1474
- await deferredPauseHandler();
1475
- if (deferredAuthInfo) await postSlackMessageBestEffort(runArgs.channelId, runArgs.threadTs, buildAuthPauseResponse(deferredAuthInfo.actorId, deferredAuthInfo.providerDisplayName, deferredAuthInfo.requestText), runArgs.conversationId, runArgs.run?.dispatch?.replyAttribution);
1476
- return true;
1477
- } catch (pauseError) {
1478
- await handleResumeFailure({
1479
- error: pauseError,
1480
- eventName: "slack.resume.pause_handler.failed",
1481
- failureCode: "persistence_failed",
1482
- resume: runArgs
1483
- });
1484
- return true;
1485
- }
1486
- if (deferredFailureHandler) await deferredFailureHandler();
1487
- return true;
1488
- }
1489
- //#endregion
1490
- //#region src/chat/task-execution/turn-wake.ts
1491
- /** Build the worker input for a paused turn. */
1492
- async function getPausedTurnRequest(args) {
1493
- const turn = (await loadTurnCheckpoint({
1494
- conversationId: args.conversationId,
1495
- turnId: args.turnId
1496
- })).record;
1497
- if (!turn || turn.state !== "paused" || turn.resumeReason !== "timeout" && turn.resumeReason !== "yield" && turn.resumeReason !== "retry" || turn.resumeReason === "timeout" && turn.sliceId < 2) return;
1498
- let routing;
1499
- try {
1500
- routing = await resolveTurnSessionRouting({
1501
- conversationId: args.conversationId,
1502
- conversationStore: args.conversationStore
1503
- });
1504
- } catch (error) {
1505
- await failTurnRecord({
1506
- conversationId: args.conversationId,
1507
- expectedVersion: turn.version,
1508
- turnId: args.turnId,
1509
- errorMessage: error instanceof Error ? error.message : String(error)
1510
- });
1511
- return;
1512
- }
1513
- return {
1514
- conversationId: args.conversationId,
1515
- destination: routing.destination,
1516
- turnId: args.turnId,
1517
- expectedVersion: turn.version
1518
- };
1519
- }
1520
- /** Wake the conversation worker for a paused turn. */
1521
- async function wakePausedTurn(request, options = {}) {
1522
- const nowMs = options.nowMs ?? Date.now();
1523
- await requestConversationWork({
1524
- conversationId: request.conversationId,
1525
- destination: request.destination,
1526
- nowMs,
1527
- state: options.state
1528
- });
1529
- const queue = options.queue ?? getVercelConversationWorkQueue();
1530
- await ensureConversationWake({
1531
- conversationId: request.conversationId,
1532
- idempotencyKey: [
1533
- "agent-continue",
1534
- request.conversationId,
1535
- request.turnId,
1536
- request.expectedVersion,
1537
- nowMs
1538
- ].join(":"),
1539
- nowMs,
1540
- queue,
1541
- state: options.state
1542
- });
1543
- }
1544
- /** Create paused Turn operations for one app. */
1545
- function createPausedTurns(options = {}) {
1546
- return {
1547
- get: (args) => getPausedTurnRequest({
1548
- ...args,
1549
- conversationStore: options.conversationStore
1550
- }),
1551
- wake: (request) => wakePausedTurn(request, options)
1552
- };
1553
- }
1554
- //#endregion
1555
- //#region src/handlers/oauth-html.ts
1556
- /** Build pre-escaped callback HTML with the shared response security policy. */
1557
- function htmlCallbackResponse(title, message, status, options = {}) {
1558
- const footerMessage = options.footerMessage ?? `You can close this tab and return to ${botConfig.userName}.`;
1559
- const isSuccess = status >= 200 && status < 300;
1560
- const statusClass = isSuccess ? "success" : "error";
1561
- const statusIcon = isSuccess ? "" : "!";
1562
- const html = `<!DOCTYPE html>
1563
- <html lang="en">
1564
- <head>
1565
- <meta charset="utf-8">
1566
- <meta name="viewport" content="width=device-width, initial-scale=1">
1567
- <meta name="color-scheme" content="light dark">
1568
- <title>${title}</title>
1569
- <style>
1570
- :root {
1571
- color-scheme: light dark;
1572
- --canvas: #f4f1fa;
1573
- --surface: #ffffff;
1574
- --text: #211a2c;
1575
- --muted: #6e6479;
1576
- --border: #ded7e8;
1577
- --accent: #6c5fc7;
1578
- --success: #16835c;
1579
- --success-soft: #ddf5ea;
1580
- --error: #bc3d50;
1581
- --error-soft: #fce7eb;
1582
- --shadow: 0 24px 64px rgba(45, 31, 63, 0.14);
1583
- }
1584
-
1585
- * { box-sizing: border-box; }
1586
-
1587
- body {
1588
- min-height: 100vh;
1589
- margin: 0;
1590
- display: grid;
1591
- place-items: center;
1592
- padding: 24px;
1593
- background: var(--canvas);
1594
- color: var(--text);
1595
- font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
1596
- }
1597
-
1598
- main {
1599
- width: min(100%, 480px);
1600
- padding: 48px 40px 40px;
1601
- text-align: center;
1602
- background: var(--surface);
1603
- border: 1px solid var(--border);
1604
- border-radius: 24px;
1605
- box-shadow: var(--shadow);
1606
- }
1607
-
1608
- .brand {
1609
- display: inline-flex;
1610
- align-items: center;
1611
- gap: 8px;
1612
- margin-bottom: 32px;
1613
- color: var(--muted);
1614
- font-size: 14px;
1615
- font-weight: 700;
1616
- letter-spacing: 0.08em;
1617
- text-transform: uppercase;
1618
- }
1619
-
1620
- .brand-mark {
1621
- width: 10px;
1622
- height: 10px;
1623
- background: var(--accent);
1624
- border-radius: 3px;
1625
- transform: rotate(12deg);
1626
- }
1627
-
1628
- .status {
1629
- width: 64px;
1630
- height: 64px;
1631
- margin: 0 auto 24px;
1632
- display: grid;
1633
- place-items: center;
1634
- border-radius: 20px;
1635
- font-size: 32px;
1636
- font-weight: 750;
1637
- line-height: 1;
1638
- }
1639
-
1640
- .status.success { color: var(--success); background: var(--success-soft); }
1641
- .status.success::before {
1642
- content: "";
1643
- width: 12px;
1644
- height: 24px;
1645
- margin-top: -5px;
1646
- border-right: 4px solid currentColor;
1647
- border-bottom: 4px solid currentColor;
1648
- transform: rotate(45deg);
1649
- }
1650
- .status.error { color: var(--error); background: var(--error-soft); }
1651
-
1652
- h1 {
1653
- margin: 0;
1654
- font-size: clamp(28px, 7vw, 36px);
1655
- line-height: 1.12;
1656
- letter-spacing: -0.035em;
1657
- }
1658
-
1659
- .message {
1660
- margin: 16px auto 0;
1661
- color: var(--muted);
1662
- font-size: 17px;
1663
- line-height: 1.6;
1664
- }
1665
-
1666
- .footer {
1667
- margin: 32px 0 0;
1668
- padding-top: 24px;
1669
- color: var(--muted);
1670
- border-top: 1px solid var(--border);
1671
- font-size: 14px;
1672
- line-height: 1.5;
1673
- }
1674
-
1675
- @media (prefers-color-scheme: dark) {
1676
- :root {
1677
- --canvas: #15121a;
1678
- --surface: #211c28;
1679
- --text: #f6f1fb;
1680
- --muted: #b8adbf;
1681
- --border: #3a3243;
1682
- --accent: #a99af0;
1683
- --success: #72d6ad;
1684
- --success-soft: #193c30;
1685
- --error: #ff9aaa;
1686
- --error-soft: #48262e;
1687
- --shadow: 0 24px 64px rgba(0, 0, 0, 0.36);
1688
- }
1689
- }
1690
-
1691
- @media (max-width: 520px) {
1692
- body { padding: 16px; }
1693
- main { padding: 40px 24px 32px; border-radius: 20px; }
1694
- }
1695
- </style>
1696
- </head>
1697
- <body>
1698
- <main>
1699
- <div class="brand"><span class="brand-mark" aria-hidden="true"></span>${botConfig.userName}</div>
1700
- <div class="status ${statusClass}" aria-hidden="true">${statusIcon}</div>
1701
- <h1>${title}</h1>
1702
- <p class="message">${message}</p>
1703
- <p class="footer">${footerMessage}</p>
1704
- </main>
1705
- </body>
1706
- </html>`;
1707
- return new Response(html, {
1708
- status,
1709
- headers: {
1710
- "Cache-Control": "no-store",
1711
- "Content-Security-Policy": "default-src 'none'; style-src 'unsafe-inline'; frame-ancestors 'none'; base-uri 'none'; form-action 'none'",
1712
- "Content-Type": "text/html; charset=utf-8",
1713
- "Referrer-Policy": "no-referrer",
1714
- "X-Content-Type-Options": "nosniff"
1715
- }
1716
- });
1717
- }
1718
- //#endregion
1719
- //#region src/handlers/mcp-oauth-callback.ts
1720
- /**
1721
- * MCP OAuth callback handler.
1722
- *
1723
- * Public signed callbacks relay to the owning CLI. The loopback callback
1724
- * finalizes local MCP authorization and leaves continuation to that CLI.
1725
- * Slack callbacks finalize authorization, update pending-auth/event-log state,
1726
- * and resume the exact parked turn. Stale callbacks must not resume newer
1727
- * thread work after another user message supersedes the paused request.
1728
- */
1729
- function callbackPages(botName) {
1730
- return {
1731
- missing_state: {
1732
- title: "Authorization failed",
1733
- message: "Missing state parameter.",
1734
- status: 400
1735
- },
1736
- provider_error: {
1737
- title: "Authorization failed",
1738
- message: "The provider returned an authorization error.",
1739
- status: 400
1740
- },
1741
- missing_code: {
1742
- title: "Authorization failed",
1743
- message: "Missing code parameter.",
1744
- status: 400
1745
- },
1746
- expired: {
1747
- title: "Authorization expired",
1748
- message: `This authorization link is no longer active. Return to ${botName} and retry the original request.`,
1749
- status: 400
1750
- },
1751
- success: {
1752
- title: "Authorization complete",
1753
- message: `Your MCP access is connected. ${botName} will continue the paused request.`,
1754
- status: 200
1755
- },
1756
- failure: {
1757
- title: "Authorization failed",
1758
- message: `${botName} could not finish the authorization callback. Return to ${botName} and retry the original request.`,
1759
- status: 500
1760
- }
1761
- };
1762
- }
1763
- var McpOAuthAttemptExpiredError = class extends Error {
1764
- constructor() {
1765
- super("MCP OAuth authorization attempt is no longer current");
1766
- this.name = "McpOAuthAttemptExpiredError";
1767
- }
1768
- };
1769
- function mcpAuthorizationId(args) {
1770
- return `${args.sessionId}:mcp:${args.provider}`;
1771
- }
1772
- function htmlResponse(kind, options) {
1773
- const botName = botConfig.userName;
1774
- const pages = callbackPages(botName);
1775
- const page = kind === "success" && options?.local ? {
1776
- ...pages.success,
1777
- message: `Your MCP access is connected. ${botName} will continue the paused request in the local client.`
1778
- } : pages[kind];
1779
- const footerMessage = kind === "success" && !options?.local ? `You can close this tab and return to ${botName}.` : void 0;
1780
- return htmlCallbackResponse(page.title, page.message, page.status, { footerMessage });
1781
- }
1782
- async function persistCompletedReplyState(channelId, threadTs, sessionId, reply) {
1783
- const threadId = `slack:${channelId}:${threadTs}`;
1784
- const currentState = await getPersistedThreadState(threadId);
1785
- const conversation = coerceThreadConversationState(currentState);
1786
- await hydrateConversationMessages({
1787
- conversation,
1788
- conversationId: threadId
1789
- });
1790
- const userMessage = getTurnUserMessage(conversation, sessionId);
1791
- const statePatch = buildDeliveredTurnStatePatch({
1792
- conversation,
1793
- reply,
1794
- sessionId,
1795
- userMessageId: userMessage?.id
1796
- });
1797
- await persistThreadStateById(threadId, { ...statePatch });
1798
- }
1799
- async function failSessionRecordBestEffort$1(args) {
1800
- try {
1801
- await failTurnRecord({
1802
- conversationId: args.conversationId,
1803
- turnId: args.turnId,
1804
- errorMessage: args.errorMessage,
1805
- expectedVersion: args.expectedVersion
1806
- });
1807
- } catch (error) {
1808
- logException(error, "mcp.oauth_callback.session_record.failure_persistence.failed", {
1809
- "app.ai.conversation_id": args.conversationId,
1810
- "app.ai.session_id": args.turnId
1811
- });
1812
- }
1813
- }
1814
- async function persistFailedReplyState(channelId, threadTs, sessionId, expectedVersion) {
1815
- const threadId = `slack:${channelId}:${threadTs}`;
1816
- const currentState = await getPersistedThreadState(threadId);
1817
- const conversation = coerceThreadConversationState(currentState);
1818
- await hydrateConversationMessages({
1819
- conversation,
1820
- conversationId: threadId
1821
- });
1822
- clearPendingAuth(conversation, sessionId);
1823
- markTurnFailed({
1824
- conversation,
1825
- nowMs: Date.now(),
1826
- sessionId,
1827
- userMessageId: getTurnUserMessageId(conversation, sessionId),
1828
- markConversationMessage
1829
- });
1830
- await failSessionRecordBestEffort$1({
1831
- conversationId: threadId,
1832
- turnId: sessionId,
1833
- errorMessage: "OAuth-resumed MCP turn failed",
1834
- expectedVersion
1835
- });
1836
- await persistThreadStateById(threadId, { conversation });
1837
- }
1838
- async function resumeAuthorizedMcpTurn(args) {
1839
- const { authSession, agentRunner, provider } = args;
1840
- if (!authSession.channelId || !authSession.destination || !authSession.threadTs) return;
1841
- const destination = requireSlackDestination(authSession.destination, "MCP OAuth resume");
1842
- const threadId = `slack:${authSession.channelId}:${authSession.threadTs}`;
1843
- const currentState = await getPersistedThreadState(threadId);
1844
- const conversation = coerceThreadConversationState(currentState);
1845
- await hydrateConversationMessages({
1846
- conversation,
1847
- conversationId: threadId
1848
- });
1849
- const pendingAuth = getConversationPendingAuth({
1850
- conversation,
1851
- kind: "mcp",
1852
- provider,
1853
- actorId: authSession.userId
1854
- });
1855
- if (pendingAuth?.authSessionId !== authSession.authSessionId) return;
1856
- const resolvedSessionId = pendingAuth.sessionId;
1857
- const userMessage = getTurnUserMessage(conversation, resolvedSessionId);
1858
- if (!isPendingAuthLatestRequest(conversation, pendingAuth)) {
1859
- clearPendingAuth(conversation, pendingAuth.sessionId);
1860
- await persistThreadStateById(threadId, { conversation });
1861
- await abandonTurnRecord({
1862
- conversationId: authSession.conversationId,
1863
- turnId: pendingAuth.sessionId,
1864
- errorMessage: "Auth completed after a newer thread message abandoned this blocked request."
1865
- });
1866
- return;
1867
- }
1868
- if (!userMessage) return;
1869
- await resumeSlackTurn({
1870
- messageText: userMessage.text,
1871
- conversationId: authSession.conversationId,
1872
- turnId: resolvedSessionId,
1873
- channelId: authSession.channelId,
1874
- threadTs: authSession.threadTs,
1875
- messageTs: getTurnUserSlackMessageTs(userMessage),
1876
- lockKey: threadId,
1877
- initialText: "",
1878
- executeTurn: async (run, saveResult, timeoutMs) => await executeTurn(agentRunner, run, saveResult, timeoutMs),
1879
- beforeStart: async () => {
1880
- const lockedState = await getPersistedThreadState(threadId);
1881
- const lockedConversation = coerceThreadConversationState(lockedState);
1882
- await hydrateConversationMessages({
1883
- conversation: lockedConversation,
1884
- conversationId: threadId
1885
- });
1886
- const lockedPendingAuth = getConversationPendingAuth({
1887
- conversation: lockedConversation,
1888
- kind: "mcp",
1889
- provider,
1890
- actorId: authSession.userId
1891
- });
1892
- if (lockedPendingAuth?.authSessionId !== authSession.authSessionId) return false;
1893
- const lockedSessionId = lockedPendingAuth.sessionId;
1894
- if (lockedSessionId !== resolvedSessionId) return false;
1895
- if (!isPendingAuthLatestRequest(lockedConversation, lockedPendingAuth)) {
1896
- clearPendingAuth(lockedConversation, lockedPendingAuth.sessionId);
1897
- await persistThreadStateById(threadId, { conversation: lockedConversation });
1898
- await abandonTurnRecord({
1899
- conversationId: authSession.conversationId,
1900
- turnId: lockedPendingAuth.sessionId,
1901
- errorMessage: "Auth completed after a newer thread message abandoned this blocked request."
1902
- });
1903
- return false;
1904
- }
1905
- const lockedUserMessage = getTurnUserMessage(lockedConversation, lockedSessionId);
1906
- if (!lockedUserMessage) return false;
1907
- const lockedSessionRecord = await getTurnRecord(authSession.conversationId, lockedSessionId);
1908
- if (!lockedSessionRecord || lockedSessionRecord.state !== "paused" || lockedSessionRecord.resumeReason !== "auth") return false;
1909
- const lockedConversationContext = buildConversationContext(lockedConversation, { excludeMessageId: lockedUserMessage.id });
1910
- const lockedLocationConfiguration = getLocationConfigurationService(destination);
1911
- let actor;
1912
- try {
1913
- actor = createSlackResumeActor({
1914
- actor: lockedSessionRecord.actor,
1915
- teamId: destination.teamId,
1916
- userId: authSession.userId
1917
- });
1918
- } catch {
1919
- await failTurnRecord({
1920
- conversationId: authSession.conversationId,
1921
- expectedVersion: lockedSessionRecord.version,
1922
- turnId: lockedSessionId,
1923
- errorMessage: "Unable to rebuild Slack actor for OAuth resume"
1924
- });
1925
- return false;
1926
- }
1927
- let routing;
1928
- try {
1929
- routing = await resolveTurnSessionRouting({ conversationId: authSession.conversationId });
1930
- } catch (error) {
1931
- await failTurnRecord({
1932
- conversationId: authSession.conversationId,
1933
- expectedVersion: lockedSessionRecord.version,
1934
- turnId: lockedSessionId,
1935
- errorMessage: error instanceof Error ? error.message : String(error)
1936
- });
1937
- return false;
1938
- }
1939
- await recordAuthorizationCompleted({
1940
- conversationId: authSession.conversationId,
1941
- kind: "mcp",
1942
- provider,
1943
- actorId: authSession.userId,
1944
- authorizationId: mcpAuthorizationId({
1945
- provider,
1946
- sessionId: lockedSessionId
1947
- })
1948
- });
1949
- const lockedMessageTs = getTurnUserSlackMessageTs(lockedUserMessage);
1950
- return {
1951
- messageText: lockedUserMessage.text,
1952
- sliceId: lockedSessionRecord.sliceId,
1953
- messageTs: lockedMessageTs,
1954
- inputMessageIds: [lockedUserMessage.id],
1955
- run: {
1956
- instruction: {
1957
- text: lockedUserMessage.text,
1958
- context: lockedConversationContext,
1959
- ...getTurnUserReplyAttachmentContext(lockedUserMessage)
1960
- },
1961
- history: await loadProjection({ conversationId: threadId }),
1962
- credentialContext: { actor: {
1963
- type: "user",
1964
- userId: actor.userId
1965
- } },
1966
- actor,
1967
- destination,
1968
- ...routing.location ? { location: routing.location } : void 0,
1969
- source: routing.source,
1970
- toolChannelId: authSession.toolChannelId ?? authSession.channelId,
1971
- environment: {
1972
- configuration: authSession.configuration,
1973
- locationConfiguration: lockedLocationConfiguration
1974
- },
1975
- state: {
1976
- pendingAuth: lockedPendingAuth,
1977
- sandboxRef: getPersistedSandboxState(lockedState)
1978
- },
1979
- durability: { recordPendingAuth: async (nextPendingAuth) => {
1980
- lockedConversation.processing.pendingAuth = nextPendingAuth;
1981
- await persistThreadStateById(threadId, { conversation: lockedConversation });
1982
- } }
1983
- },
1984
- commitResult: async (reply) => {
1985
- await persistCompletedReplyState(authSession.channelId, authSession.threadTs, lockedSessionId, reply);
1986
- },
1987
- onPostDeliveryCommitFailure: async () => {
1988
- await failTurnRecord({
1989
- conversationId: authSession.conversationId,
1990
- expectedVersion: lockedSessionRecord.version,
1991
- turnId: lockedSessionId,
1992
- errorMessage: "OAuth-resumed MCP reply was delivered but completion state did not persist"
1993
- });
1994
- },
1995
- onFailure: async () => {
1996
- try {
1997
- await persistFailedReplyState(authSession.channelId, authSession.threadTs, lockedSessionId, lockedSessionRecord.version);
1998
- } catch (persistError) {
1999
- logException(persistError, "mcp.oauth_callback.resume_failure_persist.failed", { "app.credential.provider": provider });
2000
- }
2001
- },
2002
- onAuthPause: async () => {
2003
- await persistAuthPauseTurnState({
2004
- sessionId: lockedSessionId,
2005
- threadStateId: threadId
2006
- });
2007
- logWarn("mcp.oauth_callback.resume.reparked_for_auth", { "app.credential.provider": provider });
2008
- },
2009
- onSuspend: async (resumeVersion) => {
2010
- await wakePausedTurn({
2011
- conversationId: authSession.conversationId,
2012
- destination,
2013
- turnId: lockedSessionId,
2014
- expectedVersion: resumeVersion
2015
- });
2016
- }
2017
- };
2018
- }
2019
- });
2020
- }
2021
- async function isCurrentMcpAuthorizationAttempt(authSession, provider) {
2022
- const conversationId = mcpConversationId(authSession);
2023
- if (!conversationId) return false;
2024
- const currentState = await getPersistedThreadState(conversationId);
2025
- const conversation = coerceThreadConversationState(currentState);
2026
- const pendingAuth = getConversationPendingAuth({
2027
- conversation,
2028
- kind: "mcp",
2029
- provider,
2030
- actorId: authSession.userId
2031
- });
2032
- return pendingAuth?.authSessionId === authSession.authSessionId && pendingAuth.sessionId === authSession.sessionId;
2033
- }
2034
- function mcpConversationId(authSession) {
2035
- if (authSession.destination?.platform === "local" || authSession.source?.kind === "web") return authSession.conversationId;
2036
- if (authSession.channelId && authSession.threadTs) return `slack:${authSession.channelId}:${authSession.threadTs}`;
2037
- }
2038
- /** Commit one shared credential mutation only while this attempt owns the thread. */
2039
- async function runCurrentMcpCredentialMutation(authSession, provider, mutation) {
2040
- const conversationId = mcpConversationId(authSession);
2041
- if (!conversationId) throw new McpOAuthAttemptExpiredError();
2042
- const stateAdapter = getStateAdapter();
2043
- await stateAdapter.connect();
2044
- const lock = await acquireActiveLock(stateAdapter, conversationId);
2045
- if (!lock) throw new Error(`Could not acquire MCP OAuth callback lock for ${conversationId}`);
2046
- try {
2047
- if (!await isCurrentMcpAuthorizationAttempt(authSession, provider)) throw new McpOAuthAttemptExpiredError();
2048
- return await mutation();
2049
- } finally {
2050
- await stateAdapter.releaseLock(lock);
2051
- }
2052
- }
2053
- async function GET$1(request, provider, waitUntil, options) {
2054
- const localRelay = await relayLocalOAuthCallback(request);
2055
- if (localRelay) return localRelay;
2056
- const url = new URL(request.url);
2057
- const state = url.searchParams.get("state")?.trim();
2058
- const code = url.searchParams.get("code")?.trim();
2059
- const error = url.searchParams.get("error")?.trim();
2060
- if (!state) return htmlResponse("missing_state");
2061
- if (error) {
2062
- try {
2063
- const pendingSession = await getMcpAuthSession(state);
2064
- if (pendingSession && pendingSession.provider === provider) {
2065
- try {
2066
- await runCurrentMcpCredentialMutation(pendingSession, provider, async () => {
2067
- const credentials = await getMcpStoredOAuthCredentials(pendingSession.userId, provider);
2068
- if (!credentials?.clientInformation && !credentials?.discoveryState) return;
2069
- const nextCredentials = credentials.tokens ? { tokens: credentials.tokens } : {};
2070
- await putMcpStoredOAuthCredentials(pendingSession.userId, provider, nextCredentials);
2071
- });
2072
- } catch (cleanupError) {
2073
- if (!(cleanupError instanceof McpOAuthAttemptExpiredError)) logException(cleanupError, "mcp.oauth_callback.provider_error_cleanup.failed", { "app.credential.provider": provider });
2074
- }
2075
- await deleteMcpAuthSession(pendingSession.authSessionId);
2076
- }
2077
- } catch (cleanupError) {
2078
- logException(cleanupError, "mcp.oauth_callback.provider_error_cleanup.failed", { "app.credential.provider": provider });
2079
- }
2080
- return htmlResponse("provider_error");
2081
- }
2082
- if (!code) return htmlResponse("missing_code");
2083
- try {
2084
- const pendingSession = await getMcpAuthSession(state);
2085
- if (!pendingSession || pendingSession.provider !== provider || !await isCurrentMcpAuthorizationAttempt(pendingSession, provider)) {
2086
- if (pendingSession) await deleteMcpAuthSession(pendingSession.authSessionId);
2087
- return htmlResponse("expired");
2088
- }
2089
- const authSession = await finalizeMcpAuthorization(provider, state, code, async (mutation) => await runCurrentMcpCredentialMutation(pendingSession, provider, mutation));
2090
- let providerLabel;
2091
- try {
2092
- providerLabel = formatProviderLabel(provider);
2093
- } catch {
2094
- providerLabel = void 0;
2095
- }
2096
- try {
2097
- await recordAuthenticationLinked({
2098
- conversationId: authSession.conversationId,
2099
- provider,
2100
- actorId: authSession.userId,
2101
- authorizationId: mcpAuthorizationId({
2102
- provider,
2103
- sessionId: authSession.sessionId
2104
- }),
2105
- turnId: authSession.sessionId,
2106
- ...providerLabel ? { providerLabel } : void 0
2107
- });
2108
- } catch (error) {
2109
- logException(error, "mcp.oauth_callback.authentication_event.failed", { "app.credential.provider": provider });
2110
- }
2111
- try {
2112
- await deleteMcpAuthSession(authSession.authSessionId);
2113
- } catch (cleanupError) {
2114
- logException(cleanupError, "mcp.oauth_callback.session_cleanup.failed", { "app.credential.provider": provider });
2115
- }
2116
- if (authSession.source?.kind === "web" && authSession.destination) waitUntil(async () => {
2117
- const turn = await getTurnRecord(authSession.conversationId, authSession.sessionId);
2118
- await deleteWebAuthorization({
2119
- actorId: authSession.userId,
2120
- conversationId: authSession.conversationId
2121
- });
2122
- if (!turn || turn.state !== "paused" || turn.resumeReason !== "auth") return;
2123
- await recordAuthorizationCompleted({
2124
- conversationId: authSession.conversationId,
2125
- kind: "mcp",
2126
- provider,
2127
- actorId: authSession.userId,
2128
- authorizationId: mcpAuthorizationId({
2129
- provider,
2130
- sessionId: authSession.sessionId
2131
- })
2132
- });
2133
- await wakePausedTurn({
2134
- conversationId: authSession.conversationId,
2135
- destination: authSession.destination,
2136
- turnId: authSession.sessionId,
2137
- expectedVersion: turn.version
2138
- }, options.conversationWorkQueue ? { queue: options.conversationWorkQueue } : void 0);
2139
- });
2140
- else if (authSession.destination?.platform !== "local") waitUntil(() => resumeAuthorizedMcpTurn({
2141
- authSession,
2142
- agentRunner: options.agentRunner,
2143
- provider
2144
- }));
2145
- return htmlResponse("success", { local: authSession.destination?.platform === "local" && authSession.source?.kind !== "web" });
2146
- } catch (callbackError) {
2147
- if (callbackError instanceof McpOAuthAttemptExpiredError) {
2148
- await deleteMcpAuthSession(state);
2149
- return htmlResponse("expired");
2150
- }
2151
- logException(callbackError, "mcp.oauth_callback.failed", {
2152
- "app.credential.provider": provider,
2153
- ...getMcpProviderErrorAttributes(callbackError)
2154
- });
2155
- return htmlResponse("failure");
2156
- }
2157
- }
2158
- //#endregion
2159
- //#region src/chat/slack/app-home.ts
2160
- const DEFAULT_DESCRIPTION_TEXT = "I help your team investigate, summarize, and act on work in Slack.";
2161
- const MAX_HOME_SKILLS = 6;
2162
- const MAX_SECTION_TEXT_CHARS = 3e3;
2163
- const HIDDEN_HOME_SKILLS = /* @__PURE__ */ new Set(["jr-rpc"]);
2164
- function clampSectionText(text) {
2165
- if (text.length <= MAX_SECTION_TEXT_CHARS) return text;
2166
- return `${text.slice(0, 2999)}…`;
2167
- }
2168
- function loadDescriptionText() {
2169
- const descriptionPath = path.join(homeDir(), "DESCRIPTION.md");
2170
- try {
2171
- const raw = fs.readFileSync(descriptionPath, "utf8").trim();
2172
- if (raw.length > 0) return clampSectionText(raw);
2173
- } catch {}
2174
- return DEFAULT_DESCRIPTION_TEXT;
2175
- }
2176
- async function buildSkillsSummaryText() {
2177
- const skills = (await discoverSkills()).filter((skill) => !HIDDEN_HOME_SKILLS.has(skill.name));
2178
- if (skills.length === 0) return "No skills installed.";
2179
- const visible = skills.slice(0, MAX_HOME_SKILLS);
2180
- const lines = visible.map((skill) => `• *${skill.name}* — ${skill.description}`);
2181
- if (skills.length > visible.length) lines.push(`• …and ${skills.length - visible.length} more`);
2182
- return lines.join("\n");
2183
- }
2184
- function accountLabel(account) {
2185
- const label = account.label ?? account.id;
2186
- return account.url ? `<${account.url}|${label}>` : label;
2187
- }
2188
- function connectedAccountText(plugin, account) {
2189
- return account ? `*${plugin.manifest.name}*\nConnected as ${accountLabel(account)}` : `*${plugin.manifest.name}*\n${plugin.manifest.description}`;
2190
- }
2191
- async function connectedOAuthTokens(userId, plugin, userTokenStore) {
2192
- if (plugin.manifest.oauth || plugin.manifest.credentials) {
2193
- const stored = await userTokenStore.get(userId, plugin.manifest.name);
2194
- return stored && hasRequiredOAuthScope(stored.scope, plugin.manifest.oauth?.scope) ? stored : void 0;
2195
- }
2196
- }
2197
- async function hasConnectedMcpAccount(userId, plugin) {
2198
- if (!plugin.manifest.mcp) return false;
2199
- const credentials = await getMcpStoredOAuthCredentials(userId, plugin.manifest.name);
2200
- return Boolean(credentials?.tokens || credentials?.clientInformation || credentials?.discoveryState);
2201
- }
2202
- /** Build the Slack App Home tab view showing skills, connected accounts, and version. */
2203
- async function buildHomeView(userId, userTokenStore) {
2204
- const runtimeMetadata = getRuntimeMetadata();
2205
- const descriptionText = loadDescriptionText();
2206
- const skillsSummaryText = await buildSkillsSummaryText();
2207
- const providers = pluginCatalogRuntime.getProviders();
2208
- const connectedSections = [];
2209
- for (const plugin of providers) {
2210
- const tokens = await connectedOAuthTokens(userId, plugin, userTokenStore);
2211
- if (!tokens && !await hasConnectedMcpAccount(userId, plugin)) continue;
2212
- connectedSections.push({
2213
- type: "section",
2214
- text: {
2215
- type: "mrkdwn",
2216
- text: connectedAccountText(plugin, tokens?.account)
2217
- },
2218
- accessory: {
2219
- type: "button",
2220
- text: {
2221
- type: "plain_text",
2222
- text: "Unlink"
2223
- },
2224
- action_id: "app_home_disconnect",
2225
- value: plugin.manifest.name,
2226
- style: "danger"
2227
- }
2228
- });
2229
- }
2230
- const accountBlocks = connectedSections.length > 0 ? connectedSections : [{
2231
- type: "section",
2232
- text: {
2233
- type: "mrkdwn",
2234
- text: "No connected accounts"
2235
- }
2236
- }];
2237
- return {
2238
- type: "home",
2239
- blocks: [
2240
- {
2241
- type: "header",
2242
- text: {
2243
- type: "plain_text",
2244
- text: "Junior"
2245
- }
2246
- },
2247
- {
2248
- type: "section",
2249
- text: {
2250
- type: "mrkdwn",
2251
- text: descriptionText
2252
- }
2253
- },
2254
- { type: "divider" },
2255
- {
2256
- type: "header",
2257
- text: {
2258
- type: "plain_text",
2259
- text: "What I can help with"
2260
- }
2261
- },
2262
- {
2263
- type: "section",
2264
- text: {
2265
- type: "mrkdwn",
2266
- text: skillsSummaryText
2267
- }
2268
- },
2269
- { type: "divider" },
2270
- {
2271
- type: "header",
2272
- text: {
2273
- type: "plain_text",
2274
- text: "Connected accounts"
2275
- }
2276
- },
2277
- ...accountBlocks,
2278
- {
2279
- type: "context",
2280
- elements: [{
2281
- type: "mrkdwn",
2282
- text: `*junior version:* \`${runtimeMetadata.version ?? "unknown"}\``
2283
- }]
2284
- }
2285
- ]
2286
- };
2287
- }
2288
- /** Publish the App Home view to a specific Slack user. */
2289
- async function publishAppHomeView(slackClient, userId, userTokenStore) {
2290
- const view = await buildHomeView(userId, userTokenStore);
2291
- await slackClient.views.publish({
2292
- user_id: userId,
2293
- view
2294
- });
2295
- }
2296
- //#endregion
2297
- //#region src/handlers/oauth-callback.ts
2298
- /**
2299
- * OAuth callback contract for `@sentry/junior`.
2300
- *
2301
- * Providers redirect users to `/api/oauth/callback/:provider`. A public signed
2302
- * callback relays to the owning CLI; the loopback callback exchanges and stores
2303
- * the local credential while the CLI owns continuation. Slack callbacks
2304
- * exchange credentials synchronously, then use `waitUntil(...)` for
2305
- * best-effort resume side effects.
2306
- */
2307
- function htmlErrorResponse(title, message, status) {
2308
- return htmlCallbackResponse(escapeXml(title), escapeXml(message), status);
2309
- }
2310
- function oauthTokenErrorAttributes(provider, endpoint, status) {
2311
- return {
2312
- "app.credential.provider": provider,
2313
- "app.oauth.error.phase": "token_exchange",
2314
- "server.address": new URL(endpoint).hostname,
2315
- ...status !== void 0 ? { "http.response.status_code": status } : void 0
2316
- };
2317
- }
2318
- async function persistCompletedOAuthReplyState(args) {
2319
- const currentState = await getPersistedThreadState(args.conversationId);
2320
- const conversation = coerceThreadConversationState(currentState);
2321
- await hydrateConversationMessages({
2322
- conversation,
2323
- conversationId: args.conversationId
2324
- });
2325
- const userMessage = getTurnUserMessage(conversation, args.sessionId);
2326
- const statePatch = buildDeliveredTurnStatePatch({
2327
- conversation,
2328
- reply: args.reply,
2329
- sessionId: args.sessionId,
2330
- userMessageId: userMessage?.id
2331
- });
2332
- await persistThreadStateById(args.conversationId, { ...statePatch });
2333
- }
2334
- function pluginAuthorizationId(args) {
2335
- return `${args.sessionId}:plugin:${args.provider}`;
2336
- }
2337
- async function failSessionRecordBestEffort(args) {
2338
- try {
2339
- await failTurnRecord({
2340
- conversationId: args.conversationId,
2341
- expectedVersion: args.expectedVersion,
2342
- turnId: args.turnId,
2343
- errorMessage: args.errorMessage
2344
- });
2345
- } catch (error) {
2346
- logException(error, "oauth.callback.session_record.failure_persistence.failed", {
2347
- "app.ai.conversation_id": args.conversationId,
2348
- "app.ai.session_id": args.turnId
2349
- });
2350
- }
2351
- }
2352
- async function persistFailedOAuthReplyState(args) {
2353
- const currentState = await getPersistedThreadState(args.conversationId);
2354
- const conversation = coerceThreadConversationState(currentState);
2355
- await hydrateConversationMessages({
2356
- conversation,
2357
- conversationId: args.conversationId
2358
- });
2359
- clearPendingAuth(conversation, args.sessionId);
2360
- markTurnFailed({
2361
- conversation,
2362
- nowMs: Date.now(),
2363
- sessionId: args.sessionId,
2364
- userMessageId: getTurnUserMessage(conversation, args.sessionId)?.id,
2365
- markConversationMessage
2366
- });
2367
- await failSessionRecordBestEffort({
2368
- conversationId: args.conversationId,
2369
- expectedVersion: args.expectedVersion,
2370
- turnId: args.sessionId,
2371
- errorMessage: "OAuth-resumed turn failed"
2372
- });
2373
- await persistThreadStateById(args.conversationId, { conversation });
2374
- }
2375
- async function resumeOAuthSessionRecordTurn(stored, options) {
2376
- if (!stored.resumeConversationId || !stored.resumeSessionId || !stored.channelId || !stored.destination || !stored.source || !stored.threadTs) return;
2377
- const destination = requireSlackDestination(stored.destination, "OAuth resume");
2378
- const currentState = await getPersistedThreadState(stored.resumeConversationId);
2379
- const conversation = coerceThreadConversationState(currentState);
2380
- await hydrateConversationMessages({
2381
- conversation,
2382
- conversationId: stored.resumeConversationId
2383
- });
2384
- const pendingAuth = getConversationPendingAuth({
2385
- conversation,
2386
- kind: "plugin",
2387
- provider: stored.provider,
2388
- actorId: stored.userId,
2389
- ...stored.scope ? { scope: stored.scope } : void 0
2390
- });
2391
- const resolvedSessionId = pendingAuth?.sessionId ?? stored.resumeSessionId;
2392
- const userMessage = resolvedSessionId ? getTurnUserMessage(conversation, resolvedSessionId) : void 0;
2393
- if (pendingAuth) {
2394
- if (!isPendingAuthLatestRequest(conversation, pendingAuth)) {
2395
- clearPendingAuth(conversation, pendingAuth.sessionId);
2396
- await persistThreadStateById(stored.resumeConversationId, { conversation });
2397
- await abandonTurnRecord({
2398
- conversationId: stored.resumeConversationId,
2399
- turnId: pendingAuth.sessionId,
2400
- errorMessage: "Auth completed after a newer thread message abandoned this blocked request."
2401
- });
2402
- return;
2403
- }
2404
- }
2405
- const sessionRecord = await getTurnRecord(stored.resumeConversationId, resolvedSessionId);
2406
- if (!sessionRecord) return;
2407
- if (sessionRecord.state === "completed" || sessionRecord.state === "failed" || sessionRecord.state === "abandoned") return;
2408
- if (sessionRecord.state !== "paused" || sessionRecord.resumeReason !== "auth") return;
2409
- if (!userMessage?.author?.userId) return;
2410
- if (!pendingAuth && conversation.processing.activeTurnId !== stored.resumeSessionId) return;
2411
- await resumeSlackTurn({
2412
- messageText: userMessage.text,
2413
- conversationId: stored.resumeConversationId,
2414
- turnId: resolvedSessionId,
2415
- channelId: stored.channelId,
2416
- threadTs: stored.threadTs,
2417
- messageTs: getTurnUserSlackMessageTs(userMessage),
2418
- lockKey: stored.resumeConversationId,
2419
- initialText: "",
2420
- executeTurn: async (run, saveResult, timeoutMs) => await executeTurn(options.agentRunner, run, saveResult, timeoutMs),
2421
- beforeStart: async () => {
2422
- const lockedState = await getPersistedThreadState(stored.resumeConversationId);
2423
- const lockedConversation = coerceThreadConversationState(lockedState);
2424
- await hydrateConversationMessages({
2425
- conversation: lockedConversation,
2426
- conversationId: stored.resumeConversationId
2427
- });
2428
- const lockedPendingAuth = getConversationPendingAuth({
2429
- conversation: lockedConversation,
2430
- kind: "plugin",
2431
- provider: stored.provider,
2432
- actorId: stored.userId,
2433
- ...stored.scope ? { scope: stored.scope } : void 0
2434
- });
2435
- const lockedSessionId = lockedPendingAuth?.sessionId ?? stored.resumeSessionId;
2436
- if (lockedSessionId !== resolvedSessionId) return false;
2437
- const lockedSessionRecord = await getTurnRecord(stored.resumeConversationId, lockedSessionId);
2438
- if (!lockedSessionRecord || lockedSessionRecord.state !== "paused" || lockedSessionRecord.resumeReason !== "auth") return false;
2439
- if (lockedPendingAuth) {
2440
- if (!isPendingAuthLatestRequest(lockedConversation, lockedPendingAuth)) {
2441
- clearPendingAuth(lockedConversation, lockedPendingAuth.sessionId);
2442
- await persistThreadStateById(stored.resumeConversationId, { conversation: lockedConversation });
2443
- await abandonTurnRecord({
2444
- conversationId: stored.resumeConversationId,
2445
- turnId: lockedPendingAuth.sessionId,
2446
- errorMessage: "Auth completed after a newer thread message abandoned this blocked request."
2447
- });
2448
- return false;
2449
- }
2450
- } else if (lockedConversation.processing.activeTurnId !== stored.resumeSessionId) return false;
2451
- const lockedUserMessage = getTurnUserMessage(lockedConversation, lockedSessionId);
2452
- if (!lockedUserMessage?.author?.userId) return false;
2453
- const lockedConversationContext = buildConversationContext(lockedConversation, { excludeMessageId: lockedUserMessage.id });
2454
- const lockedLocationConfiguration = getLocationConfigurationService(destination);
2455
- let actor;
2456
- try {
2457
- actor = createSlackResumeActor({
2458
- actor: lockedSessionRecord.actor,
2459
- teamId: destination.teamId,
2460
- userId: lockedUserMessage.author.userId
2461
- });
2462
- } catch {
2463
- await failTurnRecord({
2464
- conversationId: stored.resumeConversationId,
2465
- expectedVersion: lockedSessionRecord.version,
2466
- turnId: lockedSessionId,
2467
- errorMessage: "Unable to rebuild Slack actor for OAuth resume"
2468
- });
2469
- return false;
2470
- }
2471
- let routing;
2472
- try {
2473
- routing = await resolveTurnSessionRouting({ conversationId: stored.resumeConversationId });
2474
- } catch (error) {
2475
- await failTurnRecord({
2476
- conversationId: stored.resumeConversationId,
2477
- expectedVersion: lockedSessionRecord.version,
2478
- turnId: lockedSessionId,
2479
- errorMessage: error instanceof Error ? error.message : String(error)
2480
- });
2481
- return false;
2482
- }
2483
- await recordAuthorizationCompleted({
2484
- conversationId: stored.resumeConversationId,
2485
- kind: "plugin",
2486
- provider: stored.provider,
2487
- actorId: stored.userId,
2488
- authorizationId: pluginAuthorizationId({
2489
- provider: stored.provider,
2490
- sessionId: lockedSessionId
2491
- })
2492
- });
2493
- const lockedMessageTs = getTurnUserSlackMessageTs(lockedUserMessage);
2494
- return {
2495
- messageText: lockedUserMessage.text,
2496
- sliceId: lockedSessionRecord.sliceId,
2497
- messageTs: lockedMessageTs,
2498
- inputMessageIds: [lockedUserMessage.id],
2499
- run: {
2500
- instruction: {
2501
- text: lockedUserMessage.text,
2502
- context: lockedConversationContext,
2503
- ...getTurnUserReplyAttachmentContext(lockedUserMessage)
2504
- },
2505
- history: await loadProjection({ conversationId: stored.resumeConversationId }),
2506
- credentialContext: { actor: {
2507
- type: "user",
2508
- userId: actor.userId
2509
- } },
2510
- actor,
2511
- destination,
2512
- ...routing.location ? { location: routing.location } : void 0,
2513
- source: routing.source,
2514
- toolChannelId: stored.channelId,
2515
- environment: { locationConfiguration: lockedLocationConfiguration },
2516
- state: {
2517
- pendingAuth: lockedPendingAuth,
2518
- sandboxRef: getPersistedSandboxState(lockedState)
2519
- },
2520
- durability: { recordPendingAuth: async (nextPendingAuth) => {
2521
- lockedConversation.processing.pendingAuth = nextPendingAuth;
2522
- await persistThreadStateById(stored.resumeConversationId, { conversation: lockedConversation });
2523
- } }
2524
- },
2525
- commitResult: async (reply) => {
2526
- logInfo("oauth.callback.resume.completed", {
2527
- "app.credential.provider": stored.provider,
2528
- "app.ai.outcome": reply.diagnostics.outcome,
2529
- "app.ai.tool_calls": reply.diagnostics.toolCalls.length
2530
- });
2531
- await persistCompletedOAuthReplyState({
2532
- conversationId: stored.resumeConversationId,
2533
- sessionId: lockedSessionId,
2534
- reply
2535
- });
2536
- },
2537
- onPostDeliveryCommitFailure: async () => {
2538
- await failTurnRecord({
2539
- conversationId: stored.resumeConversationId,
2540
- expectedVersion: lockedSessionRecord.version,
2541
- turnId: lockedSessionId,
2542
- errorMessage: "OAuth-resumed reply was delivered but completion state did not persist"
2543
- });
2544
- },
2545
- onFailure: async () => {
2546
- await persistFailedOAuthReplyState({
2547
- conversationId: stored.resumeConversationId,
2548
- expectedVersion: lockedSessionRecord.version,
2549
- sessionId: lockedSessionId
2550
- });
2551
- },
2552
- onAuthPause: async () => {
2553
- await persistAuthPauseTurnState({
2554
- sessionId: lockedSessionId,
2555
- threadStateId: stored.resumeConversationId
2556
- });
2557
- },
2558
- onSuspend: async (resumeVersion) => {
2559
- await wakePausedTurn({
2560
- conversationId: stored.resumeConversationId,
2561
- destination,
2562
- turnId: lockedSessionId,
2563
- expectedVersion: resumeVersion
2564
- });
2565
- }
2566
- };
2567
- }
2568
- });
2569
- }
2570
- async function GET(request, provider, waitUntil, options) {
2571
- const localRelay = await relayLocalOAuthCallback(request);
2572
- if (localRelay) return localRelay;
2573
- const providerConfig = pluginCatalogRuntime.getOAuthConfig(provider);
2574
- if (!providerConfig) return htmlErrorResponse("Unknown provider", "The OAuth provider in this link is not recognized.", 404);
2575
- const providerLabel = formatProviderLabel(provider);
2576
- const url = new URL(request.url);
2577
- const errorParam = url.searchParams.get("error");
2578
- const code = url.searchParams.get("code");
2579
- const state = url.searchParams.get("state");
2580
- if (errorParam) {
2581
- if (state) await getStateAdapter().delete(`oauth-state:${state}`);
2582
- if (errorParam === "access_denied") return htmlErrorResponse("Authorization declined", `You declined the ${providerLabel} authorization request. Return to Junior and connect your ${providerLabel} account again if you change your mind.`, 400);
2583
- return htmlErrorResponse("Authorization failed", `${providerLabel} returned an error: ${errorParam}. Return to Junior and try again.`, 400);
2584
- }
2585
- if (!code || !state) return htmlErrorResponse("Invalid request", "This authorization link is missing required parameters.", 400);
2586
- const stateAdapter = getStateAdapter();
2587
- const stateKey = `oauth-state:${state}`;
2588
- const stored = parseOAuthStatePayload(await stateAdapter.get(stateKey));
2589
- if (!stored) return htmlErrorResponse("Link expired", `This authorization link has expired (links are valid for 10 minutes). Return to Junior and connect your ${providerLabel} account again to get a new link.`, 400);
2590
- if (stored.provider !== provider) return htmlErrorResponse("Provider mismatch", "This authorization link does not match the expected provider.", 400);
2591
- await stateAdapter.delete(stateKey);
2592
- const clientId = process.env[providerConfig.clientIdEnv]?.trim();
2593
- const clientSecret = process.env[providerConfig.clientSecretEnv]?.trim();
2594
- if (!clientId || !clientSecret) return htmlErrorResponse("Configuration error", "OAuth client credentials are not configured on the server.", 500);
2595
- const baseUrl = resolveBaseUrl();
2596
- if (!baseUrl) return htmlErrorResponse("Configuration error", "The server cannot determine its base URL.", 500);
2597
- const redirectUri = `${baseUrl}${providerConfig.callbackPath}`;
2598
- const requestedScope = stored.scope ?? providerConfig.scope;
2599
- let tokenResponse;
2600
- try {
2601
- const tokenRequest = buildOAuthTokenRequest({
2602
- clientId,
2603
- clientSecret,
2604
- payload: {
2605
- grant_type: "authorization_code",
2606
- code,
2607
- redirect_uri: redirectUri
2608
- },
2609
- tokenAuthMethod: providerConfig.tokenAuthMethod,
2610
- tokenExtraHeaders: providerConfig.tokenExtraHeaders
2611
- });
2612
- tokenResponse = await fetch(providerConfig.tokenEndpoint, {
2613
- method: "POST",
2614
- headers: tokenRequest.headers,
2615
- body: tokenRequest.body
2616
- });
2617
- } catch {
2618
- logWarn("oauth.token_exchange.failed", oauthTokenErrorAttributes(provider, providerConfig.tokenEndpoint));
2619
- return htmlErrorResponse("Connection failed", "Failed to exchange the authorization code. Please try again.", 500);
2620
- }
2621
- if (!tokenResponse.ok) {
2622
- logWarn("oauth.token_exchange.failed", oauthTokenErrorAttributes(provider, providerConfig.tokenEndpoint, tokenResponse.status));
2623
- return htmlErrorResponse("Connection failed", "The token exchange with the provider failed. Please try again.", 500);
2624
- }
2625
- let parsedTokenResponse;
2626
- try {
2627
- const tokenData = await tokenResponse.json();
2628
- parsedTokenResponse = parseOAuthTokenResponse(tokenData, requestedScope, { treatEmptyScopeAsUnreported: providerConfig.treatEmptyScopeAsUnreported });
2629
- } catch {
2630
- logWarn("oauth.token_exchange.failed", oauthTokenErrorAttributes(provider, providerConfig.tokenEndpoint, tokenResponse.status));
2631
- return htmlErrorResponse("Connection failed", "The provider returned an incomplete token response. Please try again.", 500);
2632
- }
2633
- if (!hasRequiredOAuthScope(parsedTokenResponse.scope, requestedScope)) return htmlErrorResponse("Connection failed", `The ${providerLabel} authorization did not grant the access Junior requires. Return to Junior and connect your ${providerLabel} account again.`, 400);
2634
- const userTokenStore = createUserTokenStore();
2635
- let account;
2636
- try {
2637
- account = await resolvePluginOAuthAccount({
2638
- provider,
2639
- tokens: parsedTokenResponse
2640
- });
2641
- } catch {
2642
- return htmlErrorResponse("Connection failed", `Junior could not verify the connected ${providerLabel} account. Please try again.`, 500);
2643
- }
2644
- await userTokenStore.set(stored.userId, provider, {
2645
- ...parsedTokenResponse,
2646
- ...account ? { account } : void 0
2647
- });
2648
- const slackActor = stored.actor?.platform === "slack" ? stored.actor : void 0;
2649
- if (account && slackActor) await runBestEffort(async () => {
2650
- const slackUserId = parseSlackUserId(slackActor.userId);
2651
- if (!slackUserId) throw new Error("OAuth Slack actor user id is invalid");
2652
- const profile = await lookupSlackUserProfile(slackUserId);
2653
- const slackIdentity = await upsertIdentity(getSqlExecutor(), {
2654
- kind: "user",
2655
- provider: "slack",
2656
- providerTenantId: slackActor.teamId,
2657
- providerSubjectId: profile.id,
2658
- displayName: profile.real_name || profile.display_name,
2659
- handle: profile.name,
2660
- ...profile.email ? {
2661
- email: profile.email,
2662
- emailVerified: true
2663
- } : void 0
2664
- });
2665
- if (!slackIdentity.userId) throw new Error("OAuth Slack identity is not linked to a user");
2666
- await upsertLinkedIdentity(getSqlExecutor(), slackIdentity.userId, {
2667
- kind: "user",
2668
- provider,
2669
- providerSubjectId: account.id,
2670
- displayName: account.displayName,
2671
- handle: account.handle ?? account.label
2672
- });
2673
- }, "oauth.callback.identity_link.failed", { "app.credential.provider": provider });
2674
- const resumeConversationId = stored.resumeConversationId;
2675
- if (resumeConversationId) await runBestEffort(async () => {
2676
- await recordAuthenticationLinked({
2677
- conversationId: resumeConversationId,
2678
- provider,
2679
- actorId: stored.userId,
2680
- providerLabel,
2681
- ...account?.label ? { accountLabel: account.label } : void 0,
2682
- ...stored.resumeSessionId ? {
2683
- authorizationId: pluginAuthorizationId({
2684
- provider,
2685
- sessionId: stored.resumeSessionId
2686
- }),
2687
- turnId: stored.resumeSessionId
2688
- } : void 0
2689
- });
2690
- }, "oauth.callback.authentication_event.failed", { "app.credential.provider": provider });
2691
- if (stored.destination?.platform !== "local" && stored.source?.kind !== "web") waitUntil(async () => {
2692
- try {
2693
- await publishAppHomeView(getSlackClient(), stored.userId, userTokenStore);
2694
- } catch {}
2695
- });
2696
- const resumesWebTurn = Boolean(stored.source?.kind === "web" && stored.destination && stored.resumeConversationId && stored.resumeSessionId);
2697
- const resumesAgentTurn = Boolean(stored.destination?.platform !== "local" && stored.resumeConversationId && stored.resumeSessionId);
2698
- if (resumesWebTurn) waitUntil(async () => {
2699
- const turn = await getTurnRecord(stored.resumeConversationId, stored.resumeSessionId);
2700
- await deleteWebAuthorization({
2701
- actorId: stored.userId,
2702
- conversationId: stored.resumeConversationId
2703
- });
2704
- if (!turn || turn.state !== "paused" || turn.resumeReason !== "auth") return;
2705
- await recordAuthorizationCompleted({
2706
- conversationId: stored.resumeConversationId,
2707
- kind: "plugin",
2708
- provider: stored.provider,
2709
- actorId: stored.userId,
2710
- authorizationId: pluginAuthorizationId({
2711
- provider: stored.provider,
2712
- sessionId: stored.resumeSessionId
2713
- })
2714
- });
2715
- await wakePausedTurn({
2716
- conversationId: stored.resumeConversationId,
2717
- destination: stored.destination,
2718
- turnId: stored.resumeSessionId,
2719
- expectedVersion: turn.version
2720
- }, options.conversationWorkQueue ? { queue: options.conversationWorkQueue } : void 0);
2721
- });
2722
- else if (resumesAgentTurn) waitUntil(() => withLogContext({ conversationId: stored.resumeConversationId }, async () => {
2723
- try {
2724
- await resumeOAuthSessionRecordTurn(stored, options);
2725
- } catch (error) {
2726
- if (error instanceof ResumeTurnBusyError) {
2727
- logWarn("oauth.callback.resume.busy", {
2728
- "app.credential.provider": stored.provider,
2729
- ...stored.resumeSessionId && { "app.ai.session_id": stored.resumeSessionId }
2730
- });
2731
- return;
2732
- }
2733
- throw error;
2734
- }
2735
- }));
2736
- else if (stored.channelId && stored.threadTs) {
2737
- const { channelId, threadTs } = stored;
2738
- waitUntil(() => postSlackMessage({
2739
- channelId,
2740
- threadTs,
2741
- text: `Your ${providerLabel} account is now connected. You can start using ${providerLabel} commands.`
2742
- }));
2743
- }
2744
- const botName = botConfig.userName;
2745
- const statusMessage = stored.destination?.platform === "local" && !resumesWebTurn ? `Your request is continuing in the local ${botName} client.` : resumesWebTurn ? `Your request is continuing in ${botName}.` : resumesAgentTurn ? "Your request is being processed in Slack." : `Your ${providerLabel} account is connected.`;
2746
- const footerMessage = stored.destination?.platform === "local" || resumesWebTurn ? `You can close this tab and return to ${botName}.` : "You can close this tab and return to Slack.";
2747
- return htmlCallbackResponse(escapeXml(`${providerLabel} account connected`), escapeXml(statusMessage), 200, { footerMessage: escapeXml(footerMessage) });
2748
- }
2749
- //#endregion
2750
- //#region src/chat/local/credential-sync.ts
2751
- const LOCAL_CREDENTIAL_SYNC_CONTEXT = "junior.local-credential-sync.v1";
2752
- const LOCAL_CREDENTIAL_SYNC_MAX_AGE_MS = 6e4;
2753
- const localCredentialSyncSchema = z.object({
2754
- createdAtMs: z.number().finite(),
2755
- provider: z.string().regex(/^[a-z][a-z0-9-]*$/),
2756
- tokens: pluginStoredTokensSchema
2757
- }).strict();
2758
- function syncSecret() {
2759
- const secret = process.env.JUNIOR_SECRET?.trim();
2760
- if (!secret) throw new Error("Local credential sync requires JUNIOR_SECRET");
2761
- return secret;
2762
- }
2763
- /** Sign one local credential payload in its own HMAC domain. */
2764
- function signature(body) {
2765
- const signingKey = createSecretKey(Buffer.from(syncSecret(), "utf8"));
2766
- return createHmac("sha256", signingKey).update(`${LOCAL_CREDENTIAL_SYNC_CONTEXT}:${body}`).digest("base64url");
2767
- }
2768
- /** Compare credential signatures without leaking a matching prefix by timing. */
2769
- function signaturesMatch(expected, actual) {
2770
- const expectedBuffer = Buffer.from(expected);
2771
- const actualBuffer = Buffer.from(actual);
2772
- return expectedBuffer.length === actualBuffer.length && timingSafeEqual(expectedBuffer, actualBuffer);
2773
- }
2774
- /** Copy a local user's provider credential to the loopback dev-server broker. */
2775
- async function syncLocalOAuthCredential(provider, tokens) {
2776
- const body = JSON.stringify({
2777
- createdAtMs: Date.now(),
2778
- provider,
2779
- tokens
2780
- });
2781
- const port = process.env.PORT?.trim() || "3000";
2782
- const response = await fetch(`http://127.0.0.1:${port}/api/internal/local-oauth-credentials`, {
2783
- method: "POST",
2784
- headers: {
2785
- "content-type": "application/json",
2786
- "x-junior-local-credential-signature": signature(body)
2787
- },
2788
- body
2789
- });
2790
- if (!response.ok) throw new Error(`Could not sync local OAuth credential: HTTP ${response.status}`);
2791
- }
2792
- /** Verify and store one credential sent by the local CLI over loopback. */
2793
- async function receiveLocalOAuthCredential(request) {
2794
- const hostname = new URL(request.url).hostname;
2795
- if (process.env.NODE_ENV !== "development" || hostname !== "127.0.0.1" && hostname !== "localhost") return new Response(null, { status: 404 });
2796
- const body = await request.text();
2797
- const actualSignature = request.headers.get("x-junior-local-credential-signature");
2798
- if (!actualSignature || !signaturesMatch(signature(body), actualSignature)) return Response.json({ error: "Unauthorized" }, { status: 401 });
2799
- let value;
2800
- try {
2801
- value = JSON.parse(body);
2802
- } catch {
2803
- return Response.json({ error: "Invalid request" }, { status: 400 });
2804
- }
2805
- const payload = localCredentialSyncSchema.safeParse(value);
2806
- if (!payload.success || Math.abs(Date.now() - payload.data.createdAtMs) > LOCAL_CREDENTIAL_SYNC_MAX_AGE_MS) return Response.json({ error: "Invalid request" }, { status: 400 });
2807
- await createUserTokenStore().set("local-cli", payload.data.provider, payload.data.tokens);
2808
- return new Response(null, { status: 204 });
2809
- }
2810
- //#endregion
2811
- export { isSlackTitlePermissionError as C, createSlackAdapterAssistantStatusSession as D, renderSlackObjectCard as E, isRetryableSlackPostError as S, buildSlackOutputMessage as T, getThreadId as _, GET$1 as a, getSlackApiErrorCode as b, wakePausedTurn as c, shouldKeepProcessingReactionForToolInvocation as d, startProcessingReaction as f, getRunId as g, getMessageTs as h, publishAppHomeView as i, resumeSlackTurn as l, getChannelId as m, syncLocalOAuthCredential as n, createPausedTurns as o, getAssistantThreadContext as p, GET as r, getPausedTurnRequest as s, receiveLocalOAuthCredential as t, buildAuthPauseResponse as u, getThreadTs as v, sendSlackReply as w, getSlackErrorObservabilityAttributes as x, stripLeadingBotMention as y };