@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.
Files changed (43) hide show
  1. package/ARCHITECTURE.md +1 -1
  2. package/docs/guardian-request-flow.md +13 -13
  3. package/node_modules/@vellumai/gateway-client/src/__tests__/guardian-request-contract.test.ts +0 -18
  4. package/node_modules/@vellumai/gateway-client/src/guardian-request-contract.ts +0 -21
  5. package/node_modules/@vellumai/gateway-client/src/index.ts +0 -2
  6. package/package.json +1 -1
  7. package/src/__tests__/guardian-card-withdrawal.test.ts +0 -22
  8. package/src/__tests__/guardian-gateway-sim.ts +0 -23
  9. package/src/__tests__/guardian-reply-router-answer-mode.test.ts +0 -2
  10. package/src/__tests__/guardian-routing-invariants.test.ts +0 -5
  11. package/src/__tests__/reaction-persistence.test.ts +1 -196
  12. package/src/__tests__/skill-load-tool.test.ts +21 -0
  13. package/src/__tests__/skills.test.ts +9 -1
  14. package/src/approvals/AGENTS.md +1 -1
  15. package/src/channels/__tests__/gateway-guardian-requests.test.ts +1 -23
  16. package/src/channels/gateway-guardian-requests.ts +0 -22
  17. package/src/cli/commands/db/__tests__/status.test.ts +22 -0
  18. package/src/cli/commands/db/index.help.ts +1 -1
  19. package/src/cli/commands/db/status.ts +172 -1
  20. package/src/config/bundled-skills/acp/SKILL.md +10 -3
  21. package/src/daemon/conversation-process.ts +0 -1
  22. package/src/notifications/AGENTS.md +1 -1
  23. package/src/notifications/adapters/discord.ts +1 -1
  24. package/src/notifications/guardian-delivery-recorder.ts +5 -9
  25. package/src/permissions/confirmation-guardian-request.ts +2 -2
  26. package/src/permissions/prompter.ts +1 -1
  27. package/src/persistence/schema-contract.test.ts +78 -0
  28. package/src/persistence/schema-contract.ts +96 -0
  29. package/src/runtime/AGENTS.md +1 -1
  30. package/src/runtime/channel-approval-types.ts +1 -5
  31. package/src/runtime/guardian-reply-router.ts +4 -75
  32. package/src/runtime/question-request-guardian-bridge.ts +1 -2
  33. package/src/runtime/routes/acp-routes.ts +1 -1
  34. package/src/runtime/routes/channel-route-shared.ts +0 -39
  35. package/src/runtime/routes/conversation-routes.ts +0 -1
  36. package/src/runtime/routes/guardian-approval-interception.ts +0 -5
  37. package/src/runtime/routes/inbound-message-handler.ts +4 -7
  38. package/src/runtime/routes/inbound-stages/guardian-reply-intercept.ts +23 -48
  39. package/src/runtime/routes/inbound-stages/reaction-intercept.test.ts +193 -90
  40. package/src/runtime/routes/inbound-stages/reaction-intercept.ts +28 -46
  41. package/src/tools/skills/load.ts +1 -1
  42. package/src/__tests__/slack-reaction-approvals.test.ts +0 -97
  43. 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 — 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 reactions/text.
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 / emoji reaction / "CODE <reply>" / bare text
31
+ ▼ user responds: button tap / "CODE <reply>" / bare text
32
32
  guardian reply router (runtime/guardian-reply-router.ts, invoked from
33
- reactions → callbacks → request codes → routes/inbound-stages/guardian-reply-intercept.ts,
34
- bare answer → explicit approve/reject → NL BEFORE background dispatch — replies to parked
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 | 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, reactions, modes) | per-feature inbound intercepts |
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 match reactions, scope plain-text replies and
190
- edit the decided card. It returns the address and its route together, since
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
@@ -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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vellumai/assistant",
3
- "version": "0.11.7-dev.202609012016.14eabff",
3
+ "version": "0.11.7-dev.202609012214.c97412d",
4
4
  "license": "MIT",
5
5
  "type": "module",
6
6
  "exports": {
@@ -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 ["set up", "install", "authenticate", "connect"]) {
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
  });
@@ -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 reactions and 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.
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(5);
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