@vellumai/assistant 0.11.7-dev.202609012016.14eabff → 0.11.7-dev.202609012214.c97412d
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/ARCHITECTURE.md +1 -1
- package/docs/guardian-request-flow.md +13 -13
- package/node_modules/@vellumai/gateway-client/src/__tests__/guardian-request-contract.test.ts +0 -18
- package/node_modules/@vellumai/gateway-client/src/guardian-request-contract.ts +0 -21
- package/node_modules/@vellumai/gateway-client/src/index.ts +0 -2
- package/package.json +1 -1
- package/src/__tests__/guardian-card-withdrawal.test.ts +0 -22
- package/src/__tests__/guardian-gateway-sim.ts +0 -23
- package/src/__tests__/guardian-reply-router-answer-mode.test.ts +0 -2
- package/src/__tests__/guardian-routing-invariants.test.ts +0 -5
- package/src/__tests__/reaction-persistence.test.ts +1 -196
- package/src/__tests__/skill-load-tool.test.ts +21 -0
- package/src/__tests__/skills.test.ts +9 -1
- package/src/approvals/AGENTS.md +1 -1
- package/src/channels/__tests__/gateway-guardian-requests.test.ts +1 -23
- package/src/channels/gateway-guardian-requests.ts +0 -22
- package/src/cli/commands/db/__tests__/status.test.ts +22 -0
- package/src/cli/commands/db/index.help.ts +1 -1
- package/src/cli/commands/db/status.ts +172 -1
- package/src/config/bundled-skills/acp/SKILL.md +10 -3
- package/src/daemon/conversation-process.ts +0 -1
- package/src/notifications/AGENTS.md +1 -1
- package/src/notifications/adapters/discord.ts +1 -1
- package/src/notifications/guardian-delivery-recorder.ts +5 -9
- package/src/permissions/confirmation-guardian-request.ts +2 -2
- package/src/permissions/prompter.ts +1 -1
- package/src/persistence/schema-contract.test.ts +78 -0
- package/src/persistence/schema-contract.ts +96 -0
- package/src/runtime/AGENTS.md +1 -1
- package/src/runtime/channel-approval-types.ts +1 -5
- package/src/runtime/guardian-reply-router.ts +4 -75
- package/src/runtime/question-request-guardian-bridge.ts +1 -2
- package/src/runtime/routes/acp-routes.ts +1 -1
- package/src/runtime/routes/channel-route-shared.ts +0 -39
- package/src/runtime/routes/conversation-routes.ts +0 -1
- package/src/runtime/routes/guardian-approval-interception.ts +0 -5
- package/src/runtime/routes/inbound-message-handler.ts +4 -7
- package/src/runtime/routes/inbound-stages/guardian-reply-intercept.ts +23 -48
- package/src/runtime/routes/inbound-stages/reaction-intercept.test.ts +193 -90
- package/src/runtime/routes/inbound-stages/reaction-intercept.ts +28 -46
- package/src/tools/skills/load.ts +1 -1
- package/src/__tests__/slack-reaction-approvals.test.ts +0 -97
- package/src/__tests__/slack-reaction-guardian-approval.test.ts +0 -307
package/ARCHITECTURE.md
CHANGED
|
@@ -120,7 +120,7 @@ Scoped approval grants allow a guardian's approval decision on one channel (e.g.
|
|
|
120
120
|
|
|
121
121
|
### Guardian Decision Primitive
|
|
122
122
|
|
|
123
|
-
All guardian approval decisions
|
|
123
|
+
All guardian approval decisions, regardless of how they arrive, route through a single primitive in `src/approvals/guardian-decision-primitive.ts`, which centralizes decision logic for callback button handlers, the conversational approval engine, and channel text replies.
|
|
124
124
|
|
|
125
125
|
**Core API:**
|
|
126
126
|
|
|
@@ -28,10 +28,10 @@ anti-pattern was retired in #35642 and again in the ask_question redesign).
|
|
|
28
28
|
telegram: inline keyboard; slack: blocks; vellum: conversation card via approval-card-builder)
|
|
29
29
|
card deliveries recorded per channel (guardian-delivery-recorder.ts → guardian_request_deliveries)
|
|
30
30
|
│
|
|
31
|
-
▼ user responds: button tap /
|
|
31
|
+
▼ user responds: button tap / "CODE <reply>" / bare text
|
|
32
32
|
guardian reply router (runtime/guardian-reply-router.ts, invoked from
|
|
33
|
-
|
|
34
|
-
|
|
33
|
+
callbacks → request codes → bare answer → routes/inbound-stages/guardian-reply-intercept.ts,
|
|
34
|
+
explicit approve/reject → NL BEFORE background dispatch: replies to parked
|
|
35
35
|
│ prompts resolve inline, never deferred)
|
|
36
36
|
▼ one decision primitive
|
|
37
37
|
applyGuardianDecision (approvals/guardian-decision-primitive.ts:
|
|
@@ -45,14 +45,14 @@ anti-pattern was retired in #35642 and again in the ask_question redesign).
|
|
|
45
45
|
|
|
46
46
|
## Who owns what
|
|
47
47
|
|
|
48
|
-
| Concern | Owner
|
|
49
|
-
| --------------------------------------- |
|
|
50
|
-
| The "what": card text, actions, options | broadcaster context build (once per broadcast)
|
|
51
|
-
| The "how": channel-native rendering | `notifications/adapters/<channel>`
|
|
52
|
-
| Request state | gateway `guardian_requests` (+ deliveries)
|
|
53
|
-
| Decisions | `applyGuardianDecision` (CAS, atomic ACL outcome)
|
|
54
|
-
| Kind-specific follow-through | resolver registry (`kind` → resolver)
|
|
55
|
-
| Reply understanding | guardian reply router (codes, buttons,
|
|
48
|
+
| Concern | Owner | Never |
|
|
49
|
+
| --------------------------------------- | ------------------------------------------------- | ----------------------------------- |
|
|
50
|
+
| The "what": card text, actions, options | broadcaster context build (once per broadcast) | built per-adapter |
|
|
51
|
+
| The "how": channel-native rendering | `notifications/adapters/<channel>` | domain logic, payload parsing |
|
|
52
|
+
| Request state | gateway `guardian_requests` (+ deliveries) | daemon-side request tables |
|
|
53
|
+
| Decisions | `applyGuardianDecision` (CAS, atomic ACL outcome) | inline decision logic at call sites |
|
|
54
|
+
| Kind-specific follow-through | resolver registry (`kind` → resolver) | switch statements in the router |
|
|
55
|
+
| Reply understanding | guardian reply router (codes, buttons, modes) | per-feature inbound intercepts |
|
|
56
56
|
|
|
57
57
|
## The canonical home-feed projection
|
|
58
58
|
|
|
@@ -186,8 +186,8 @@ chat the turn is running in. On Slack that chat can be a shared room, and the
|
|
|
186
186
|
card carries the tool, a command preview and live buttons.
|
|
187
187
|
`resolveGuardianPromptDelivery` addresses it to the guardian's bound DM
|
|
188
188
|
instead, by chat id rather than user id because that address is written to the
|
|
189
|
-
delivery row and read back to
|
|
190
|
-
|
|
189
|
+
delivery row and read back to scope plain-text replies and edit the decided
|
|
190
|
+
card. It returns the address and its route together, since
|
|
191
191
|
the turn's own callback carries a `threadTs` naming a thread that does not
|
|
192
192
|
exist in the DM. When no private address resolves it returns nothing and the
|
|
193
193
|
prompt is left to the in-app confirmation, because the room is the disclosure
|
package/node_modules/@vellumai/gateway-client/src/__tests__/guardian-request-contract.test.ts
CHANGED
|
@@ -16,7 +16,6 @@ import {
|
|
|
16
16
|
ExpireGuardianRequestIpcParamsSchema,
|
|
17
17
|
ExpireInteractionBoundIpcResponseSchema,
|
|
18
18
|
GetGuardianRequestByCallSessionIpcParamsSchema,
|
|
19
|
-
GetGuardianRequestByDestinationMessageIpcParamsSchema,
|
|
20
19
|
GetGuardianRequestByPendingQuestionIpcParamsSchema,
|
|
21
20
|
GUARDIAN_REQUESTS_IPC_METHODS,
|
|
22
21
|
GuardianRequestAclOutcomeSchema,
|
|
@@ -482,23 +481,6 @@ describe("delivery + destination IPC schemas", () => {
|
|
|
482
481
|
);
|
|
483
482
|
});
|
|
484
483
|
|
|
485
|
-
test("get_by_destination_message requires all three keys", () => {
|
|
486
|
-
const params = {
|
|
487
|
-
channel: "telegram",
|
|
488
|
-
chatId: "tg-chat-guardian",
|
|
489
|
-
messageId: "msg-42",
|
|
490
|
-
};
|
|
491
|
-
expect(
|
|
492
|
-
GetGuardianRequestByDestinationMessageIpcParamsSchema.parse(params),
|
|
493
|
-
).toEqual(params);
|
|
494
|
-
expect(() =>
|
|
495
|
-
GetGuardianRequestByDestinationMessageIpcParamsSchema.parse({
|
|
496
|
-
channel: "telegram",
|
|
497
|
-
chatId: "tg-chat-guardian",
|
|
498
|
-
}),
|
|
499
|
-
).toThrow();
|
|
500
|
-
});
|
|
501
|
-
|
|
502
484
|
test("list_pending_by_destination accepts either addressing form", () => {
|
|
503
485
|
const byConversation = { conversationId: "conv-1", channel: "telegram" };
|
|
504
486
|
expect(
|
|
@@ -15,11 +15,6 @@
|
|
|
15
15
|
* daemon's client-facing HTTP surface owns the distinct `guardian_actions_*`
|
|
16
16
|
* operationIds (`guardian_actions_pending` / `guardian_actions_decision`),
|
|
17
17
|
* which do not change.
|
|
18
|
-
*
|
|
19
|
-
* Destination lookups are deliberately split into a single-message lookup
|
|
20
|
-
* (`get_by_destination_message`) and a pending-list read
|
|
21
|
-
* (`list_pending_by_destination`) so each response schema has exactly one
|
|
22
|
-
* shape instead of a params-dependent polymorphic result.
|
|
23
18
|
*/
|
|
24
19
|
|
|
25
20
|
import { z } from "zod";
|
|
@@ -194,7 +189,6 @@ export const GUARDIAN_REQUESTS_IPC_METHODS = {
|
|
|
194
189
|
updateDelivery: "guardian_requests_update_delivery",
|
|
195
190
|
listDeliveries: "guardian_requests_list_deliveries",
|
|
196
191
|
listDeliveriesByChat: "guardian_requests_list_deliveries_by_chat",
|
|
197
|
-
getByDestinationMessage: "guardian_requests_get_by_destination_message",
|
|
198
192
|
listPendingByDestination: "guardian_requests_list_pending_by_destination",
|
|
199
193
|
listPendingByScope: "guardian_requests_list_pending_by_scope",
|
|
200
194
|
inScope: "guardian_requests_in_scope",
|
|
@@ -627,21 +621,6 @@ export type GuardianRequestDeliveryListIpcResponse = z.infer<
|
|
|
627
621
|
// Destination + scope lookups
|
|
628
622
|
// ---------------------------------------------------------------------------
|
|
629
623
|
|
|
630
|
-
/**
|
|
631
|
-
* Request for `guardian_requests_get_by_destination_message` — reaction
|
|
632
|
-
* routing: recover the pending request whose delivered card is the reacted-to
|
|
633
|
-
* message.
|
|
634
|
-
*/
|
|
635
|
-
export const GetGuardianRequestByDestinationMessageIpcParamsSchema = z.object({
|
|
636
|
-
channel: z.string().min(1),
|
|
637
|
-
chatId: z.string().min(1),
|
|
638
|
-
messageId: z.string().min(1),
|
|
639
|
-
});
|
|
640
|
-
|
|
641
|
-
export type GetGuardianRequestByDestinationMessageIpcParams = z.infer<
|
|
642
|
-
typeof GetGuardianRequestByDestinationMessageIpcParamsSchema
|
|
643
|
-
>;
|
|
644
|
-
|
|
645
624
|
/**
|
|
646
625
|
* Request for `guardian_requests_list_pending_by_destination`. Two forms:
|
|
647
626
|
* by destination conversation (`conversationId`, optionally narrowed by
|
|
@@ -245,7 +245,6 @@ export {
|
|
|
245
245
|
ExpireInteractionBoundIpcResponseSchema,
|
|
246
246
|
GetGuardianRequestByCallSessionIpcParamsSchema,
|
|
247
247
|
GetGuardianRequestByCodeIpcParamsSchema,
|
|
248
|
-
GetGuardianRequestByDestinationMessageIpcParamsSchema,
|
|
249
248
|
GetGuardianRequestByPendingQuestionIpcParamsSchema,
|
|
250
249
|
GetGuardianRequestIpcParamsSchema,
|
|
251
250
|
GUARDIAN_REQUESTS_IPC_METHODS,
|
|
@@ -286,7 +285,6 @@ export type {
|
|
|
286
285
|
ExpireInteractionBoundIpcResponse,
|
|
287
286
|
GetGuardianRequestByCallSessionIpcParams,
|
|
288
287
|
GetGuardianRequestByCodeIpcParams,
|
|
289
|
-
GetGuardianRequestByDestinationMessageIpcParams,
|
|
290
288
|
GetGuardianRequestByPendingQuestionIpcParams,
|
|
291
289
|
GetGuardianRequestIpcParams,
|
|
292
290
|
GuardianRequestAclOutcome,
|
package/package.json
CHANGED
|
@@ -686,28 +686,6 @@ describe("recordApprovalCardDelivery", () => {
|
|
|
686
686
|
expect(delivery?.destinationChatId).toBeNull();
|
|
687
687
|
expect(delivery?.status).toBe("pending");
|
|
688
688
|
});
|
|
689
|
-
|
|
690
|
-
test("lets a Slack reaction resolve back to its request (LUM-2502)", async () => {
|
|
691
|
-
// A delivered Slack approval card must be addressable by (channel, chat, ts)
|
|
692
|
-
// so an emoji reaction on it resolves to the right request rather than
|
|
693
|
-
// silently falling through to transcript persistence.
|
|
694
|
-
const req = makeRequest();
|
|
695
|
-
await recordApprovalCardDelivery({
|
|
696
|
-
requestId: req.id,
|
|
697
|
-
channel: "slack",
|
|
698
|
-
chatId: "C-guardian",
|
|
699
|
-
messageId: "1700000000.5678",
|
|
700
|
-
status: "sent",
|
|
701
|
-
});
|
|
702
|
-
|
|
703
|
-
const resolved =
|
|
704
|
-
await bridgeState.module.getPendingRequestByDestinationMessageOrNull(
|
|
705
|
-
"slack",
|
|
706
|
-
"C-guardian",
|
|
707
|
-
"1700000000.5678",
|
|
708
|
-
);
|
|
709
|
-
expect(resolved?.id).toBe(req.id);
|
|
710
|
-
});
|
|
711
689
|
});
|
|
712
690
|
|
|
713
691
|
describe("recordGuardianRequestDeliveries", () => {
|
|
@@ -454,25 +454,6 @@ export function createGuardianGatewaySim() {
|
|
|
454
454
|
.map((d) => ({ ...d }));
|
|
455
455
|
}
|
|
456
456
|
|
|
457
|
-
async function getPendingRequestByDestinationMessage(
|
|
458
|
-
channel: string,
|
|
459
|
-
chatId: string,
|
|
460
|
-
messageId: string,
|
|
461
|
-
): Promise<SimGuardianRequest | null> {
|
|
462
|
-
throwIfReadError();
|
|
463
|
-
const delivery = deliveries.find(
|
|
464
|
-
(d) =>
|
|
465
|
-
d.destinationChannel === channel &&
|
|
466
|
-
d.destinationChatId === chatId &&
|
|
467
|
-
d.destinationMessageId === messageId,
|
|
468
|
-
);
|
|
469
|
-
if (!delivery) {
|
|
470
|
-
return null;
|
|
471
|
-
}
|
|
472
|
-
const request = requests.get(delivery.requestId);
|
|
473
|
-
return request?.status === "pending" ? { ...request } : null;
|
|
474
|
-
}
|
|
475
|
-
|
|
476
457
|
async function listPendingRequestsByDestination(params: {
|
|
477
458
|
channel?: string;
|
|
478
459
|
chatId?: string;
|
|
@@ -624,10 +605,6 @@ export function createGuardianGatewaySim() {
|
|
|
624
605
|
listGuardianRequestDeliveries,
|
|
625
606
|
[],
|
|
626
607
|
),
|
|
627
|
-
getPendingRequestByDestinationMessageOrNull: degrade(
|
|
628
|
-
getPendingRequestByDestinationMessage,
|
|
629
|
-
null,
|
|
630
|
-
),
|
|
631
608
|
listPendingRequestsByDestinationOrEmpty: degrade(
|
|
632
609
|
listPendingRequestsByDestination,
|
|
633
610
|
[],
|
|
@@ -44,7 +44,6 @@ mock.module("../channels/gateway-guardian-requests.js", () => ({
|
|
|
44
44
|
getGuardianRequestOrNull: (id: string) =>
|
|
45
45
|
Promise.resolve(requestsById.get(id) ?? null),
|
|
46
46
|
getGuardianRequestByCodeOrNull: () => Promise.resolve(null),
|
|
47
|
-
getPendingRequestByDestinationMessageOrNull: () => Promise.resolve(null),
|
|
48
47
|
listGuardianRequestsOrEmpty: () => Promise.resolve(pendingList),
|
|
49
48
|
}));
|
|
50
49
|
|
|
@@ -91,7 +90,6 @@ function makeRequest(
|
|
|
91
90
|
function makeContext(overrides: Record<string, unknown> = {}) {
|
|
92
91
|
return {
|
|
93
92
|
messageText: "",
|
|
94
|
-
channel: "telegram",
|
|
95
93
|
actor: {
|
|
96
94
|
actorPrincipalId: "prin-guardian",
|
|
97
95
|
actorExternalUserId: "tg-guardian",
|
|
@@ -106,7 +106,6 @@ function replyCtx(
|
|
|
106
106
|
): GuardianReplyContext {
|
|
107
107
|
return {
|
|
108
108
|
messageText: "",
|
|
109
|
-
channel: "telegram",
|
|
110
109
|
actor: guardianActor(),
|
|
111
110
|
conversationId: "conv-test",
|
|
112
111
|
...overrides,
|
|
@@ -1509,7 +1508,6 @@ describe("routing invariant: destination hints do not bypass tool_approval princ
|
|
|
1509
1508
|
const result = await routeGuardianReply(
|
|
1510
1509
|
replyCtx({
|
|
1511
1510
|
messageText: "approve",
|
|
1512
|
-
channel: "telegram",
|
|
1513
1511
|
actor: guardianActor({ guardianPrincipalId: "different-principal" }),
|
|
1514
1512
|
conversationId: "conv-guardian-chat",
|
|
1515
1513
|
pendingScope: { mode: "scoped", requestIds: [req.id] },
|
|
@@ -1544,7 +1542,6 @@ describe("routing invariant: destination hints do not bypass tool_approval princ
|
|
|
1544
1542
|
const result = await routeGuardianReply(
|
|
1545
1543
|
replyCtx({
|
|
1546
1544
|
messageText: "approve",
|
|
1547
|
-
channel: "telegram",
|
|
1548
1545
|
actor: guardianActor({ actorExternalUserId: "guardian-tg-user" }),
|
|
1549
1546
|
conversationId: "conv-guardian-chat",
|
|
1550
1547
|
// pendingScope omitted — no delivery hints
|
|
@@ -1704,7 +1701,6 @@ describe("routing invariant: invite handoff bypass for access requests", () => {
|
|
|
1704
1701
|
|
|
1705
1702
|
const result = await routeGuardianReply({
|
|
1706
1703
|
messageText: "C0D3A5 approve",
|
|
1707
|
-
channel: "vellum",
|
|
1708
1704
|
actor: trustedActor({ channel: "vellum" }),
|
|
1709
1705
|
conversationId: "conv-guardian-conversation",
|
|
1710
1706
|
pendingScope: { mode: "scoped", requestIds: [req.id] },
|
|
@@ -1743,7 +1739,6 @@ describe("routing invariant: invite handoff bypass for access requests", () => {
|
|
|
1743
1739
|
|
|
1744
1740
|
const result = await routeGuardianReply({
|
|
1745
1741
|
messageText: "please approve this request",
|
|
1746
|
-
channel: "vellum",
|
|
1747
1742
|
actor: trustedActor({ channel: "vellum" }),
|
|
1748
1743
|
conversationId: "conv-guardian-conversation",
|
|
1749
1744
|
pendingScope: { mode: "scoped", requestIds: [req.id] },
|
|
@@ -87,14 +87,12 @@ import { getDb } from "../persistence/db-connection.js";
|
|
|
87
87
|
import { initializeDb } from "../persistence/db-init.js";
|
|
88
88
|
import { linkMessage, recordInbound } from "../persistence/delivery-crud.js";
|
|
89
89
|
import { messages } from "../persistence/schema/conversations.js";
|
|
90
|
-
import * as pendingInteractions from "../runtime/pending-interactions.js";
|
|
91
90
|
import { isReactionEvent } from "../runtime/routes/inbound-stages/reaction-intercept.js";
|
|
92
91
|
import {
|
|
93
92
|
handleChannelInbound,
|
|
94
93
|
seedContactChannel,
|
|
95
94
|
} from "./helpers/channel-test-adapter.js";
|
|
96
95
|
import { createGuardianBinding } from "./helpers/create-guardian-binding.js";
|
|
97
|
-
import { bridgeState } from "./helpers/gateway-guardian-requests-store-bridge.js";
|
|
98
96
|
|
|
99
97
|
await initializeDb();
|
|
100
98
|
|
|
@@ -623,200 +621,6 @@ describe("Slack reaction event persistence", () => {
|
|
|
623
621
|
});
|
|
624
622
|
});
|
|
625
623
|
|
|
626
|
-
// ---------------------------------------------------------------------------
|
|
627
|
-
// Guardian approval-by-reaction integration test
|
|
628
|
-
// ---------------------------------------------------------------------------
|
|
629
|
-
//
|
|
630
|
-
// Verifies that approval interception runs before reaction persistence so a
|
|
631
|
-
// guardian's `reaction:` event on a pending approval prompt applies the
|
|
632
|
-
// decision and no transcript-line row is written for the reaction itself.
|
|
633
|
-
|
|
634
|
-
const GUARDIAN_USER_ID = "U_GUARDIAN_REACT";
|
|
635
|
-
const GUARDIAN_DISPLAY_NAME = "Guardian Reactor";
|
|
636
|
-
const GUARDIAN_REACTION_TOOL = "execute_shell";
|
|
637
|
-
const GUARDIAN_REACTION_INPUT = { command: "rm -rf /tmp/test" };
|
|
638
|
-
|
|
639
|
-
function seedGuardianForChannel(): void {
|
|
640
|
-
seedGatewayGuardian({
|
|
641
|
-
channelType: "slack",
|
|
642
|
-
address: GUARDIAN_USER_ID,
|
|
643
|
-
principalId: GUARDIAN_USER_ID,
|
|
644
|
-
externalChatId: SLACK_CHANNEL_ID,
|
|
645
|
-
});
|
|
646
|
-
createGuardianBinding({
|
|
647
|
-
channel: "slack",
|
|
648
|
-
guardianExternalUserId: GUARDIAN_USER_ID,
|
|
649
|
-
guardianDeliveryChatId: SLACK_CHANNEL_ID,
|
|
650
|
-
guardianPrincipalId: GUARDIAN_USER_ID,
|
|
651
|
-
});
|
|
652
|
-
}
|
|
653
|
-
|
|
654
|
-
function seedPendingGuardianApprovalForReaction(
|
|
655
|
-
requestId: string,
|
|
656
|
-
conversationId: string,
|
|
657
|
-
reactedTs: string,
|
|
658
|
-
): void {
|
|
659
|
-
// Canonical tool_approval request keyed by the confirmation requestId — the
|
|
660
|
-
// same record assistant-event-hub creates for every confirmation.
|
|
661
|
-
bridgeState.seedRequest({
|
|
662
|
-
id: requestId,
|
|
663
|
-
kind: "tool_approval",
|
|
664
|
-
sourceType: "channel",
|
|
665
|
-
sourceChannel: "slack",
|
|
666
|
-
sourceConversationId: conversationId,
|
|
667
|
-
requesterExternalUserId: "requester-user-1",
|
|
668
|
-
requesterChatId: SLACK_CHANNEL_ID,
|
|
669
|
-
guardianExternalUserId: GUARDIAN_USER_ID,
|
|
670
|
-
guardianPrincipalId: GUARDIAN_USER_ID,
|
|
671
|
-
toolName: GUARDIAN_REACTION_TOOL,
|
|
672
|
-
status: "pending",
|
|
673
|
-
expiresAt: Date.now() + 300_000,
|
|
674
|
-
});
|
|
675
|
-
|
|
676
|
-
// The delivered approval card → request mapping the reaction resolves
|
|
677
|
-
// against (destination message id = the reacted Slack ts).
|
|
678
|
-
bridgeState.seedDelivery({
|
|
679
|
-
requestId,
|
|
680
|
-
destinationChannel: "slack",
|
|
681
|
-
destinationChatId: SLACK_CHANNEL_ID,
|
|
682
|
-
destinationMessageId: reactedTs,
|
|
683
|
-
});
|
|
684
|
-
|
|
685
|
-
// Register a pending interaction so the tool_approval resolver finds the
|
|
686
|
-
// requester-side hook to drive `allow`.
|
|
687
|
-
const handleConfirmationResponse = mock(() => {});
|
|
688
|
-
const _mockSession = {
|
|
689
|
-
handleConfirmationResponse,
|
|
690
|
-
ensureActorScopedHistory: async () => {},
|
|
691
|
-
} as unknown as Conversation;
|
|
692
|
-
_conversationMocks.set(conversationId, _mockSession);
|
|
693
|
-
pendingInteractions.register(requestId, {
|
|
694
|
-
conversationId,
|
|
695
|
-
kind: "confirmation",
|
|
696
|
-
confirmationDetails: {
|
|
697
|
-
toolName: GUARDIAN_REACTION_TOOL,
|
|
698
|
-
input: GUARDIAN_REACTION_INPUT,
|
|
699
|
-
riskLevel: "high",
|
|
700
|
-
allowlistOptions: [
|
|
701
|
-
{ label: "test", description: "test", pattern: "test" },
|
|
702
|
-
],
|
|
703
|
-
scopeOptions: [{ label: "everywhere", scope: "everywhere" }],
|
|
704
|
-
},
|
|
705
|
-
});
|
|
706
|
-
}
|
|
707
|
-
|
|
708
|
-
describe("guardian approval-by-reaction integration via handleChannelInbound", () => {
|
|
709
|
-
beforeEach(() => {
|
|
710
|
-
const db = getDb();
|
|
711
|
-
db.run("DELETE FROM messages");
|
|
712
|
-
db.run("DELETE FROM channel_inbound_events");
|
|
713
|
-
db.run("DELETE FROM conversations");
|
|
714
|
-
db.run("DELETE FROM contact_channels");
|
|
715
|
-
db.run("DELETE FROM contacts");
|
|
716
|
-
bridgeState.reset();
|
|
717
|
-
gatewayGuardians = [];
|
|
718
|
-
pendingInteractions.clear();
|
|
719
|
-
msgCounter = 0;
|
|
720
|
-
});
|
|
721
|
-
|
|
722
|
-
test("guardian reaction on pending approval applies decision and persists no transcript row", async () => {
|
|
723
|
-
seedGuardianForChannel();
|
|
724
|
-
const requestId = "req-guardian-react-1";
|
|
725
|
-
// Back the guardian request and its pending interaction with a real
|
|
726
|
-
// conversation row, inserted directly via the DB layer.
|
|
727
|
-
const db = getDb();
|
|
728
|
-
const conversationId = "conv-react-test-1";
|
|
729
|
-
const now = Date.now();
|
|
730
|
-
db.$client
|
|
731
|
-
.prepare(
|
|
732
|
-
`INSERT INTO conversations (
|
|
733
|
-
id, title, created_at, updated_at, total_input_tokens, total_output_tokens,
|
|
734
|
-
total_estimated_cost, context_compacted_message_count, conversation_type,
|
|
735
|
-
source, memory_scope_id, host_access, is_auto_title
|
|
736
|
-
) VALUES (?, NULL, ?, ?, 0, 0, 0, 0, 'standard', 'user', 'default', 0, 1)`,
|
|
737
|
-
)
|
|
738
|
-
.run(conversationId, now, now);
|
|
739
|
-
|
|
740
|
-
const reactedTs = "1700000099.000001";
|
|
741
|
-
// The approval card was delivered on this ts; its canonical delivery row
|
|
742
|
-
// is how the guardian reaction is scoped to a known approval message.
|
|
743
|
-
seedPendingGuardianApprovalForReaction(
|
|
744
|
-
requestId,
|
|
745
|
-
conversationId,
|
|
746
|
-
reactedTs,
|
|
747
|
-
);
|
|
748
|
-
|
|
749
|
-
const body = {
|
|
750
|
-
sourceChannel: "slack",
|
|
751
|
-
interface: "slack",
|
|
752
|
-
conversationExternalId: SLACK_CHANNEL_ID,
|
|
753
|
-
externalMessageId: `${SLACK_CHANNEL_ID}:${reactedTs}:guardian-react`,
|
|
754
|
-
content: "reaction:white_check_mark",
|
|
755
|
-
callbackData: "reaction:white_check_mark",
|
|
756
|
-
actorExternalId: GUARDIAN_USER_ID,
|
|
757
|
-
actorDisplayName: GUARDIAN_DISPLAY_NAME,
|
|
758
|
-
replyCallbackUrl: "http://localhost:7830/deliver/slack",
|
|
759
|
-
sourceMetadata: {
|
|
760
|
-
messageId: reactedTs,
|
|
761
|
-
chatType: "channel",
|
|
762
|
-
},
|
|
763
|
-
};
|
|
764
|
-
const req = new Request("http://localhost:8080/channels/inbound", {
|
|
765
|
-
method: "POST",
|
|
766
|
-
headers: {
|
|
767
|
-
"Content-Type": "application/json",
|
|
768
|
-
"X-Gateway-Origin": TEST_BEARER_TOKEN,
|
|
769
|
-
},
|
|
770
|
-
body: JSON.stringify(body),
|
|
771
|
-
});
|
|
772
|
-
|
|
773
|
-
// Wire a non-undefined `approvalConversationGenerator` to mirror the
|
|
774
|
-
// production configuration. The deterministic reaction handler must
|
|
775
|
-
// short-circuit before the generator is consulted — if it runs, it
|
|
776
|
-
// returns `keep_pending` so the assertions below would fail loudly.
|
|
777
|
-
const approvalConversationGenerator = mock(async () => ({
|
|
778
|
-
disposition: "keep_pending" as const,
|
|
779
|
-
replyText: "mock conversational turn should not be invoked for reactions",
|
|
780
|
-
}));
|
|
781
|
-
|
|
782
|
-
const resp = await handleChannelInbound(
|
|
783
|
-
req,
|
|
784
|
-
undefined,
|
|
785
|
-
TEST_BEARER_TOKEN,
|
|
786
|
-
undefined,
|
|
787
|
-
approvalConversationGenerator,
|
|
788
|
-
);
|
|
789
|
-
const json = (await resp.json()) as Record<string, unknown>;
|
|
790
|
-
|
|
791
|
-
expect(resp.status).toBe(200);
|
|
792
|
-
expect(json.accepted).toBe(true);
|
|
793
|
-
expect(json.canonicalRouter).toBe("canonical_decision_applied");
|
|
794
|
-
expect(approvalConversationGenerator).not.toHaveBeenCalled();
|
|
795
|
-
|
|
796
|
-
// The guardian request is resolved (no longer pending).
|
|
797
|
-
expect(bridgeState.getRequest(requestId)?.status).toBe("approved");
|
|
798
|
-
|
|
799
|
-
// No transcript row was written for the reaction itself — resolved
|
|
800
|
-
// guardian approval reactions have no transcript representation.
|
|
801
|
-
const reactionRows = readPersistedMessages().filter((row) => {
|
|
802
|
-
if (!row.metadata) {
|
|
803
|
-
return false;
|
|
804
|
-
}
|
|
805
|
-
try {
|
|
806
|
-
const env = JSON.parse(row.metadata) as Record<string, unknown>;
|
|
807
|
-
if (typeof env.slackMeta !== "string") {
|
|
808
|
-
return false;
|
|
809
|
-
}
|
|
810
|
-
const meta = readSlackMetadata(env.slackMeta);
|
|
811
|
-
return meta?.eventKind === "reaction";
|
|
812
|
-
} catch {
|
|
813
|
-
return false;
|
|
814
|
-
}
|
|
815
|
-
});
|
|
816
|
-
expect(reactionRows.length).toBe(0);
|
|
817
|
-
});
|
|
818
|
-
});
|
|
819
|
-
|
|
820
624
|
// ---------------------------------------------------------------------------
|
|
821
625
|
// Reaction access-control regression (LUM-2489)
|
|
822
626
|
// ---------------------------------------------------------------------------
|
|
@@ -830,6 +634,7 @@ describe("guardian approval-by-reaction integration via handleChannelInbound", (
|
|
|
830
634
|
describe("reaction access control (no verification handshake)", () => {
|
|
831
635
|
const STRANGER_USER_ID = "U_REACTION_STRANGER";
|
|
832
636
|
const CONTACT_USER_ID = "U_REACTION_CONTACT";
|
|
637
|
+
const GUARDIAN_USER_ID = "U_GUARDIAN_REACT";
|
|
833
638
|
// Guardian's approval channel is a DM, distinct from the public channel the
|
|
834
639
|
// reaction lands in (mirroring production). Reusing the public channel id
|
|
835
640
|
// would let a reactor match the guardian's channel via findContactChannel's
|
|
@@ -127,6 +127,14 @@ describe("skill_load tool", () => {
|
|
|
127
127
|
);
|
|
128
128
|
});
|
|
129
129
|
|
|
130
|
+
test("description routes Claude Code and Codex mentions to acp", () => {
|
|
131
|
+
// skill_load is always in the tool list. Naming the agents here is what
|
|
132
|
+
// makes a mention load ACP instead of treating Claude Code as a competitor.
|
|
133
|
+
expect(skillLoadTool.description).toContain("Claude Code");
|
|
134
|
+
expect(skillLoadTool.description).toContain("Codex");
|
|
135
|
+
expect(skillLoadTool.description).toContain('skill: "acp"');
|
|
136
|
+
});
|
|
137
|
+
|
|
130
138
|
test("routes recurring monitoring and reminders to the schedule skill", () => {
|
|
131
139
|
const description = skillLoadTool.description;
|
|
132
140
|
expect(description).toContain('skill: "schedule"');
|
|
@@ -399,6 +407,19 @@ describe("skill_load tool", () => {
|
|
|
399
407
|
expect(result.content).toContain('<loaded_skill id="app-builder"');
|
|
400
408
|
});
|
|
401
409
|
|
|
410
|
+
test("bundled acp skill offers a one-time connect when the user names an agent", async () => {
|
|
411
|
+
const result = await executeSkillLoad({ skill: "acp" });
|
|
412
|
+
|
|
413
|
+
expect(result.isError).toBe(false);
|
|
414
|
+
expect(result.content).toContain("Skill: ACP");
|
|
415
|
+
expect(result.content).toContain(
|
|
416
|
+
"When the user names Claude Code or Codex",
|
|
417
|
+
);
|
|
418
|
+
expect(result.content).toContain("offer once, in one short sentence");
|
|
419
|
+
expect(result.content).toContain("Do not spawn unless they accept");
|
|
420
|
+
expect(result.content).toContain('<loaded_skill id="acp"');
|
|
421
|
+
});
|
|
422
|
+
|
|
402
423
|
test("bundled phone-calls loads when setup includes are unavailable", async () => {
|
|
403
424
|
const result = await executeSkillLoad({ skill: "phone-calls" });
|
|
404
425
|
|
|
@@ -898,12 +898,20 @@ describe("always-candidate frontmatter parsing", () => {
|
|
|
898
898
|
// A "configure Claude Code" or "let's do the auth" turn only reaches this
|
|
899
899
|
// skill if the card actually says it covers setup and authentication;
|
|
900
900
|
// delegation-only wording routes those turns to a terminal skill instead.
|
|
901
|
+
// "names" keeps a mention ("claude code will do it") from falling through
|
|
902
|
+
// as a competitor reference.
|
|
901
903
|
const { buildSkillContent } =
|
|
902
904
|
await import("../plugins/defaults/memory/substrate/skill-content.js");
|
|
903
905
|
const acp = loadSkillCatalog().find((skill) => skill.id === "acp");
|
|
904
906
|
const card = buildSkillContent(acp!).toLowerCase();
|
|
905
907
|
|
|
906
|
-
for (const term of [
|
|
908
|
+
for (const term of [
|
|
909
|
+
"names",
|
|
910
|
+
"set up",
|
|
911
|
+
"install",
|
|
912
|
+
"authenticate",
|
|
913
|
+
"connect",
|
|
914
|
+
]) {
|
|
907
915
|
expect(card).toContain(term);
|
|
908
916
|
}
|
|
909
917
|
});
|
package/src/approvals/AGENTS.md
CHANGED
|
@@ -7,7 +7,7 @@ The end-to-end lifecycle of interactive guardian requests (approvals, questions)
|
|
|
7
7
|
- **Rich delivery failures must degrade gracefully.** If delivering a rich approval prompt (e.g., Telegram inline buttons) fails, fall back to plain text with instructions (e.g., `Reply "yes" to approve`) — never auto-deny.
|
|
8
8
|
- **Non-rich channels** (http-api) receive plain-text approval prompts. The conversational approval engine handles free-text responses.
|
|
9
9
|
- **Race conditions:** Always check whether a decision has already been resolved before delivering the engine's optimistic reply. If `handleChannelDecision` returns `applied: false`, deliver an "already resolved" notice and return `stale_ignored`.
|
|
10
|
-
- **Unified guardian decision primitive:** All guardian decision paths (callback buttons, conversational engine, channel
|
|
10
|
+
- **Unified guardian decision primitive:** All guardian decision paths (callback buttons, conversational engine, channel text) must route through `applyGuardianDecision()` in `assistant/src/approvals/guardian-decision-primitive.ts`. Do not inline decision logic (CAS resolution, resolver dispatch, grant minting) at individual callsites.
|
|
11
11
|
|
|
12
12
|
## Single-Guardian Invariant
|
|
13
13
|
|
|
@@ -327,21 +327,6 @@ describe("request lookups", () => {
|
|
|
327
327
|
});
|
|
328
328
|
});
|
|
329
329
|
|
|
330
|
-
test("getPendingRequestByDestinationMessageOrNull maps the destination triple", async () => {
|
|
331
|
-
ipcResponse = null;
|
|
332
|
-
expect(
|
|
333
|
-
await client.getPendingRequestByDestinationMessageOrNull(
|
|
334
|
-
"telegram",
|
|
335
|
-
"chat-456",
|
|
336
|
-
"msg-1",
|
|
337
|
-
),
|
|
338
|
-
).toBeNull();
|
|
339
|
-
expect(ipcCalls[0]).toEqual({
|
|
340
|
-
method: "guardian_requests_get_by_destination_message",
|
|
341
|
-
params: { channel: "telegram", chatId: "chat-456", messageId: "msg-1" },
|
|
342
|
-
});
|
|
343
|
-
});
|
|
344
|
-
|
|
345
330
|
test("getPendingRequestByCallSession and getRequestByPendingQuestionOrNull map params", async () => {
|
|
346
331
|
const request = makeWireRequest({ callSessionId: "call-1" });
|
|
347
332
|
ipcResponse = request;
|
|
@@ -378,18 +363,11 @@ describe("request lookups", () => {
|
|
|
378
363
|
|
|
379
364
|
ipcError = new Error("gateway unavailable");
|
|
380
365
|
expect(await client.getGuardianRequestByCodeOrNull("AB12")).toBeNull();
|
|
381
|
-
expect(
|
|
382
|
-
await client.getPendingRequestByDestinationMessageOrNull(
|
|
383
|
-
"telegram",
|
|
384
|
-
"chat-456",
|
|
385
|
-
"msg-1",
|
|
386
|
-
),
|
|
387
|
-
).toBeNull();
|
|
388
366
|
expect(
|
|
389
367
|
await client.getPendingRequestByCallSessionOrNull("call-1"),
|
|
390
368
|
).toBeNull();
|
|
391
369
|
expect(await client.getRequestByPendingQuestionOrNull("pq-1")).toBeNull();
|
|
392
|
-
expect(warnCalls).toHaveLength(
|
|
370
|
+
expect(warnCalls).toHaveLength(4);
|
|
393
371
|
});
|
|
394
372
|
|
|
395
373
|
test("OrNull variants pass successful reads through untouched", async () => {
|
|
@@ -301,28 +301,6 @@ export async function listGuardianRequestDeliveriesByChat(
|
|
|
301
301
|
);
|
|
302
302
|
}
|
|
303
303
|
|
|
304
|
-
/**
|
|
305
|
-
* Reaction routing: the pending request whose delivered card is the
|
|
306
|
-
* reacted-to message. Throws on transport failure.
|
|
307
|
-
*/
|
|
308
|
-
async function getPendingRequestByDestinationMessage(
|
|
309
|
-
channel: string,
|
|
310
|
-
chatId: string,
|
|
311
|
-
messageId: string,
|
|
312
|
-
): Promise<GuardianRequestWire | null> {
|
|
313
|
-
return callGateway(
|
|
314
|
-
GUARDIAN_REQUESTS_IPC_METHODS.getByDestinationMessage,
|
|
315
|
-
{ channel, chatId, messageId },
|
|
316
|
-
GuardianRequestLookupIpcResponseSchema,
|
|
317
|
-
);
|
|
318
|
-
}
|
|
319
|
-
|
|
320
|
-
/** `getPendingRequestByDestinationMessage` deny-path variant: degrades to null. */
|
|
321
|
-
export const getPendingRequestByDestinationMessageOrNull = degradeOnFailure(
|
|
322
|
-
getPendingRequestByDestinationMessage,
|
|
323
|
-
null,
|
|
324
|
-
);
|
|
325
|
-
|
|
326
304
|
/**
|
|
327
305
|
* Reply routing: pending requests delivered to a destination conversation
|
|
328
306
|
* (`conversationId`, optionally narrowed by `channel`) or chat
|