@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.
- package/Dockerfile +5 -0
- package/docs/architecture/turn-actor.md +9 -0
- package/knip.json +1 -0
- package/node_modules/@vellumai/app-icons/package.json +18 -0
- package/node_modules/@vellumai/app-icons/src/index.test.ts +85 -0
- package/node_modules/@vellumai/app-icons/src/index.ts +387 -0
- package/node_modules/@vellumai/app-icons/tsconfig.json +20 -0
- package/node_modules/@vellumai/ces-client/node_modules/@vellumai/service-contracts/src/__tests__/channels.test.ts +31 -0
- package/node_modules/@vellumai/ces-client/node_modules/@vellumai/service-contracts/src/channels.ts +28 -3
- package/node_modules/@vellumai/gateway-client/node_modules/@vellumai/service-contracts/src/__tests__/channels.test.ts +31 -0
- package/node_modules/@vellumai/gateway-client/node_modules/@vellumai/service-contracts/src/channels.ts +28 -3
- package/node_modules/@vellumai/gateway-client/src/__tests__/plugin-admission-denied-contract.test.ts +10 -0
- package/node_modules/@vellumai/gateway-client/src/index.ts +1 -0
- package/node_modules/@vellumai/gateway-client/src/plugin-admission-denied-contract.ts +15 -2
- package/node_modules/@vellumai/service-contracts/src/__tests__/channels.test.ts +31 -0
- package/node_modules/@vellumai/service-contracts/src/channels.ts +28 -3
- package/openapi.yaml +628 -0
- package/package.json +3 -1
- package/scripts/smoke-container-workspace-dependencies.ts +19 -0
- package/src/__tests__/app-builder-icon-names.test.ts +29 -0
- package/src/__tests__/assistant-attachment-directive.test.ts +4 -0
- package/src/__tests__/conversation-agent-loop-inference-profile.test.ts +1 -0
- package/src/__tests__/conversation-agent-loop-overflow.test.ts +1 -0
- package/src/__tests__/conversation-agent-loop.test.ts +2 -0
- package/src/__tests__/conversation-attachments.test.ts +142 -0
- package/src/__tests__/conversation-delete-activation-progress.test.ts +145 -0
- package/src/__tests__/conversation-event-sink.test.ts +50 -0
- package/src/__tests__/conversation-process-app-control-preactivation.test.ts +10 -2
- package/src/__tests__/conversation-queue.test.ts +297 -0
- package/src/__tests__/credential-routes.test.ts +185 -6
- package/src/__tests__/drain-kick-guard.test.ts +2 -0
- package/src/__tests__/drain-requeue-on-contention.test.ts +6 -0
- package/src/__tests__/messaging-send-tool.test.ts +42 -0
- package/src/__tests__/oauth-commands-routes.test.ts +47 -0
- package/src/__tests__/subagent-manager-notify.test.ts +25 -4
- package/src/activation/progress-store.test.ts +1564 -0
- package/src/activation/progress-store.ts +1296 -0
- package/src/activation/turn-hooks.test.ts +246 -0
- package/src/activation/turn-hooks.ts +126 -0
- package/src/api/events/subagent-status-changed.ts +7 -5
- package/src/api/responses/activation.ts +136 -0
- package/src/apps/app-store.ts +8 -0
- package/src/cli/__tests__/catalog-search-help.test.ts +7 -5
- package/src/cli/commands/__tests__/cli-test-harness.ts +12 -3
- package/src/cli/commands/channels/__tests__/channels.test.ts +2 -0
- package/src/cli/commands/channels/__tests__/request.test.ts +191 -0
- package/src/cli/commands/channels/index.help.ts +70 -18
- package/src/cli/commands/channels/index.ts +17 -6
- package/src/cli/commands/channels/request.ts +66 -0
- package/src/cli/commands/oauth/request.test.ts +2 -0
- package/src/cli/commands/oauth/request.ts +227 -186
- package/src/config/bundled-skills/app-builder/SKILL.md +3 -1
- package/src/config/bundled-skills/app-builder/TOOLS.json +2 -2
- package/src/config/bundled-skills/messaging/tools/messaging-send.ts +8 -4
- package/src/config/feature-flag-registry.json +35 -5
- package/src/daemon/assistant-attachments.ts +11 -0
- package/src/daemon/conversation-agent-loop-handlers.ts +5 -0
- package/src/daemon/conversation-agent-loop.ts +71 -3
- package/src/daemon/conversation-attachments.ts +42 -1
- package/src/daemon/conversation-event-sink.ts +25 -0
- package/src/daemon/conversation-process.ts +69 -21
- package/src/daemon/conversation-store.ts +4 -3
- package/src/daemon/conversation-surfaces.ts +19 -4
- package/src/daemon/message-types/sync.ts +2 -0
- package/src/ipc/assistant-server.ts +2 -0
- package/src/ipc/routes/__tests__/activation-sync-ipc-routes.test.ts +51 -0
- package/src/ipc/routes/activation-sync-ipc-routes.ts +42 -0
- package/src/notifications/AGENTS.md +1 -1
- package/src/notifications/__tests__/proactive-home-thread.test.ts +111 -0
- package/src/notifications/conversation-pairing.ts +47 -5
- package/src/notifications/delivered-post-record.ts +3 -0
- package/src/persistence/__tests__/slack-thread-root-evidence.test.ts +102 -0
- package/src/persistence/conversation-crud.ts +39 -0
- package/src/persistence/delivery-crud.ts +13 -0
- package/src/plugins/AGENTS.md +1 -0
- package/src/runtime/auth/__tests__/route-policy.test.ts +37 -0
- package/src/runtime/routes/__tests__/user-routes-notices.test.ts +248 -0
- package/src/runtime/routes/activation-routes.test.ts +415 -0
- package/src/runtime/routes/activation-routes.ts +172 -0
- package/src/runtime/routes/credential-routes.ts +46 -3
- package/src/runtime/routes/index.ts +2 -0
- package/src/runtime/routes/oauth-commands-routes.ts +21 -8
- package/src/runtime/routes/platform-managed-credentials.ts +48 -0
- package/src/runtime/routes/secret-routes.ts +3 -12
- package/src/runtime/routes/user-route-resolution.ts +21 -0
- package/src/runtime/routes/user-routes.ts +112 -9
- package/src/runtime/sync/activation-sidecar-publish.test.ts +78 -0
- package/src/runtime/sync/documents-sidecar-publish.test.ts +3 -0
- package/src/runtime/sync/resource-sync-events.ts +23 -0
- package/src/runtime/sync/worker-daemon-notify.test.ts +36 -0
- package/src/runtime/sync/worker-daemon-notify.ts +39 -1
- 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
|
|
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 {
|
|
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`)
|
|
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;
|
package/src/plugins/AGENTS.md
CHANGED
|
@@ -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(
|