@vellumai/assistant 0.12.0-staging.1 → 0.12.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 (92) hide show
  1. package/Dockerfile +5 -0
  2. package/docs/architecture/turn-actor.md +9 -0
  3. package/knip.json +1 -0
  4. package/node_modules/@vellumai/app-icons/package.json +18 -0
  5. package/node_modules/@vellumai/app-icons/src/index.test.ts +85 -0
  6. package/node_modules/@vellumai/app-icons/src/index.ts +387 -0
  7. package/node_modules/@vellumai/app-icons/tsconfig.json +20 -0
  8. package/node_modules/@vellumai/ces-client/node_modules/@vellumai/service-contracts/src/__tests__/channels.test.ts +31 -0
  9. package/node_modules/@vellumai/ces-client/node_modules/@vellumai/service-contracts/src/channels.ts +28 -3
  10. package/node_modules/@vellumai/gateway-client/node_modules/@vellumai/service-contracts/src/__tests__/channels.test.ts +31 -0
  11. package/node_modules/@vellumai/gateway-client/node_modules/@vellumai/service-contracts/src/channels.ts +28 -3
  12. package/node_modules/@vellumai/gateway-client/src/__tests__/plugin-admission-denied-contract.test.ts +10 -0
  13. package/node_modules/@vellumai/gateway-client/src/index.ts +1 -0
  14. package/node_modules/@vellumai/gateway-client/src/plugin-admission-denied-contract.ts +15 -2
  15. package/node_modules/@vellumai/service-contracts/src/__tests__/channels.test.ts +31 -0
  16. package/node_modules/@vellumai/service-contracts/src/channels.ts +28 -3
  17. package/openapi.yaml +628 -0
  18. package/package.json +3 -1
  19. package/scripts/smoke-container-workspace-dependencies.ts +19 -0
  20. package/src/__tests__/app-builder-icon-names.test.ts +29 -0
  21. package/src/__tests__/assistant-attachment-directive.test.ts +4 -0
  22. package/src/__tests__/conversation-agent-loop-inference-profile.test.ts +1 -0
  23. package/src/__tests__/conversation-agent-loop-overflow.test.ts +1 -0
  24. package/src/__tests__/conversation-agent-loop.test.ts +2 -0
  25. package/src/__tests__/conversation-attachments.test.ts +142 -0
  26. package/src/__tests__/conversation-delete-activation-progress.test.ts +145 -0
  27. package/src/__tests__/conversation-event-sink.test.ts +50 -0
  28. package/src/__tests__/conversation-process-app-control-preactivation.test.ts +10 -2
  29. package/src/__tests__/conversation-queue.test.ts +297 -0
  30. package/src/__tests__/credential-routes.test.ts +185 -6
  31. package/src/__tests__/drain-kick-guard.test.ts +2 -0
  32. package/src/__tests__/drain-requeue-on-contention.test.ts +6 -0
  33. package/src/__tests__/messaging-send-tool.test.ts +42 -0
  34. package/src/__tests__/oauth-commands-routes.test.ts +47 -0
  35. package/src/__tests__/subagent-manager-notify.test.ts +25 -4
  36. package/src/activation/progress-store.test.ts +1564 -0
  37. package/src/activation/progress-store.ts +1296 -0
  38. package/src/activation/turn-hooks.test.ts +246 -0
  39. package/src/activation/turn-hooks.ts +126 -0
  40. package/src/api/events/subagent-status-changed.ts +7 -5
  41. package/src/api/responses/activation.ts +136 -0
  42. package/src/apps/app-store.ts +8 -0
  43. package/src/cli/__tests__/catalog-search-help.test.ts +7 -5
  44. package/src/cli/commands/__tests__/cli-test-harness.ts +12 -3
  45. package/src/cli/commands/channels/__tests__/channels.test.ts +2 -0
  46. package/src/cli/commands/channels/__tests__/request.test.ts +191 -0
  47. package/src/cli/commands/channels/index.help.ts +70 -18
  48. package/src/cli/commands/channels/index.ts +17 -6
  49. package/src/cli/commands/channels/request.ts +66 -0
  50. package/src/cli/commands/oauth/request.test.ts +2 -0
  51. package/src/cli/commands/oauth/request.ts +227 -186
  52. package/src/config/bundled-skills/app-builder/SKILL.md +3 -1
  53. package/src/config/bundled-skills/app-builder/TOOLS.json +2 -2
  54. package/src/config/bundled-skills/messaging/tools/messaging-send.ts +8 -4
  55. package/src/config/feature-flag-registry.json +35 -5
  56. package/src/daemon/assistant-attachments.ts +11 -0
  57. package/src/daemon/conversation-agent-loop-handlers.ts +5 -0
  58. package/src/daemon/conversation-agent-loop.ts +71 -3
  59. package/src/daemon/conversation-attachments.ts +42 -1
  60. package/src/daemon/conversation-event-sink.ts +25 -0
  61. package/src/daemon/conversation-process.ts +69 -21
  62. package/src/daemon/conversation-store.ts +4 -3
  63. package/src/daemon/conversation-surfaces.ts +19 -4
  64. package/src/daemon/message-types/sync.ts +2 -0
  65. package/src/ipc/assistant-server.ts +2 -0
  66. package/src/ipc/routes/__tests__/activation-sync-ipc-routes.test.ts +51 -0
  67. package/src/ipc/routes/activation-sync-ipc-routes.ts +42 -0
  68. package/src/notifications/AGENTS.md +1 -1
  69. package/src/notifications/__tests__/proactive-home-thread.test.ts +111 -0
  70. package/src/notifications/conversation-pairing.ts +47 -5
  71. package/src/notifications/delivered-post-record.ts +3 -0
  72. package/src/persistence/__tests__/slack-thread-root-evidence.test.ts +102 -0
  73. package/src/persistence/conversation-crud.ts +39 -0
  74. package/src/persistence/delivery-crud.ts +13 -0
  75. package/src/plugins/AGENTS.md +1 -0
  76. package/src/runtime/auth/__tests__/route-policy.test.ts +37 -0
  77. package/src/runtime/routes/__tests__/user-routes-notices.test.ts +248 -0
  78. package/src/runtime/routes/activation-routes.test.ts +415 -0
  79. package/src/runtime/routes/activation-routes.ts +172 -0
  80. package/src/runtime/routes/credential-routes.ts +46 -3
  81. package/src/runtime/routes/index.ts +2 -0
  82. package/src/runtime/routes/oauth-commands-routes.ts +21 -8
  83. package/src/runtime/routes/platform-managed-credentials.ts +48 -0
  84. package/src/runtime/routes/secret-routes.ts +3 -12
  85. package/src/runtime/routes/user-route-resolution.ts +21 -0
  86. package/src/runtime/routes/user-routes.ts +112 -9
  87. package/src/runtime/sync/activation-sidecar-publish.test.ts +78 -0
  88. package/src/runtime/sync/documents-sidecar-publish.test.ts +3 -0
  89. package/src/runtime/sync/resource-sync-events.ts +23 -0
  90. package/src/runtime/sync/worker-daemon-notify.test.ts +36 -0
  91. package/src/runtime/sync/worker-daemon-notify.ts +39 -1
  92. package/src/tools/apps/executors.ts +8 -8
@@ -0,0 +1,111 @@
1
+ import { beforeAll, beforeEach, describe, expect, mock, test } from "bun:test";
2
+
3
+ // A proactive post into a thread must be recorded in the conversation the
4
+ // thread's replies resolve to, so the record and the replies meet. These
5
+ // cases run the real resolvers against the test workspace's database, so
6
+ // the home and the reply's resolution are compared, not restated. The sync
7
+ // publisher is observed, since a conversation minted here has to reach the
8
+ // conversation list on its own.
9
+
10
+ const listChanged = mock((_reason: string) => {});
11
+ const actualSync = await import("../../runtime/sync/resource-sync-events.js");
12
+ mock.module("../../runtime/sync/resource-sync-events.js", () => ({
13
+ ...actualSync,
14
+ publishConversationListChanged: listChanged,
15
+ }));
16
+
17
+ const { getOrCreateConversation } =
18
+ await import("../../persistence/conversation-key-store.js");
19
+ const { initializeDb } = await import("../../persistence/db-init.js");
20
+ const { buildScopedConversationKey, resolveInboundConversation } =
21
+ await import("../../persistence/delivery-crud.js");
22
+ const { resolveProactiveHomeConversation } =
23
+ await import("../conversation-pairing.js");
24
+
25
+ const homeParams = {
26
+ source: "notification",
27
+ conversationType: "background" as const,
28
+ title: "Messages to a chat",
29
+ };
30
+
31
+ beforeAll(async () => {
32
+ await initializeDb();
33
+ });
34
+
35
+ beforeEach(() => {
36
+ listChanged.mockClear();
37
+ });
38
+
39
+ describe("a post into a thread lives where the thread's replies arrive", () => {
40
+ test("telegram topic: the topic's own conversation, minted and announced when absent, found afterwards", async () => {
41
+ const home = await resolveProactiveHomeConversation({
42
+ ...homeParams,
43
+ sourceChannel: "telegram",
44
+ externalChatId: "555000111",
45
+ threadId: "42",
46
+ });
47
+ expect(home.createdNewConversation).toBe(true);
48
+ expect(listChanged).toHaveBeenCalledTimes(1);
49
+ expect(listChanged).toHaveBeenCalledWith("created");
50
+
51
+ const reply = resolveInboundConversation("telegram", "555000111", "42");
52
+ expect(reply.conversationId).toBe(home.conversationId);
53
+
54
+ listChanged.mockClear();
55
+ const again = await resolveProactiveHomeConversation({
56
+ ...homeParams,
57
+ sourceChannel: "telegram",
58
+ externalChatId: "555000111",
59
+ threadId: "42",
60
+ });
61
+ expect(again).toEqual({
62
+ conversationId: home.conversationId,
63
+ createdNewConversation: false,
64
+ });
65
+ expect(listChanged).not.toHaveBeenCalled();
66
+ });
67
+
68
+ test("slack thread with a conversation of its own: that conversation, never the flat channel's", async () => {
69
+ const flat = getOrCreateConversation(
70
+ buildScopedConversationKey("slack", "C0THREADED"),
71
+ );
72
+ const threaded = getOrCreateConversation(
73
+ buildScopedConversationKey("slack", "C0THREADED", "1700000000.000500"),
74
+ );
75
+ const home = await resolveProactiveHomeConversation({
76
+ ...homeParams,
77
+ sourceChannel: "slack",
78
+ externalChatId: "C0THREADED",
79
+ threadId: "1700000000.000500",
80
+ });
81
+ expect(home.conversationId).toBe(threaded.conversationId);
82
+ expect(home.conversationId).not.toBe(flat.conversationId);
83
+ expect(listChanged).not.toHaveBeenCalled();
84
+ });
85
+
86
+ test("a thread-less delivery keeps the chat-level home", async () => {
87
+ const first = await resolveProactiveHomeConversation({
88
+ ...homeParams,
89
+ sourceChannel: "telegram",
90
+ externalChatId: "555000222",
91
+ });
92
+ const second = await resolveProactiveHomeConversation({
93
+ ...homeParams,
94
+ sourceChannel: "telegram",
95
+ externalChatId: "555000222",
96
+ });
97
+ expect(first.createdNewConversation).toBe(true);
98
+ expect(listChanged).toHaveBeenCalledTimes(1);
99
+ expect(second).toEqual({
100
+ conversationId: first.conversationId,
101
+ createdNewConversation: false,
102
+ });
103
+ const topic = await resolveProactiveHomeConversation({
104
+ ...homeParams,
105
+ sourceChannel: "telegram",
106
+ externalChatId: "555000222",
107
+ threadId: "7",
108
+ });
109
+ expect(topic.conversationId).not.toBe(first.conversationId);
110
+ });
111
+ });
@@ -31,7 +31,7 @@
31
31
 
32
32
  import type { ConversationStrategy } from "../channels/config.js";
33
33
  import { getConversationStrategy } from "../channels/config.js";
34
- import type { ChannelId } from "../channels/types.js";
34
+ import { type ChannelId, isChannelId } from "../channels/types.js";
35
35
  import { isAssistantInitiatedThreadsEnabled } from "../config/assistant-initiated-threads-gate.js";
36
36
  import {
37
37
  addMessage,
@@ -42,11 +42,18 @@ import {
42
42
  ASSISTANT_INITIATED_SOURCE,
43
43
  type ConversationCreateType,
44
44
  } from "../persistence/conversation-types.js";
45
+ import {
46
+ findInboundConversationId,
47
+ resolveInboundConversation,
48
+ } from "../persistence/delivery-crud.js";
45
49
  import {
46
50
  getBindingByChannelChat,
47
51
  upsertOutboundBinding,
48
52
  } from "../persistence/external-conversation-store.js";
49
- import { publishConversationMessagesChanged } from "../runtime/sync/resource-sync-events.js";
53
+ import {
54
+ publishConversationListChanged,
55
+ publishConversationMessagesChanged,
56
+ } from "../runtime/sync/resource-sync-events.js";
50
57
  import { getLogger } from "../util/logger.js";
51
58
  import { withSqliteRetry } from "../util/sqlite-retry.js";
52
59
  import {
@@ -483,10 +490,17 @@ export async function pairDeliveryWithConversation(
483
490
 
484
491
  /**
485
492
  * Where a chat's proactive posts live: the conversation a delivery to
486
- * (`sourceChannel`, `externalChatId`) is recorded in once the channel
487
- * acknowledges it.
493
+ * (`sourceChannel`, `externalChatId`), and `threadId` when the post lands
494
+ * in a thread, is recorded in once the channel acknowledges it.
495
+ *
496
+ * A delivery into a thread lives where the thread's replies arrive: the
497
+ * conversation ingress resolves for that (channel, chat, thread), found,
498
+ * aliased from the flat channel where Slack's thread evidence allows, or
499
+ * minted the way ingress mints it on the first reply. Inbound conversations
500
+ * on Slack and Telegram are keyed per thread, so any other home would put
501
+ * the post in a conversation its replies never reach.
488
502
  *
489
- * Resolution order:
503
+ * Resolution order for a thread-less delivery:
490
504
  * 1. The chat's inbound conversation, when the person has messaged in this
491
505
  * chat and the inbound pipeline bound it at the un-prefixed key. Posting
492
506
  * there keeps the notification in the history the person's replies land
@@ -514,9 +528,36 @@ export async function resolveProactiveHomeConversation(params: {
514
528
  title: string;
515
529
  groupId?: string;
516
530
  scheduleJobId?: string;
531
+ /** The thread the post lands in, as the channel resolved it. */
532
+ threadId?: string | null;
517
533
  }): Promise<{ conversationId: string; createdNewConversation: boolean }> {
518
534
  const { sourceChannel, externalChatId } = params;
519
535
 
536
+ const threadId = params.threadId?.trim();
537
+ if (threadId) {
538
+ const existing = findInboundConversationId(
539
+ sourceChannel,
540
+ externalChatId,
541
+ threadId,
542
+ );
543
+ if (existing) {
544
+ return { conversationId: existing, createdNewConversation: false };
545
+ }
546
+ const minted = resolveInboundConversation(
547
+ sourceChannel,
548
+ externalChatId,
549
+ threadId,
550
+ isChannelId(sourceChannel) ? { origin: sourceChannel } : undefined,
551
+ );
552
+ // The record that follows invalidates only the conversation's messages;
553
+ // a conversation that did not exist a moment ago has to reach the list.
554
+ publishConversationListChanged("created");
555
+ return {
556
+ conversationId: minted.conversationId,
557
+ createdNewConversation: true,
558
+ };
559
+ }
560
+
520
561
  const inboundBinding = getBindingByChannelChat(sourceChannel, externalChatId);
521
562
  if (inboundBinding) {
522
563
  const inboundConversation = getConversation(inboundBinding.conversationId);
@@ -575,6 +616,7 @@ export async function resolveProactiveHomeConversation(params: {
575
616
  sourceChannel: notificationChannel(sourceChannel),
576
617
  externalChatId,
577
618
  });
619
+ publishConversationListChanged("created");
578
620
  return { conversationId: conversation.id, createdNewConversation: true };
579
621
  }
580
622
 
@@ -33,6 +33,8 @@ export interface DeliveredChannelPost {
33
33
  channel: ChannelId;
34
34
  /** Channel-native id of the chat the post landed in. */
35
35
  externalChatId: string;
36
+ /** The thread or topic the post landed in, absent when it is not in one. */
37
+ threadId?: string;
36
38
  /** The text the adapter sent, as the channel received it. */
37
39
  text: string;
38
40
  /** The message id the channel assigned when it acknowledged the post. */
@@ -56,6 +58,7 @@ export async function recordDeliveredChannelPost(
56
58
  const envelope: ProviderMessageMetadata = {
57
59
  source: post.channel,
58
60
  conversationExternalId: post.externalChatId,
61
+ ...(post.threadId ? { threadId: post.threadId } : {}),
59
62
  eventKind: "message",
60
63
  };
61
64
  const row = await addMessage(post.conversationId, "assistant", post.text, {
@@ -0,0 +1,102 @@
1
+ import { beforeAll, describe, expect, test } from "bun:test";
2
+
3
+ import { addMessage } from "../conversation-crud.js";
4
+ import { getOrCreateConversation } from "../conversation-key-store.js";
5
+ import { initializeDb } from "../db-init.js";
6
+ import {
7
+ buildScopedConversationKey,
8
+ recordOutboundPost,
9
+ resolveInboundConversation,
10
+ } from "../delivery-crud.js";
11
+
12
+ // A Slack thread may continue the conversation that lives on the flat
13
+ // channel key. The evidence for that includes the thread's root being a
14
+ // post the assistant made from that conversation, resolved through the
15
+ // outbound-post index the post-send reconciliation writes, so a reply under
16
+ // the assistant's own top-level post lands where the post is recorded.
17
+
18
+ beforeAll(async () => {
19
+ await initializeDb();
20
+ });
21
+
22
+ /** Record a delivered post the way the post-send reconciliation does. */
23
+ async function recordFlatPost(
24
+ chatId: string,
25
+ providerMessageIds: readonly string[],
26
+ ): Promise<{ conversationId: string }> {
27
+ const flat = getOrCreateConversation(
28
+ buildScopedConversationKey("slack", chatId),
29
+ );
30
+ const [firstId] = providerMessageIds;
31
+ const row = await addMessage(flat.conversationId, "assistant", "posted", {
32
+ skipIndexing: true,
33
+ metadata: {
34
+ providerMeta: JSON.stringify({
35
+ source: "slack",
36
+ conversationExternalId: chatId,
37
+ messageId: firstId,
38
+ eventKind: "message",
39
+ }),
40
+ },
41
+ });
42
+ for (const providerMessageId of providerMessageIds) {
43
+ recordOutboundPost({
44
+ sourceChannel: "slack",
45
+ externalChatId: chatId,
46
+ providerMessageId,
47
+ messageId: row.id,
48
+ conversationId: flat.conversationId,
49
+ });
50
+ }
51
+ return flat;
52
+ }
53
+
54
+ describe("slack thread evidence", () => {
55
+ test("a thread rooted at the assistant's own flat post continues the flat conversation", async () => {
56
+ const flat = await recordFlatPost("C0ROOTED", ["1700000000.000900"]);
57
+ const reply = resolveInboundConversation(
58
+ "slack",
59
+ "C0ROOTED",
60
+ "1700000000.000900",
61
+ );
62
+ expect(reply.conversationId).toBe(flat.conversationId);
63
+ });
64
+
65
+ test("a thread rooted at a later chunk of a split post continues it too", async () => {
66
+ const flat = await recordFlatPost("C0SPLIT", [
67
+ "1700000000.000910",
68
+ "1700000000.000911",
69
+ ]);
70
+ const reply = resolveInboundConversation(
71
+ "slack",
72
+ "C0SPLIT",
73
+ "1700000000.000911",
74
+ );
75
+ expect(reply.conversationId).toBe(flat.conversationId);
76
+ });
77
+
78
+ test("a post recorded for another chat is not evidence for this one", async () => {
79
+ await recordFlatPost("C0SOMEWHEREELSE", ["1700000000.000902"]);
80
+ const flat = getOrCreateConversation(
81
+ buildScopedConversationKey("slack", "C0OTHERCHAT"),
82
+ );
83
+ const reply = resolveInboundConversation(
84
+ "slack",
85
+ "C0OTHERCHAT",
86
+ "1700000000.000902",
87
+ );
88
+ expect(reply.conversationId).not.toBe(flat.conversationId);
89
+ });
90
+
91
+ test("a thread with no evidence in the flat conversation is its own conversation", async () => {
92
+ const flat = getOrCreateConversation(
93
+ buildScopedConversationKey("slack", "C0UNRELATED"),
94
+ );
95
+ const reply = resolveInboundConversation(
96
+ "slack",
97
+ "C0UNRELATED",
98
+ "1700000000.000901",
99
+ );
100
+ expect(reply.conversationId).not.toBe(flat.conversationId);
101
+ });
102
+ });
@@ -22,6 +22,10 @@ import {
22
22
  import { v4 as uuid, v7 as uuidv7 } from "uuid";
23
23
  import { z } from "zod";
24
24
 
25
+ import {
26
+ forgetActivationConversation,
27
+ forgetAllActivationConversations,
28
+ } from "../activation/progress-store.js";
25
29
  import type { ChannelId, InterfaceId } from "../channels/types.js";
26
30
  import { parseChannelId, parseInterfaceId } from "../channels/types.js";
27
31
  import { CHANNEL_IDS, isChannelId } from "../channels/types.js";
@@ -228,6 +232,29 @@ function purgeWatchTimelineForDeletedConversation(id: string): void {
228
232
  }
229
233
  }
230
234
 
235
+ /**
236
+ * Release a deleted conversation from the activation checklist.
237
+ *
238
+ * The checklist records which task was launched into which conversation in a
239
+ * workspace file the cascade never reaches, so a delete that skipped this
240
+ * would leave the task's row stuck on Working, pointing at a conversation the
241
+ * user can no longer open and offering no way to launch the task again. A
242
+ * finished task keeps its record and only loses the dead link.
243
+ *
244
+ * Fired from the shared primitive so every delete caller cleans up, and
245
+ * best-effort for the same reason as the rest of the post-transaction
246
+ * cleanup: the conversation row is already gone, so throwing here would turn
247
+ * a completed delete into an error the caller cannot usefully retry.
248
+ */
249
+ function forgetActivationLinkForDeletedConversation(id: string): void {
250
+ void forgetActivationConversation(id).catch((err: unknown) => {
251
+ log.warn(
252
+ { err, conversationId: id },
253
+ "Failed to release a deleted conversation from the activation checklist",
254
+ );
255
+ });
256
+ }
257
+
231
258
  function deletePendingTelemetryEventsForConversation(id: string): void {
232
259
  const telemetry = getTelemetryDb({ createIfMissing: false });
233
260
  if (!telemetry || !dedicatedTableExists(telemetry, "telemetry_events")) {
@@ -2307,6 +2334,7 @@ export function deleteConversation(id: string): DeletedMemoryIds {
2307
2334
  }
2308
2335
 
2309
2336
  purgeWatchTimelineForDeletedConversation(id);
2337
+ forgetActivationLinkForDeletedConversation(id);
2310
2338
 
2311
2339
  // Notify `conversation-deleted` hooks (e.g. the memory plugin failing its
2312
2340
  // still-pending jobs for this conversation). Fire-and-forget from this
@@ -2459,6 +2487,7 @@ export async function deleteConversationGently(
2459
2487
  }
2460
2488
 
2461
2489
  purgeWatchTimelineForDeletedConversation(id);
2490
+ forgetActivationLinkForDeletedConversation(id);
2462
2491
 
2463
2492
  // Notify `conversation-deleted` hooks — fire-and-forget, same contract as
2464
2493
  // the synchronous delete primitive.
@@ -4089,6 +4118,16 @@ export async function clearAll(): Promise<{
4089
4118
 
4090
4119
  void clearAllConversationIds();
4091
4120
 
4121
+ // The wipe took every conversation the checklist could be pointing at, so
4122
+ // release all of them at once rather than one delete at a time. Same
4123
+ // best-effort contract as the per-conversation path.
4124
+ void forgetAllActivationConversations().catch((err: unknown) => {
4125
+ log.warn(
4126
+ { err },
4127
+ "clearAll: failed to release conversations from the activation checklist",
4128
+ );
4129
+ });
4130
+
4092
4131
  return { conversations: convCount, messages: msgCount };
4093
4132
  }
4094
4133
 
@@ -142,6 +142,19 @@ function legacySlackConversationHasThreadEvidence(
142
142
  return true;
143
143
  }
144
144
 
145
+ // A thread rooted at the assistant's own post continues the conversation
146
+ // the post was recorded in. Every id a delivered post was acknowledged
147
+ // under is in the outbound index, split posts included, so this lookup
148
+ // is exact and not bounded by the scan below.
149
+ const rooted = findMessageByProviderMessageId(
150
+ "slack",
151
+ externalChatId,
152
+ sourceThreadId,
153
+ );
154
+ if (rooted?.conversationId === conversationId) {
155
+ return true;
156
+ }
157
+
145
158
  let offset = 0;
146
159
  while (offset < SLACK_LEGACY_THREAD_EVIDENCE_MAX_SCAN) {
147
160
  const remaining = SLACK_LEGACY_THREAD_EVIDENCE_MAX_SCAN - offset;
@@ -25,5 +25,6 @@ Calling the assistant's service APIs from a default plugin (e.g. `persistence/co
25
25
  A plugin's HTTP routes live on disk under `<pluginDir>/routes/` and are served in the plugin's own namespace at `/x/plugins/<plugin-name>/<path>`. They are **not** a `Plugin` contribution slot and are not wired through the loader or bootstrap — the `/x/*` route dispatcher (`runtime/routes/user-route-dispatcher.ts`) resolves each request against the filesystem at request time, exactly like the workspace `/x/*` user routes it shares code with.
26
26
 
27
27
  - **Namespace reservation**: the `plugins/<name>/` prefix under `/x/` resolves only against `<workspaceDir>/plugins/<name>/routes/`. A request there never falls back to a workspace `routes/plugins/…` file, so plugins can't collide with workspace routes or each other. A missing file 404s; nothing is registered ahead of time.
28
+ - **Reserved sub-namespace**: `notices/` under a plugin's namespace is served to the gateway's service principal only (`GATEWAY_PRINCIPALS`). `runtime/routes/user-routes.ts` registers it ahead of the `x/:path*` catch-all, and both the HTTP router and the gateway's IPC proxy take the first match, so the policy holds only while that order does. Those definitions match the plain spelling only; the `x/:path*` catch-all refuses every other spelling that resolves into the namespace (percent-encoded, doubled slashes, `.` segments, case variants) via `isPluginNoticeRoutePath`, so the reservation cannot be respelled around. The gateway posts its admission-denied notice there (`PLUGIN_ADMISSION_DENIED_NOTICE_PATH` in `@vellumai/gateway-client`, built from `PLUGIN_NOTICES_ROUTE_PREFIX`). Anything else the gateway must tell a plugin goes under the same prefix, never under a path an actor client can reach.
28
29
  - **Authoring**: each route file exports named HTTP-method functions (`export async function GET(request) {…}`), mirroring the workspace user-route convention. Nested directories and `index` files map to sub-paths (`routes/webhooks/incoming.ts` → `/x/plugins/<name>/webhooks/incoming`; `routes/index.ts` → the namespace root). Files are lazily loaded and mtime-cache-busted.
29
30
  - **Runtime access**: handlers receive the shared `UserRouteContext` (event hub, conversation posting) as a second argument, and may also reach singletons through their `@vellumai/plugin-api` imports.
@@ -350,6 +350,43 @@ describe("ROUTES policy declarations", () => {
350
350
  expect(route!.policy!.requiredScopes).toContain("ingress.write");
351
351
  });
352
352
 
353
+ test("a plugin's notices/ namespace is gateway-only, ahead of the /x catch-all", async () => {
354
+ // Both routers take the first definition that matches, and the gateway's
355
+ // IPC proxy compiles the schema in ROUTES order, so the reservation holds
356
+ // only while every notice definition precedes its catch-all sibling.
357
+ const { ROUTES } = await import("../../routes/index.js");
358
+ const methods = [
359
+ "GET",
360
+ "POST",
361
+ "PUT",
362
+ "PATCH",
363
+ "DELETE",
364
+ "HEAD",
365
+ "OPTIONS",
366
+ ];
367
+ const endpoints = [
368
+ "x/plugins/:plugin/notices",
369
+ "x/plugins/:plugin/notices/:path*",
370
+ ];
371
+ for (const method of methods) {
372
+ const catchAll = ROUTES.findIndex(
373
+ (r) => r.endpoint === "x/:path*" && r.method === method,
374
+ );
375
+ expect(catchAll).toBeGreaterThan(-1);
376
+ for (const endpoint of endpoints) {
377
+ const notice = ROUTES.findIndex(
378
+ (r) => r.endpoint === endpoint && r.method === method,
379
+ );
380
+ expect(notice).toBeGreaterThan(-1);
381
+ expect(catchAll).toBeGreaterThan(notice);
382
+ const policy = ROUTES[notice]!.policy;
383
+ expect(policy).not.toBeNull();
384
+ expect(policy!.allowedPrincipalTypes).toEqual(["svc_gateway"]);
385
+ expect(policy!.requiredScopes).toContain("internal.write");
386
+ }
387
+ }
388
+ });
389
+
353
390
  test("internal/twilio/voice-webhook is gateway-only", async () => {
354
391
  const { ROUTES } = await import("../../routes/index.js");
355
392
  const route = ROUTES.find(