@agent-native/dispatch 0.13.12 → 0.14.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 (74) hide show
  1. package/dist/actions/delete-destination.d.ts +12 -12
  2. package/dist/actions/provider-api-audit.d.ts +7 -0
  3. package/dist/actions/provider-api-audit.d.ts.map +1 -0
  4. package/dist/actions/provider-api-audit.js +74 -0
  5. package/dist/actions/provider-api-audit.js.map +1 -0
  6. package/dist/actions/provider-api-catalog.d.ts +1 -1
  7. package/dist/actions/provider-api-request.js +10 -0
  8. package/dist/actions/provider-api-request.js.map +1 -1
  9. package/dist/actions/send-platform-message.d.ts +1 -0
  10. package/dist/actions/send-platform-message.js +43 -22
  11. package/dist/actions/send-platform-message.js.map +1 -1
  12. package/dist/components/dispatch-control-plane.d.ts.map +1 -1
  13. package/dist/components/dispatch-control-plane.js +1 -1
  14. package/dist/components/dispatch-control-plane.js.map +1 -1
  15. package/dist/components/messaging-setup-panel.d.ts.map +1 -1
  16. package/dist/components/messaging-setup-panel.js +203 -127
  17. package/dist/components/messaging-setup-panel.js.map +1 -1
  18. package/dist/hooks/use-navigation-state.d.ts.map +1 -1
  19. package/dist/hooks/use-navigation-state.js +1 -2
  20. package/dist/hooks/use-navigation-state.js.map +1 -1
  21. package/dist/routes/pages/$appId.d.ts.map +1 -1
  22. package/dist/routes/pages/$appId.js +3 -1
  23. package/dist/routes/pages/$appId.js.map +1 -1
  24. package/dist/routes/pages/apps.$appId.d.ts.map +1 -1
  25. package/dist/routes/pages/apps.$appId.js +2 -2
  26. package/dist/routes/pages/apps.$appId.js.map +1 -1
  27. package/dist/routes/pages/apps.d.ts.map +1 -1
  28. package/dist/routes/pages/apps.js +1 -3
  29. package/dist/routes/pages/apps.js.map +1 -1
  30. package/dist/routes/pages/messaging.js +1 -1
  31. package/dist/routes/pages/messaging.js.map +1 -1
  32. package/dist/routes/pages/team.js +1 -1
  33. package/dist/routes/pages/team.js.map +1 -1
  34. package/dist/server/lib/dispatch-integrations.d.ts +7 -1
  35. package/dist/server/lib/dispatch-integrations.d.ts.map +1 -1
  36. package/dist/server/lib/dispatch-integrations.js +178 -8
  37. package/dist/server/lib/dispatch-integrations.js.map +1 -1
  38. package/dist/server/lib/dispatch-routing.d.ts +6 -0
  39. package/dist/server/lib/dispatch-routing.d.ts.map +1 -0
  40. package/dist/server/lib/dispatch-routing.js +30 -0
  41. package/dist/server/lib/dispatch-routing.js.map +1 -0
  42. package/dist/server/lib/env-config.d.ts.map +1 -1
  43. package/dist/server/lib/env-config.js +50 -0
  44. package/dist/server/lib/env-config.js.map +1 -1
  45. package/dist/server/lib/onboarding-steps.d.ts.map +1 -1
  46. package/dist/server/lib/onboarding-steps.js +25 -0
  47. package/dist/server/lib/onboarding-steps.js.map +1 -1
  48. package/dist/server/lib/provider-api.d.ts +2 -2
  49. package/dist/server/lib/provider-api.d.ts.map +1 -1
  50. package/dist/server/plugins/integrations.d.ts.map +1 -1
  51. package/dist/server/plugins/integrations.js +5 -2
  52. package/dist/server/plugins/integrations.js.map +1 -1
  53. package/package.json +2 -2
  54. package/src/actions/provider-api-audit.spec.ts +52 -0
  55. package/src/actions/provider-api-audit.ts +88 -0
  56. package/src/actions/provider-api-request.ts +10 -0
  57. package/src/actions/send-platform-message.spec.ts +108 -0
  58. package/src/actions/send-platform-message.ts +73 -25
  59. package/src/components/dispatch-control-plane.tsx +1 -5
  60. package/src/components/messaging-setup-panel.spec.tsx +191 -0
  61. package/src/components/messaging-setup-panel.tsx +472 -193
  62. package/src/hooks/use-navigation-state.ts +1 -2
  63. package/src/routes/pages/$appId.tsx +3 -5
  64. package/src/routes/pages/apps.$appId.tsx +3 -7
  65. package/src/routes/pages/apps.tsx +0 -3
  66. package/src/routes/pages/messaging.tsx +1 -1
  67. package/src/routes/pages/team.tsx +1 -1
  68. package/src/server/lib/dispatch-integrations.spec.ts +113 -0
  69. package/src/server/lib/dispatch-integrations.ts +239 -6
  70. package/src/server/lib/dispatch-routing.spec.ts +42 -0
  71. package/src/server/lib/dispatch-routing.ts +42 -0
  72. package/src/server/lib/env-config.ts +50 -0
  73. package/src/server/lib/onboarding-steps.ts +32 -0
  74. package/src/server/plugins/integrations.ts +5 -2
@@ -62,7 +62,6 @@ export function useNavigationState(extensions?: DispatchExtensionConfig) {
62
62
  }
63
63
  return null;
64
64
  },
65
- refetchInterval: 2_000,
66
65
  structuralSharing: false,
67
66
  });
68
67
 
@@ -275,7 +274,7 @@ function resolvePath(
275
274
  case "threads":
276
275
  return "/thread-debug";
277
276
  case "team":
278
- return "/settings#team";
277
+ return "/settings#organization";
279
278
  case "extensions":
280
279
  return command?.extensionId
281
280
  ? `/extensions/${encodeURIComponent(command.extensionId)}`
@@ -96,11 +96,9 @@ export async function clientLoader({
96
96
  export default function WorkspaceAppCatchAllRoute() {
97
97
  const t = useT();
98
98
  const { appId } = useParams();
99
- const { data: apps = [], isLoading } = useActionQuery(
100
- "list-workspace-apps",
101
- { includeAgentCards: false },
102
- { refetchInterval: 2_000 },
103
- );
99
+ const { data: apps = [], isLoading } = useActionQuery("list-workspace-apps", {
100
+ includeAgentCards: false,
101
+ });
104
102
  const app = useMemo(
105
103
  () =>
106
104
  (apps as WorkspaceAppSummary[]).find((item) => item.id === appId) ?? null,
@@ -23,13 +23,9 @@ export function meta() {
23
23
  export default function WorkspaceAppRoute() {
24
24
  const t = useT();
25
25
  const { appId } = useParams();
26
- const { data: apps = [], isLoading } = useActionQuery(
27
- "list-workspace-apps",
28
- { includeAgentCards: false },
29
- {
30
- refetchInterval: 2_000,
31
- },
32
- );
26
+ const { data: apps = [], isLoading } = useActionQuery("list-workspace-apps", {
27
+ includeAgentCards: false,
28
+ });
33
29
  const app = useMemo(
34
30
  () =>
35
31
  (apps as WorkspaceAppSummary[]).find((item) => item.id === appId) ?? null,
@@ -78,9 +78,6 @@ export default function AppsRoute() {
78
78
  const { data: apps = [], isLoading: appsLoading } = useActionQuery(
79
79
  "list-workspace-apps",
80
80
  { includeAgentCards: false, includeArchived: true },
81
- {
82
- refetchInterval: 2_000,
83
- },
84
81
  );
85
82
  const { data: workspace } = useActionQuery(
86
83
  "get-workspace-info",
@@ -9,7 +9,7 @@ export default function MessagingRoute() {
9
9
  return (
10
10
  <DispatchShell
11
11
  title="Messaging"
12
- description="Connect Slack and Telegram directly in dispatch so inbound conversations come through one place."
12
+ description="Connect Slack, Microsoft Teams, Discord interactions, Telegram, WhatsApp Cloud API, or provider-webhook email so supported inbound conversations reach one Dispatch inbox."
13
13
  >
14
14
  <MessagingSetupPanel />
15
15
  </DispatchShell>
@@ -5,5 +5,5 @@ export function meta() {
5
5
  }
6
6
 
7
7
  export default function TeamRoute() {
8
- return <Navigate to="/settings#team" replace />;
8
+ return <Navigate to="/settings#organization" replace />;
9
9
  }
@@ -4,6 +4,7 @@ const mocks = vi.hoisted(() => ({
4
4
  consumeLinkToken: vi.fn(),
5
5
  resolveLinkedOwner: vi.fn(),
6
6
  resolveOrgIdForEmail: vi.fn(),
7
+ resolveSecret: vi.fn(),
7
8
  }));
8
9
 
9
10
  vi.mock("./dispatch-store.js", () => ({
@@ -15,6 +16,16 @@ vi.mock("@agent-native/core/org", () => ({
15
16
  resolveOrgIdForEmail: mocks.resolveOrgIdForEmail,
16
17
  }));
17
18
 
19
+ vi.mock("@agent-native/core/server", async () => {
20
+ const actual = await vi.importActual<
21
+ typeof import("@agent-native/core/server")
22
+ >("@agent-native/core/server");
23
+ return {
24
+ ...actual,
25
+ resolveSecret: mocks.resolveSecret,
26
+ };
27
+ });
28
+
18
29
  import type {
19
30
  IncomingMessage,
20
31
  PlatformAdapter,
@@ -24,6 +35,7 @@ import {
24
35
  beforeDispatchProcess,
25
36
  identityKeyForIncoming,
26
37
  resolveDispatchOwner,
38
+ resolveDispatchExecutionContext,
27
39
  } from "./dispatch-integrations.js";
28
40
 
29
41
  const originalFetch = globalThis.fetch;
@@ -94,6 +106,9 @@ beforeEach(() => {
94
106
  mocks.resolveLinkedOwner.mockResolvedValue(null);
95
107
  mocks.consumeLinkToken.mockResolvedValue("owner@example.test");
96
108
  mocks.resolveOrgIdForEmail.mockResolvedValue(null);
109
+ mocks.resolveSecret.mockImplementation(
110
+ async (key: string) => process.env[key] ?? null,
111
+ );
97
112
  vi.stubGlobal(
98
113
  "fetch",
99
114
  vi.fn(async () => new Response(JSON.stringify({ ok: false }))),
@@ -130,6 +145,7 @@ describe("resolveDispatchOwner", () => {
130
145
 
131
146
  it("uses the verified Slack email for org members", async () => {
132
147
  vi.stubEnv("SLACK_BOT_TOKEN", "xoxb-token");
148
+ mocks.resolveSecret.mockResolvedValueOnce(null);
133
149
  mocks.resolveOrgIdForEmail.mockResolvedValueOnce("org_123");
134
150
  vi.mocked(globalThis.fetch).mockResolvedValueOnce(
135
151
  new Response(
@@ -153,6 +169,37 @@ describe("resolveDispatchOwner", () => {
153
169
  expect(incoming.platformContext.senderEmail).toBe("user@example.test");
154
170
  });
155
171
 
172
+ it("uses the request-scoped Slack token when no env token exists", async () => {
173
+ mocks.resolveSecret.mockResolvedValueOnce("configured-slack-token");
174
+ mocks.resolveOrgIdForEmail.mockResolvedValueOnce("org_123");
175
+ vi.mocked(globalThis.fetch).mockResolvedValueOnce(
176
+ new Response(
177
+ JSON.stringify({
178
+ ok: true,
179
+ user: {
180
+ profile: { email: "member@example.test", display_name: "Member" },
181
+ },
182
+ }),
183
+ ),
184
+ );
185
+
186
+ await expect(
187
+ resolveDispatchOwner(
188
+ slackIncoming({
189
+ senderId: "U999",
190
+ platformContext: { teamId: "T999", channelId: "C1" },
191
+ }),
192
+ ),
193
+ ).resolves.toBe("member@example.test");
194
+ expect(mocks.resolveSecret).toHaveBeenCalledWith("SLACK_BOT_TOKEN");
195
+ expect(globalThis.fetch).toHaveBeenCalledWith(
196
+ "https://slack.com/api/users.info?user=U999",
197
+ {
198
+ headers: { Authorization: "Bearer configured-slack-token" },
199
+ },
200
+ );
201
+ });
202
+
156
203
  it("falls back to the configured Slack owner when the sender is not an org member", async () => {
157
204
  vi.stubEnv("SLACK_BOT_TOKEN", "xoxb-token");
158
205
  vi.stubEnv("DISPATCH_DEFAULT_OWNER_EMAIL", "default@example.test");
@@ -226,6 +273,20 @@ describe("resolveDispatchOwner", () => {
226
273
  });
227
274
 
228
275
  describe("beforeDispatchProcess", () => {
276
+ it("attaches capability-based guidance for structured intake", async () => {
277
+ const incoming = slackIncoming({
278
+ text: "File this review request using our intake form",
279
+ });
280
+
281
+ await expect(beforeDispatchProcess(incoming, noopAdapter)).resolves.toEqual(
282
+ { handled: false },
283
+ );
284
+ expect((incoming as any).routingHint.targetAgent).toBeUndefined();
285
+ expect((incoming as any).routingHint.instruction).toContain(
286
+ "workspace instructions/resources",
287
+ );
288
+ });
289
+
229
290
  it("asks unlinked Telegram users to link before using org context", async () => {
230
291
  vi.stubEnv("APP_URL", "https://dispatch.agent-native.test");
231
292
 
@@ -274,4 +335,56 @@ describe("beforeDispatchProcess", () => {
274
335
  externalUserName: "Steve",
275
336
  });
276
337
  });
338
+
339
+ it("replies with linking guidance instead of silently dropping an unlinked Slack DM", async () => {
340
+ vi.stubEnv("APP_URL", "https://dispatch.agent-native.test");
341
+ const incoming = slackIncoming({
342
+ triggerKind: "dm",
343
+ conversationType: "dm",
344
+ platformContext: {
345
+ teamId: "T123",
346
+ channelId: "D123",
347
+ channelType: "im",
348
+ },
349
+ });
350
+
351
+ const execution = await resolveDispatchExecutionContext(incoming);
352
+ const result = await beforeDispatchProcess(incoming, noopAdapter);
353
+
354
+ expect(execution.ownerEmail).toMatch(/@integration\.local$/);
355
+ expect(incoming.platformContext.identityLinkRequired).toBe(true);
356
+ expect(result).toEqual({
357
+ handled: true,
358
+ responseText:
359
+ "Agent Native is ready, but this Slack account is not linked to an Agent Native user yet. Open https://dispatch.agent-native.test/identities, create a Slack link token, then send `/link <token>` in this DM.",
360
+ });
361
+ });
362
+
363
+ it("lets an unlinked Slack DM consume a link token before the agent gate", async () => {
364
+ const incoming = slackIncoming({
365
+ text: "/link token-123",
366
+ triggerKind: "dm",
367
+ conversationType: "dm",
368
+ platformContext: {
369
+ teamId: "T123",
370
+ channelId: "D123",
371
+ channelType: "im",
372
+ },
373
+ });
374
+
375
+ await resolveDispatchExecutionContext(incoming);
376
+ const result = await beforeDispatchProcess(incoming, noopAdapter);
377
+
378
+ expect(result).toEqual({
379
+ handled: true,
380
+ responseText:
381
+ "Linked successfully. Future slack messages will use owner@example.test's personal dispatch context.",
382
+ });
383
+ expect(mocks.consumeLinkToken).toHaveBeenCalledWith({
384
+ platform: "slack",
385
+ token: "token-123",
386
+ externalUserId: "T123:U123",
387
+ externalUserName: "U123",
388
+ });
389
+ });
277
390
  });
@@ -1,18 +1,36 @@
1
1
  import crypto from "node:crypto";
2
2
 
3
+ import {
4
+ evaluateIntegrationScopePolicy,
5
+ getActiveIntegrationInstallationByKey,
6
+ getIntegrationScope,
7
+ resolveIntegrationTokenBundle,
8
+ saveIntegrationScope,
9
+ slackInstallationKey,
10
+ } from "@agent-native/core/integrations";
3
11
  import { resolveOrgIdForEmail } from "@agent-native/core/org";
4
- import { withConfiguredAppBasePath } from "@agent-native/core/server";
12
+ import {
13
+ readDeployCredentialEnv,
14
+ resolveSecret,
15
+ withConfiguredAppBasePath,
16
+ } from "@agent-native/core/server";
5
17
  import type {
6
18
  IncomingMessage,
19
+ IntegrationExecutionContext,
7
20
  PlatformAdapter,
8
21
  } from "@agent-native/core/server";
9
22
 
10
23
  import { handleRemoteCodeCommand } from "./dispatch-remote-commands.js";
24
+ import {
25
+ dispatchIntegrationRoutingHint,
26
+ type DispatchIntegrationRoutingHint,
27
+ } from "./dispatch-routing.js";
11
28
  import { consumeLinkToken, resolveLinkedOwner } from "./dispatch-store.js";
12
29
 
13
30
  type SlackSenderProfile = {
14
31
  email: string | null;
15
32
  name: string | null;
33
+ trust: "trusted" | "guest" | "external_shared" | "unknown";
16
34
  };
17
35
 
18
36
  const slackProfileCache = new Map<
@@ -104,6 +122,33 @@ function configuredDispatchIdentitiesUrl(): string | null {
104
122
  }
105
123
  }
106
124
 
125
+ async function resolveManagedSlackInstallation(incoming: IncomingMessage) {
126
+ if (incoming.platform !== "slack") return null;
127
+ const teamId = contextString(incoming.platformContext.teamId);
128
+ const apiAppId = contextString(incoming.platformContext.apiAppId);
129
+ if (!teamId) return null;
130
+ try {
131
+ return await getActiveIntegrationInstallationByKey(
132
+ "slack",
133
+ slackInstallationKey({ teamId, apiAppId }),
134
+ );
135
+ } catch {
136
+ return null;
137
+ }
138
+ }
139
+
140
+ async function resolveManagedSlackToken(
141
+ incoming: IncomingMessage,
142
+ ): Promise<string | null> {
143
+ const installation = await resolveManagedSlackInstallation(incoming);
144
+ if (!installation) return null;
145
+ const bundle = await resolveIntegrationTokenBundle(
146
+ "slack",
147
+ installation.installationKey,
148
+ );
149
+ return bundle?.accessToken ?? null;
150
+ }
151
+
107
152
  function formatTelegramLinkRequiredMessage(): string {
108
153
  const identitiesUrl = configuredDispatchIdentitiesUrl();
109
154
  const linkStep = identitiesUrl
@@ -112,14 +157,30 @@ function formatTelegramLinkRequiredMessage(): string {
112
157
  return `Telegram is connected, but this Telegram account is not linked to an Agent-Native user yet. ${linkStep} After that I can use your Builder.io org and connected apps.`;
113
158
  }
114
159
 
160
+ function formatSlackLinkRequiredMessage(): string {
161
+ const identitiesUrl = configuredDispatchIdentitiesUrl();
162
+ const linkStep = identitiesUrl
163
+ ? `Open ${identitiesUrl}, create a Slack link token, then send \`/link <token>\` in this DM.`
164
+ : "Open Dispatch while signed in, create a Slack link token, then send `/link <token>` in this DM.";
165
+ return `Agent Native is ready, but this Slack account is not linked to an Agent Native user yet. ${linkStep}`;
166
+ }
167
+
115
168
  async function resolveSlackSenderProfile(
116
169
  incoming: IncomingMessage,
117
170
  ): Promise<SlackSenderProfile> {
118
- if (incoming.platform !== "slack") return { email: null, name: null };
119
- const token = process.env.SLACK_BOT_TOKEN;
171
+ if (incoming.platform !== "slack") {
172
+ return { email: null, name: null, trust: "unknown" };
173
+ }
174
+ const managedToken = await resolveManagedSlackToken(incoming);
175
+ const token =
176
+ managedToken ??
177
+ (await resolveSecret("SLACK_BOT_TOKEN")) ??
178
+ readDeployCredentialEnv("SLACK_BOT_TOKEN");
120
179
  const userId = contextString(incoming.senderId);
121
180
  const teamId = contextString(incoming.platformContext.teamId);
122
- if (!token || !userId) return { email: null, name: null };
181
+ if (!token || !userId) {
182
+ return { email: null, name: null, trust: "unknown" };
183
+ }
123
184
 
124
185
  // Slack user IDs are scoped per workspace, so without a teamId we can't
125
186
  // safely cache: two installs of the bot in different workspaces could
@@ -146,6 +207,8 @@ async function resolveSlackSenderProfile(
146
207
  real_name?: string;
147
208
  display_name?: string;
148
209
  };
210
+ is_restricted?: boolean;
211
+ is_ultra_restricted?: boolean;
149
212
  };
150
213
  };
151
214
  const profile = data.ok
@@ -157,8 +220,14 @@ async function resolveSlackSenderProfile(
157
220
  data.user?.real_name?.trim() ||
158
221
  data.user?.name?.trim() ||
159
222
  null,
223
+ trust:
224
+ data.user?.is_ultra_restricted === true
225
+ ? ("external_shared" as const)
226
+ : data.user?.is_restricted === true
227
+ ? ("guest" as const)
228
+ : ("trusted" as const),
160
229
  }
161
- : { email: null, name: null };
230
+ : { email: null, name: null, trust: "unknown" as const };
162
231
  if (cacheKey) {
163
232
  slackProfileCache.set(cacheKey, {
164
233
  profile,
@@ -167,7 +236,7 @@ async function resolveSlackSenderProfile(
167
236
  }
168
237
  return profile;
169
238
  } catch {
170
- return { email: null, name: null };
239
+ return { email: null, name: null, trust: "unknown" };
171
240
  }
172
241
  }
173
242
 
@@ -188,6 +257,51 @@ async function resolveSlackOwnerFromVerifiedEmail(
188
257
  return orgId ? profile.email : null;
189
258
  }
190
259
 
260
+ async function resolveSlackConversationTrust(
261
+ incoming: IncomingMessage,
262
+ actorTrust: SlackSenderProfile["trust"],
263
+ ): Promise<{
264
+ trust: SlackSenderProfile["trust"];
265
+ conversationType: "channel" | "direct_message" | "group_direct_message";
266
+ }> {
267
+ if (incoming.triggerKind === "dm") {
268
+ return { trust: actorTrust, conversationType: "direct_message" };
269
+ }
270
+ const token = await resolveManagedSlackToken(incoming);
271
+ const channel = contextString(incoming.platformContext.channelId);
272
+ if (!token || !channel) {
273
+ return { trust: "unknown", conversationType: "channel" };
274
+ }
275
+ try {
276
+ const params = new URLSearchParams({ channel });
277
+ const response = await fetch(
278
+ `https://slack.com/api/conversations.info?${params}`,
279
+ { headers: { Authorization: `Bearer ${token}` } },
280
+ );
281
+ const data = (await response.json()) as {
282
+ ok?: boolean;
283
+ channel?: {
284
+ is_ext_shared?: boolean;
285
+ is_im?: boolean;
286
+ is_mpim?: boolean;
287
+ };
288
+ };
289
+ if (!data.ok) {
290
+ return { trust: "unknown", conversationType: "channel" };
291
+ }
292
+ return {
293
+ trust: data.channel?.is_ext_shared ? "external_shared" : actorTrust,
294
+ conversationType: data.channel?.is_im
295
+ ? "direct_message"
296
+ : data.channel?.is_mpim
297
+ ? "group_direct_message"
298
+ : "channel",
299
+ };
300
+ } catch {
301
+ return { trust: "unknown", conversationType: "channel" };
302
+ }
303
+ }
304
+
191
305
  export async function resolveDispatchOwner(
192
306
  incoming: IncomingMessage,
193
307
  ): Promise<string> {
@@ -254,6 +368,111 @@ export async function resolveDispatchOwner(
254
368
  }
255
369
  }
256
370
 
371
+ /**
372
+ * Resolve a personal DM user or a workspace-qualified channel service
373
+ * principal. Managed Slack channels never silently inherit one requester's
374
+ * personal credentials or memory.
375
+ */
376
+ export async function resolveDispatchExecutionContext(
377
+ incoming: IncomingMessage,
378
+ ): Promise<IntegrationExecutionContext> {
379
+ if (incoming.platform !== "slack" || incoming.triggerKind === "dm") {
380
+ const ownerEmail = await resolveDispatchOwner(incoming);
381
+ if (
382
+ incoming.platform === "slack" &&
383
+ ownerEmail.endsWith("@integration.local")
384
+ ) {
385
+ // Preserve a credential-less principal long enough for beforeProcess to
386
+ // deliver linking guidance or consume `/link <token>`. Never run the
387
+ // agent under this synthetic owner.
388
+ incoming.platformContext.identityLinkRequired = true;
389
+ }
390
+ return {
391
+ ownerEmail,
392
+ orgId: (await resolveOrgIdForEmail(ownerEmail)) ?? null,
393
+ principalType: "user",
394
+ };
395
+ }
396
+
397
+ const installation = await resolveManagedSlackInstallation(incoming);
398
+ if (!installation) {
399
+ // Preserve the legacy manually configured app path while managed installs
400
+ // roll out. Its behavior remains explicit and visible in Settings.
401
+ const ownerEmail = await resolveDispatchOwner(incoming);
402
+ return {
403
+ ownerEmail,
404
+ orgId: (await resolveOrgIdForEmail(ownerEmail)) ?? null,
405
+ principalType: "user",
406
+ };
407
+ }
408
+
409
+ const teamId = contextString(incoming.platformContext.teamId);
410
+ const channelId = contextString(incoming.platformContext.channelId);
411
+ if (!teamId || !channelId) {
412
+ throw new Error("Slack channel identity is incomplete");
413
+ }
414
+ const profile = await resolveSlackSenderProfile(incoming);
415
+ const conversation = await resolveSlackConversationTrust(
416
+ incoming,
417
+ profile.trust,
418
+ );
419
+ const access = {
420
+ ownerEmail: installation.ownerEmail,
421
+ orgId: installation.orgId,
422
+ };
423
+ const key = {
424
+ platform: "slack",
425
+ tenantId: teamId,
426
+ conversationId: channelId,
427
+ };
428
+ let scope = await getIntegrationScope(key, access);
429
+ if (!scope && incoming.triggerKind === "mention") {
430
+ scope = await saveIntegrationScope(
431
+ {
432
+ ...key,
433
+ conversationType: conversation.conversationType,
434
+ trust: conversation.trust,
435
+ orgId: installation.orgId,
436
+ installationId: installation.id,
437
+ },
438
+ access,
439
+ );
440
+ }
441
+ if (!scope) throw new Error("Slack channel is not enabled for Agent Native");
442
+ const decision = evaluateIntegrationScopePolicy(scope, {
443
+ // Thread replies reached this point only after the adapter's active-task
444
+ // gate, so they are continuation steering rather than ambient chatter.
445
+ mentioned:
446
+ incoming.triggerKind === "mention" ||
447
+ incoming.triggerKind === "thread_reply",
448
+ });
449
+ if (!decision.allowed) {
450
+ throw new Error(`Slack channel policy denied: ${decision.reason}`);
451
+ }
452
+ incoming.integrationScopeId = scope.id;
453
+ if (scope.defaultModel) {
454
+ incoming.platformContext.defaultModel = scope.defaultModel;
455
+ }
456
+ incoming.actorTrust = {
457
+ memberType:
458
+ conversation.trust === "guest"
459
+ ? "guest"
460
+ : conversation.trust === "external_shared"
461
+ ? "external"
462
+ : conversation.trust === "trusted"
463
+ ? "member"
464
+ : "unknown",
465
+ verified: conversation.trust !== "unknown",
466
+ };
467
+ return {
468
+ ownerEmail: scope.serviceOwnerEmail,
469
+ orgId: scope.orgId,
470
+ principalType: "service",
471
+ installationId: installation.id,
472
+ scopeId: scope.id,
473
+ };
474
+ }
475
+
257
476
  export async function beforeDispatchProcess(
258
477
  incoming: IncomingMessage,
259
478
  adapter: PlatformAdapter,
@@ -263,6 +482,20 @@ export async function beforeDispatchProcess(
263
482
  contextString(incoming.platformContext.rawText) || trimmed;
264
483
  const match = commandText.match(/^\/link(?:@\w+)?\s+([a-zA-Z0-9_-]+)$/);
265
484
  if (!match) {
485
+ const routedIncoming = incoming as IncomingMessage & {
486
+ routingHint?: DispatchIntegrationRoutingHint;
487
+ };
488
+ routedIncoming.routingHint ??= dispatchIntegrationRoutingHint(trimmed);
489
+ if (
490
+ incoming.platform === "slack" &&
491
+ incoming.triggerKind === "dm" &&
492
+ incoming.platformContext.identityLinkRequired === true
493
+ ) {
494
+ return {
495
+ handled: true,
496
+ responseText: formatSlackLinkRequiredMessage(),
497
+ };
498
+ }
266
499
  if (platformRequiresExplicitLink(incoming)) {
267
500
  const owner = await resolveLinkedOwner(
268
501
  incoming.platform,
@@ -0,0 +1,42 @@
1
+ import { describe, expect, it } from "vitest";
2
+
3
+ import { dispatchIntegrationRoutingHint } from "./dispatch-routing.js";
4
+
5
+ describe("dispatchIntegrationRoutingHint", () => {
6
+ it.each([
7
+ "File a security review request for the platform team",
8
+ "Add this hiring ask to the intake board",
9
+ "Create a vendor request form with the required fields",
10
+ "What is currently in the editorial requests queue?",
11
+ ])(
12
+ "resolves structured intake through workspace capabilities: %s",
13
+ (text) => {
14
+ const hint = dispatchIntegrationRoutingHint(text);
15
+ expect(hint?.targetAgent).toBeUndefined();
16
+ expect(hint?.instruction).toContain("workspace instructions/resources");
17
+ expect(hint?.instruction).toContain("do not assume a particular app");
18
+ },
19
+ );
20
+
21
+ it.each([
22
+ "Design a homepage for the launch",
23
+ "Create a visual mockup for this settings screen",
24
+ "Redesign the product UI",
25
+ ])("routes visual output to Design: %s", (text) => {
26
+ expect(dispatchIntegrationRoutingHint(text)).toMatchObject({
27
+ targetAgent: "design",
28
+ });
29
+ });
30
+
31
+ it("lets unrelated domain questions use normal agent discovery", () => {
32
+ expect(
33
+ dispatchIntegrationRoutingHint(
34
+ "What were the reasons for closed-lost deals this quarter?",
35
+ ),
36
+ ).toBeUndefined();
37
+ });
38
+
39
+ it("leaves organization-specific shorthand to learned workspace instructions", () => {
40
+ expect(dispatchIntegrationRoutingHint("Apoorva queue")).toBeUndefined();
41
+ });
42
+ });
@@ -0,0 +1,42 @@
1
+ const STRUCTURED_INTAKE_PATTERNS = [
2
+ /\b(?:file|submit|add|log|track|triage|prioriti[sz]e)\b.{0,64}\b(?:asks?|requests?|tickets?|intake)\b/i,
3
+ /\b(?:asks?|requests?|tickets?|intake)\b.{0,64}\b(?:database|table|board|form|queue|priority|deadline|urgency)\b/i,
4
+ /\b(?:database|table|board|form|queue)\b.{0,64}\b(?:asks?|requests?|tickets?|intake|priority|deadline|urgency)\b/i,
5
+ ];
6
+
7
+ const VISUAL_DESIGN_PATTERNS = [
8
+ /\b(?:design|redesign|create|make|generate|mock(?:\s+up)?)\b.{0,64}\b(?:visual|mockup|wireframe|screen|interface|ui|website|landing\s+page|homepage|logo|graphic|illustration)\b/i,
9
+ /\b(?:visual|ui|website|product|brand)\s+design\b/i,
10
+ ];
11
+
12
+ export interface DispatchIntegrationRoutingHint {
13
+ targetAgent?: string;
14
+ instruction: string;
15
+ }
16
+
17
+ export function dispatchIntegrationRoutingHint(
18
+ text: string,
19
+ ): DispatchIntegrationRoutingHint | undefined {
20
+ const normalized = text.replace(/\s+/g, " ").trim();
21
+ if (!normalized) return undefined;
22
+
23
+ // Route by the requested artifact type, not organization-specific names.
24
+ // Exact destinations, schemas, and required fields come from workspace
25
+ // resources such as shared LEARNINGS.md rather than this classifier.
26
+ if (STRUCTURED_INTAKE_PATTERNS.some((pattern) => pattern.test(normalized))) {
27
+ return {
28
+ instruction:
29
+ "Resolve this structured-intake request from loaded workspace instructions/resources and discovered app capabilities. Follow any workspace-defined canonical destination and form contract; do not assume a particular app, database, schema, or owner. Preserve the source thread URL, submit once, verify the saved record, and return its exact link.",
30
+ };
31
+ }
32
+
33
+ if (VISUAL_DESIGN_PATTERNS.some((pattern) => pattern.test(normalized))) {
34
+ return {
35
+ targetAgent: "design",
36
+ instruction:
37
+ "Delegate to Design because the requested output is a visual design, mockup, or interface rather than an intake record.",
38
+ };
39
+ }
40
+
41
+ return undefined;
42
+ }