@vellumai/assistant 0.12.4-dev.202609232017.d454a3d → 0.12.4-dev.202609232115.73e8b74

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 (121) hide show
  1. package/docs/architecture/memory.md +9 -1
  2. package/docs/architecture/scheduling.md +13 -5
  3. package/docs/guardian-request-flow.md +1 -1
  4. package/node_modules/@vellumai/ces-client/node_modules/@vellumai/service-contracts/src/__tests__/guardian-requests.test.ts +26 -0
  5. package/node_modules/@vellumai/ces-client/node_modules/@vellumai/service-contracts/src/guardian-requests.ts +77 -3
  6. package/node_modules/@vellumai/gateway-client/node_modules/@vellumai/service-contracts/src/__tests__/guardian-requests.test.ts +26 -0
  7. package/node_modules/@vellumai/gateway-client/node_modules/@vellumai/service-contracts/src/guardian-requests.ts +77 -3
  8. package/node_modules/@vellumai/gateway-client/src/outbound-contract.ts +2 -4
  9. package/node_modules/@vellumai/service-contracts/src/__tests__/guardian-requests.test.ts +26 -0
  10. package/node_modules/@vellumai/service-contracts/src/guardian-requests.ts +77 -3
  11. package/openapi.yaml +9 -1
  12. package/package.json +1 -1
  13. package/src/__tests__/background-shell-bash.test.ts +34 -0
  14. package/src/__tests__/background-shell-host-bash.test.ts +98 -1
  15. package/src/__tests__/background-tool-registry.test.ts +39 -0
  16. package/src/__tests__/client-os-metadata-persistence.test.ts +4 -20
  17. package/src/__tests__/conversation-agent-loop.test.ts +529 -1
  18. package/src/__tests__/conversation-pairing.test.ts +59 -0
  19. package/src/__tests__/conversation-queue.test.ts +257 -4
  20. package/src/__tests__/conversation-runtime-assembly.test.ts +27 -0
  21. package/src/__tests__/emit-signal-routing-intent.test.ts +62 -0
  22. package/src/__tests__/evict-conversations-for-reload.test.ts +21 -0
  23. package/src/__tests__/introduction-card-resolver.test.ts +53 -8
  24. package/src/__tests__/non-member-access-request.test.ts +48 -0
  25. package/src/__tests__/notification-events-source-context-probe.test.ts +48 -1
  26. package/src/__tests__/notification-platform-adapter.test.ts +97 -0
  27. package/src/__tests__/notification-vellum-adapter.test.ts +103 -3
  28. package/src/__tests__/scheduler-disk-pressure.test.ts +1 -0
  29. package/src/__tests__/scheduler-result-notification.test.ts +521 -7
  30. package/src/__tests__/scheduler-wake.test.ts +1 -0
  31. package/src/__tests__/subagent-cron-run-attribution.test.ts +41 -2
  32. package/src/__tests__/subagent-manager-notify.test.ts +48 -0
  33. package/src/__tests__/trusted-contact-inline-approval-integration.test.ts +6 -6
  34. package/src/__tests__/wake-schedule-escalation.test.ts +1 -0
  35. package/src/api/index.ts +4 -0
  36. package/src/api/requests/guardian-actions.test.ts +45 -0
  37. package/src/api/requests/guardian-actions.ts +20 -0
  38. package/src/approvals/guardian-decision-primitive.ts +6 -4
  39. package/src/approvals/guardian-request-resolvers.ts +35 -30
  40. package/src/calls/__tests__/voice-leg-coordinator.test.ts +28 -0
  41. package/src/calls/__tests__/voice-session-bridge.test.ts +17 -0
  42. package/src/calls/__tests__/voice-triage-escalate.test.ts +8 -0
  43. package/src/calls/voice-control-protocol.ts +3 -0
  44. package/src/calls/voice-leg-coordinator.ts +8 -1
  45. package/src/calls/voice-session-bridge.ts +13 -0
  46. package/src/calls/voice-triage-escalate.ts +26 -6
  47. package/src/daemon/__tests__/abort-null-controller.test.ts +135 -1
  48. package/src/daemon/__tests__/turn-tail-assistant-reply-notify.test.ts +16 -0
  49. package/src/daemon/conversation-agent-loop.ts +40 -22
  50. package/src/daemon/conversation-lifecycle.ts +70 -7
  51. package/src/daemon/conversation-messaging.ts +31 -2
  52. package/src/daemon/conversation-process.ts +107 -16
  53. package/src/daemon/conversation-queue-manager.ts +7 -0
  54. package/src/daemon/conversation-runtime-assembly.ts +1 -0
  55. package/src/daemon/conversation-turn-finalize.ts +14 -0
  56. package/src/daemon/conversation.ts +14 -0
  57. package/src/daemon/wake-conversation-ops.ts +4 -2
  58. package/src/live-voice/__tests__/live-voice-triage-escalate.test.ts +11 -0
  59. package/src/live-voice/live-voice-session.ts +2 -0
  60. package/src/messaging/providers/discord/withdraw.ts +4 -5
  61. package/src/messaging/providers/slack/withdraw.ts +5 -5
  62. package/src/messaging/providers/telegram-bot/withdraw.ts +4 -5
  63. package/src/notifications/README.md +77 -6
  64. package/src/notifications/__tests__/assistant-reply-producer.test.ts +377 -119
  65. package/src/notifications/__tests__/background-result-producer.test.ts +1272 -0
  66. package/src/notifications/__tests__/broadcaster.test.ts +292 -1
  67. package/src/notifications/__tests__/completion-policy.test.ts +243 -0
  68. package/src/notifications/__tests__/decision-engine.test.ts +51 -4
  69. package/src/notifications/__tests__/has-pending-background-work.test.ts +281 -0
  70. package/src/notifications/__tests__/home-feed-side-effect.test.ts +74 -0
  71. package/src/notifications/__tests__/resolve-visible-in-source.test.ts +110 -1
  72. package/src/notifications/__tests__/schedule-result-producer.test.ts +11 -4
  73. package/src/notifications/adapters/macos.ts +24 -12
  74. package/src/notifications/adapters/platform.ts +16 -5
  75. package/src/notifications/assistant-reply-producer.ts +96 -71
  76. package/src/notifications/background-result-producer.ts +369 -0
  77. package/src/notifications/broadcaster.ts +28 -8
  78. package/src/notifications/completion-policy.ts +115 -0
  79. package/src/notifications/completion-work.ts +46 -0
  80. package/src/notifications/conversation-pairing.ts +10 -6
  81. package/src/notifications/decision-engine.ts +18 -24
  82. package/src/notifications/emit-signal.ts +16 -1
  83. package/src/notifications/events-store.ts +4 -0
  84. package/src/notifications/has-pending-background-work.ts +91 -0
  85. package/src/notifications/home-feed-mirror.ts +6 -1
  86. package/src/notifications/home-feed-side-effect.ts +8 -9
  87. package/src/notifications/resolve-visible-in-source.ts +56 -2
  88. package/src/notifications/result-output.ts +154 -0
  89. package/src/notifications/schedule-result-producer.ts +6 -187
  90. package/src/notifications/types.ts +2 -0
  91. package/src/persistence/__tests__/recent-conversation-messages.test.ts +41 -0
  92. package/src/persistence/conversation-crud.ts +24 -0
  93. package/src/persistence/conversation-types.ts +1 -35
  94. package/src/plugins/defaults/memory/__tests__/turn-retrieval.test.ts +21 -0
  95. package/src/plugins/defaults/memory/hooks/user-prompt-submit.ts +7 -3
  96. package/src/plugins/defaults/memory/turn-retrieval.ts +8 -0
  97. package/src/plugins/defaults/memory/v3/__tests__/injection.test.ts +24 -0
  98. package/src/plugins/defaults/memory/v3/injector.ts +4 -4
  99. package/src/plugins/types.ts +2 -0
  100. package/src/runtime/__tests__/agent-wake.test.ts +308 -0
  101. package/src/runtime/access-request-helper.ts +16 -9
  102. package/src/runtime/agent-wake-queue.ts +52 -0
  103. package/src/runtime/agent-wake.ts +110 -16
  104. package/src/runtime/channel-approval-types.ts +6 -38
  105. package/src/runtime/channel-approvals.ts +4 -5
  106. package/src/runtime/introduction-policy.test.ts +17 -1
  107. package/src/runtime/introduction-policy.ts +25 -7
  108. package/src/runtime/routes/guardian-action-routes.ts +8 -23
  109. package/src/runtime/web-presence.ts +3 -4
  110. package/src/schedule/schedule-store.ts +10 -0
  111. package/src/schedule/scheduler.ts +83 -24
  112. package/src/subagent/manager.ts +32 -12
  113. package/src/telemetry/telemetry-wire-source.json +1 -1
  114. package/src/telemetry/telemetry-wire.generated.ts +1 -0
  115. package/src/tools/background-tool-registry.ts +30 -3
  116. package/src/tools/host-terminal/host-shell.ts +7 -0
  117. package/src/tools/terminal/shell-launch.test.ts +27 -1
  118. package/src/tools/terminal/shell.ts +4 -0
  119. package/src/util/abort-reasons.ts +2 -0
  120. package/src/util/single-flight.test.ts +6 -0
  121. package/src/util/single-flight.ts +2 -0
@@ -214,7 +214,15 @@ static substrate context remain available. When the answer depends on a saved pe
214
214
  fact that is absent from that context, the front-door rule escalates instead of
215
215
  guessing.
216
216
 
217
- The escalated leg runs the ordinary memory pipeline before the quality model.
217
+ The escalated leg normally runs the memory pipeline before the quality model.
218
+ During screen sharing, the front door can emit `[ESCALATE_SCREEN]` for an
219
+ annotation or immediate on-screen action answerable from the screen and
220
+ current conversation. The bridge skips fresh retrieval only when that verdict
221
+ and an active screen share are both present. This per-turn policy gates the
222
+ legacy hook and both v3 injectors, including re-injection, and clears at turn
223
+ release. Static context and historical memory blocks remain available.
224
+ Requests needing saved facts or preferences absent from context, uncertain
225
+ classifications, and ordinary `[ESCALATE]` verdicts retain retrieval.
218
226
  V3 retrieval routes on the latest visible caller message, ignoring the hidden
219
227
  continuation message used to start that leg. The selector uses low effort to
220
228
  keep retrieval latency bounded. This keeps current-turn memory work off the
@@ -2,6 +2,12 @@
2
2
 
3
3
  Recurring schedules, watchers, and queued task execution architecture.
4
4
 
5
+ ## Execute-mode completion and time limits
6
+
7
+ An execute-mode run owns its automatic result notification, including results from background commands and subagents. The scheduler waits for the firing's own active turns, queued continuations, commands, child tasks, and command wakes to settle before marking the run successful and emitting `schedule.result`. Unrelated work in a reused conversation does not extend the wait. Ownership is recorded before asynchronous turn setup, covers dequeued messages while their processing claim and persistence are pending, and follows wakes through queueing, hydration, and execution. Dequeued dispatches retain a cancellation signal for teardown until the active turn takes over. User interrupts preserve accepted user prompts during that handoff; started turns remain cancellable. Background work retains its original timing through queueing, dispatch, active processing, and command wakes. The reply-notification gate and persisted-history check apply that timing to suppress stale kickoff alerts without hiding replies to newer user requests. Quiet runs skip this fallback, and explicit successful delivery suppresses duplicates.
8
+
9
+ The wait shares the run's `timeouts.scheduleTurnTimeoutSec` budget. If delegated work exceeds that budget, the scheduler records an error, cancels only commands, child tasks, queued continuations, and the active turn owned by that run. User work and other runs in the same conversation remain intact. The attempt follows the existing retry and exhaustion-alert policy; partial output never emits a success notification. Scheduled command wakes carry `cronRunId`, check the persisted run status before starting, and observe cancellation while running, so a late callback cannot restart a failed attempt.
10
+
5
11
  ## Recurrence Schedules — Cron and RRULE Dual-Syntax Engine
6
12
 
7
13
  The scheduler supports two recurrence syntaxes for recurring tasks:
@@ -88,11 +94,13 @@ sequenceDiagram
88
94
 
89
95
  The `enforceRoutingIntent()` step runs after the LLM produces a channel selection but before deterministic checks. It acts as a post-decision guard:
90
96
 
91
- | Intent | Enforcement Rule |
92
- | ---------------- | ------------------------------------------------------------------------------------------------- |
93
- | `single_channel` | No override. The LLM's channel selection stands. |
94
- | `multi_channel` | If the LLM selected < 2 channels and 2+ are connected, expand to at least two connected channels. |
95
- | `all_channels` | Replace the LLM's selection with all connected channels. |
97
+ | Intent | Enforcement Rule |
98
+ | ---------------- | ---------------------------------------------------------------------------------------------------------------------------- |
99
+ | `single_channel` | Cap delivery to one channel: the signal's source channel when it is connected, otherwise the first channel the LLM selected. |
100
+ | `multi_channel` | If the LLM selected < 2 channels and 2+ are connected, expand to at least two connected channels. |
101
+ | `all_channels` | Replace the LLM's selection with all connected channels. |
102
+
103
+ A decision the LLM suppressed (`shouldNotify: false`) is left alone under every intent. A signal with no routing intent keeps the LLM's selection.
96
104
 
97
105
  When enforcement changes the decision, the updated `selectedChannels` and annotated `reasoningSummary` are re-persisted to `notification_decisions` so the audit trail reflects what was actually dispatched.
98
106
 
@@ -24,7 +24,7 @@ anti-pattern was retired in #35642 and again in the ask_question redesign).
24
24
  (generic actions[] + plainTextFallback) resolveQuestionContext)
25
25
  │
26
26
  ▼ per-channel rendering ONLY
27
- channel adapters (notifications/adapters/{telegram,slack,macos,platform},
27
+ channel adapters (notifications/adapters/{telegram,slack,discord,macos,platform},
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
  │
@@ -0,0 +1,26 @@
1
+ import { describe, expect, test } from "bun:test";
2
+
3
+ import {
4
+ GUARDIAN_DECISION_ACTION_IDS,
5
+ isDenyingGuardianAction,
6
+ isParkGuardianAction,
7
+ } from "../guardian-requests.js";
8
+
9
+ describe("guardian decision actions", () => {
10
+ test("reject, leave_unverified and block deny; every other action approves", () => {
11
+ const denying = GUARDIAN_DECISION_ACTION_IDS.filter(
12
+ isDenyingGuardianAction,
13
+ );
14
+ expect(denying).toEqual(["reject", "leave_unverified", "block"]);
15
+ });
16
+
17
+ test("only leave_unverified parks the sender", () => {
18
+ const parking = GUARDIAN_DECISION_ACTION_IDS.filter(isParkGuardianAction);
19
+ expect(parking).toEqual(["leave_unverified"]);
20
+ });
21
+
22
+ test("an absent or unknown action neither denies nor parks", () => {
23
+ expect(isDenyingGuardianAction(undefined)).toBe(false);
24
+ expect(isParkGuardianAction("approve_always")).toBe(false);
25
+ });
26
+ });
@@ -1,7 +1,8 @@
1
1
  /**
2
- * Canonical vocabulary for a guardian request's lifecycle status, shared by
3
- * the gateway that owns the request rows, the daemon that decides and
4
- * projects them, and the web that renders the projection.
2
+ * Canonical vocabulary for guardian requests, shared by the gateway that owns
3
+ * the request rows, the daemon that decides and projects them, and the web
4
+ * that renders the projection: a request's lifecycle status, the actions a
5
+ * guardian can decide it with, and the weight a card gives each action.
5
6
  */
6
7
  import { z } from "zod";
7
8
 
@@ -16,3 +17,76 @@ export const GuardianRequestStatusSchema = z.enum(
16
17
  GUARDIAN_REQUEST_STATUS_VALUES,
17
18
  );
18
19
  export type GuardianRequestStatus = z.infer<typeof GuardianRequestStatusSchema>;
20
+
21
+ /**
22
+ * The actions a guardian can decide a request with.
23
+ *
24
+ * `approve_once` / `reject` are the generic decision pair used by every
25
+ * request kind. `trust` / `verify_code` / `leave_unverified` / `block` are the
26
+ * introduction-card actions, valid only for `access_request` requests.
27
+ */
28
+ export const GUARDIAN_DECISION_ACTION_IDS = [
29
+ "approve_once",
30
+ "reject",
31
+ "trust",
32
+ "verify_code",
33
+ "leave_unverified",
34
+ "block",
35
+ ] as const;
36
+ export const GuardianDecisionActionIdSchema = z.enum(
37
+ GUARDIAN_DECISION_ACTION_IDS,
38
+ );
39
+ export type GuardianDecisionActionId = z.infer<
40
+ typeof GuardianDecisionActionIdSchema
41
+ >;
42
+
43
+ /** Actions that resolve a request to `denied`; every other one approves. */
44
+ export const GUARDIAN_DENYING_ACTION_VALUES = [
45
+ "reject",
46
+ "leave_unverified",
47
+ "block",
48
+ ] as const satisfies readonly GuardianDecisionActionId[];
49
+
50
+ /**
51
+ * The denying actions that park the sender at `unverified`: a neutral hold,
52
+ * not a rejection. A parked contact is still admitted under the permissive
53
+ * admission floors (`any_contact`, `strangers`) and can be trusted or verified
54
+ * later; contrast `block` (revoked, a hard keep-out) and `reject` (an explicit
55
+ * decline). All three resolve the request to `denied`, so a resolved card
56
+ * consults this to read a park neutrally rather than as a denial.
57
+ */
58
+ export const GUARDIAN_PARK_ACTION_VALUES = [
59
+ "leave_unverified",
60
+ ] as const satisfies readonly GuardianDecisionActionId[];
61
+
62
+ const DENYING_ACTIONS: ReadonlySet<string> = new Set(
63
+ GUARDIAN_DENYING_ACTION_VALUES,
64
+ );
65
+ const PARK_ACTIONS: ReadonlySet<string> = new Set(GUARDIAN_PARK_ACTION_VALUES);
66
+
67
+ /** True when `action` resolves a request to `denied`. */
68
+ export function isDenyingGuardianAction(action: string | undefined): boolean {
69
+ return action !== undefined && DENYING_ACTIONS.has(action);
70
+ }
71
+
72
+ /** True when `action` parks the sender at `unverified`. */
73
+ export function isParkGuardianAction(action: string | undefined): boolean {
74
+ return action !== undefined && PARK_ACTIONS.has(action);
75
+ }
76
+
77
+ /**
78
+ * Surface-agnostic weight of a card action. Each renderer translates it to
79
+ * its own token (Slack primary/danger, a web button variant); absent means the
80
+ * renderer's default.
81
+ */
82
+ export const GUARDIAN_ACTION_EMPHASIS_VALUES = [
83
+ "primary",
84
+ "secondary",
85
+ "destructive",
86
+ ] as const;
87
+ export const GuardianActionEmphasisSchema = z.enum(
88
+ GUARDIAN_ACTION_EMPHASIS_VALUES,
89
+ );
90
+ export type GuardianActionEmphasis = z.infer<
91
+ typeof GuardianActionEmphasisSchema
92
+ >;
@@ -0,0 +1,26 @@
1
+ import { describe, expect, test } from "bun:test";
2
+
3
+ import {
4
+ GUARDIAN_DECISION_ACTION_IDS,
5
+ isDenyingGuardianAction,
6
+ isParkGuardianAction,
7
+ } from "../guardian-requests.js";
8
+
9
+ describe("guardian decision actions", () => {
10
+ test("reject, leave_unverified and block deny; every other action approves", () => {
11
+ const denying = GUARDIAN_DECISION_ACTION_IDS.filter(
12
+ isDenyingGuardianAction,
13
+ );
14
+ expect(denying).toEqual(["reject", "leave_unverified", "block"]);
15
+ });
16
+
17
+ test("only leave_unverified parks the sender", () => {
18
+ const parking = GUARDIAN_DECISION_ACTION_IDS.filter(isParkGuardianAction);
19
+ expect(parking).toEqual(["leave_unverified"]);
20
+ });
21
+
22
+ test("an absent or unknown action neither denies nor parks", () => {
23
+ expect(isDenyingGuardianAction(undefined)).toBe(false);
24
+ expect(isParkGuardianAction("approve_always")).toBe(false);
25
+ });
26
+ });
@@ -1,7 +1,8 @@
1
1
  /**
2
- * Canonical vocabulary for a guardian request's lifecycle status, shared by
3
- * the gateway that owns the request rows, the daemon that decides and
4
- * projects them, and the web that renders the projection.
2
+ * Canonical vocabulary for guardian requests, shared by the gateway that owns
3
+ * the request rows, the daemon that decides and projects them, and the web
4
+ * that renders the projection: a request's lifecycle status, the actions a
5
+ * guardian can decide it with, and the weight a card gives each action.
5
6
  */
6
7
  import { z } from "zod";
7
8
 
@@ -16,3 +17,76 @@ export const GuardianRequestStatusSchema = z.enum(
16
17
  GUARDIAN_REQUEST_STATUS_VALUES,
17
18
  );
18
19
  export type GuardianRequestStatus = z.infer<typeof GuardianRequestStatusSchema>;
20
+
21
+ /**
22
+ * The actions a guardian can decide a request with.
23
+ *
24
+ * `approve_once` / `reject` are the generic decision pair used by every
25
+ * request kind. `trust` / `verify_code` / `leave_unverified` / `block` are the
26
+ * introduction-card actions, valid only for `access_request` requests.
27
+ */
28
+ export const GUARDIAN_DECISION_ACTION_IDS = [
29
+ "approve_once",
30
+ "reject",
31
+ "trust",
32
+ "verify_code",
33
+ "leave_unverified",
34
+ "block",
35
+ ] as const;
36
+ export const GuardianDecisionActionIdSchema = z.enum(
37
+ GUARDIAN_DECISION_ACTION_IDS,
38
+ );
39
+ export type GuardianDecisionActionId = z.infer<
40
+ typeof GuardianDecisionActionIdSchema
41
+ >;
42
+
43
+ /** Actions that resolve a request to `denied`; every other one approves. */
44
+ export const GUARDIAN_DENYING_ACTION_VALUES = [
45
+ "reject",
46
+ "leave_unverified",
47
+ "block",
48
+ ] as const satisfies readonly GuardianDecisionActionId[];
49
+
50
+ /**
51
+ * The denying actions that park the sender at `unverified`: a neutral hold,
52
+ * not a rejection. A parked contact is still admitted under the permissive
53
+ * admission floors (`any_contact`, `strangers`) and can be trusted or verified
54
+ * later; contrast `block` (revoked, a hard keep-out) and `reject` (an explicit
55
+ * decline). All three resolve the request to `denied`, so a resolved card
56
+ * consults this to read a park neutrally rather than as a denial.
57
+ */
58
+ export const GUARDIAN_PARK_ACTION_VALUES = [
59
+ "leave_unverified",
60
+ ] as const satisfies readonly GuardianDecisionActionId[];
61
+
62
+ const DENYING_ACTIONS: ReadonlySet<string> = new Set(
63
+ GUARDIAN_DENYING_ACTION_VALUES,
64
+ );
65
+ const PARK_ACTIONS: ReadonlySet<string> = new Set(GUARDIAN_PARK_ACTION_VALUES);
66
+
67
+ /** True when `action` resolves a request to `denied`. */
68
+ export function isDenyingGuardianAction(action: string | undefined): boolean {
69
+ return action !== undefined && DENYING_ACTIONS.has(action);
70
+ }
71
+
72
+ /** True when `action` parks the sender at `unverified`. */
73
+ export function isParkGuardianAction(action: string | undefined): boolean {
74
+ return action !== undefined && PARK_ACTIONS.has(action);
75
+ }
76
+
77
+ /**
78
+ * Surface-agnostic weight of a card action. Each renderer translates it to
79
+ * its own token (Slack primary/danger, a web button variant); absent means the
80
+ * renderer's default.
81
+ */
82
+ export const GUARDIAN_ACTION_EMPHASIS_VALUES = [
83
+ "primary",
84
+ "secondary",
85
+ "destructive",
86
+ ] as const;
87
+ export const GuardianActionEmphasisSchema = z.enum(
88
+ GUARDIAN_ACTION_EMPHASIS_VALUES,
89
+ );
90
+ export type GuardianActionEmphasis = z.infer<
91
+ typeof GuardianActionEmphasisSchema
92
+ >;
@@ -15,6 +15,7 @@
15
15
  */
16
16
 
17
17
  import type { KnownBlock } from "@slack/types";
18
+ import { GuardianActionEmphasisSchema } from "@vellumai/service-contracts/guardian-requests";
18
19
  import { z } from "zod";
19
20
 
20
21
  // ---------------------------------------------------------------------------
@@ -41,10 +42,7 @@ export type AttachmentMetadata = z.infer<typeof AttachmentMetadataSchema>;
41
42
  export const ApprovalActionOptionSchema = z.object({
42
43
  id: z.string(),
43
44
  label: z.string(),
44
- // Surface-agnostic button weight. Renderers translate it to their platform
45
- // token (Slack primary/danger, Surface primary/destructive); absent means
46
- // the renderer applies its own default styling.
47
- emphasis: z.enum(["primary", "secondary", "destructive"]).optional(),
45
+ emphasis: GuardianActionEmphasisSchema.optional(),
48
46
  });
49
47
 
50
48
  export type ApprovalActionOption = z.infer<typeof ApprovalActionOptionSchema>;
@@ -0,0 +1,26 @@
1
+ import { describe, expect, test } from "bun:test";
2
+
3
+ import {
4
+ GUARDIAN_DECISION_ACTION_IDS,
5
+ isDenyingGuardianAction,
6
+ isParkGuardianAction,
7
+ } from "../guardian-requests.js";
8
+
9
+ describe("guardian decision actions", () => {
10
+ test("reject, leave_unverified and block deny; every other action approves", () => {
11
+ const denying = GUARDIAN_DECISION_ACTION_IDS.filter(
12
+ isDenyingGuardianAction,
13
+ );
14
+ expect(denying).toEqual(["reject", "leave_unverified", "block"]);
15
+ });
16
+
17
+ test("only leave_unverified parks the sender", () => {
18
+ const parking = GUARDIAN_DECISION_ACTION_IDS.filter(isParkGuardianAction);
19
+ expect(parking).toEqual(["leave_unverified"]);
20
+ });
21
+
22
+ test("an absent or unknown action neither denies nor parks", () => {
23
+ expect(isDenyingGuardianAction(undefined)).toBe(false);
24
+ expect(isParkGuardianAction("approve_always")).toBe(false);
25
+ });
26
+ });
@@ -1,7 +1,8 @@
1
1
  /**
2
- * Canonical vocabulary for a guardian request's lifecycle status, shared by
3
- * the gateway that owns the request rows, the daemon that decides and
4
- * projects them, and the web that renders the projection.
2
+ * Canonical vocabulary for guardian requests, shared by the gateway that owns
3
+ * the request rows, the daemon that decides and projects them, and the web
4
+ * that renders the projection: a request's lifecycle status, the actions a
5
+ * guardian can decide it with, and the weight a card gives each action.
5
6
  */
6
7
  import { z } from "zod";
7
8
 
@@ -16,3 +17,76 @@ export const GuardianRequestStatusSchema = z.enum(
16
17
  GUARDIAN_REQUEST_STATUS_VALUES,
17
18
  );
18
19
  export type GuardianRequestStatus = z.infer<typeof GuardianRequestStatusSchema>;
20
+
21
+ /**
22
+ * The actions a guardian can decide a request with.
23
+ *
24
+ * `approve_once` / `reject` are the generic decision pair used by every
25
+ * request kind. `trust` / `verify_code` / `leave_unverified` / `block` are the
26
+ * introduction-card actions, valid only for `access_request` requests.
27
+ */
28
+ export const GUARDIAN_DECISION_ACTION_IDS = [
29
+ "approve_once",
30
+ "reject",
31
+ "trust",
32
+ "verify_code",
33
+ "leave_unverified",
34
+ "block",
35
+ ] as const;
36
+ export const GuardianDecisionActionIdSchema = z.enum(
37
+ GUARDIAN_DECISION_ACTION_IDS,
38
+ );
39
+ export type GuardianDecisionActionId = z.infer<
40
+ typeof GuardianDecisionActionIdSchema
41
+ >;
42
+
43
+ /** Actions that resolve a request to `denied`; every other one approves. */
44
+ export const GUARDIAN_DENYING_ACTION_VALUES = [
45
+ "reject",
46
+ "leave_unverified",
47
+ "block",
48
+ ] as const satisfies readonly GuardianDecisionActionId[];
49
+
50
+ /**
51
+ * The denying actions that park the sender at `unverified`: a neutral hold,
52
+ * not a rejection. A parked contact is still admitted under the permissive
53
+ * admission floors (`any_contact`, `strangers`) and can be trusted or verified
54
+ * later; contrast `block` (revoked, a hard keep-out) and `reject` (an explicit
55
+ * decline). All three resolve the request to `denied`, so a resolved card
56
+ * consults this to read a park neutrally rather than as a denial.
57
+ */
58
+ export const GUARDIAN_PARK_ACTION_VALUES = [
59
+ "leave_unverified",
60
+ ] as const satisfies readonly GuardianDecisionActionId[];
61
+
62
+ const DENYING_ACTIONS: ReadonlySet<string> = new Set(
63
+ GUARDIAN_DENYING_ACTION_VALUES,
64
+ );
65
+ const PARK_ACTIONS: ReadonlySet<string> = new Set(GUARDIAN_PARK_ACTION_VALUES);
66
+
67
+ /** True when `action` resolves a request to `denied`. */
68
+ export function isDenyingGuardianAction(action: string | undefined): boolean {
69
+ return action !== undefined && DENYING_ACTIONS.has(action);
70
+ }
71
+
72
+ /** True when `action` parks the sender at `unverified`. */
73
+ export function isParkGuardianAction(action: string | undefined): boolean {
74
+ return action !== undefined && PARK_ACTIONS.has(action);
75
+ }
76
+
77
+ /**
78
+ * Surface-agnostic weight of a card action. Each renderer translates it to
79
+ * its own token (Slack primary/danger, a web button variant); absent means the
80
+ * renderer's default.
81
+ */
82
+ export const GUARDIAN_ACTION_EMPHASIS_VALUES = [
83
+ "primary",
84
+ "secondary",
85
+ "destructive",
86
+ ] as const;
87
+ export const GuardianActionEmphasisSchema = z.enum(
88
+ GUARDIAN_ACTION_EMPHASIS_VALUES,
89
+ );
90
+ export type GuardianActionEmphasis = z.infer<
91
+ typeof GuardianActionEmphasisSchema
92
+ >;
package/openapi.yaml CHANGED
@@ -14106,7 +14106,7 @@ paths:
14106
14106
  post:
14107
14107
  operationId: guardianactions_decision_post
14108
14108
  summary: Submit guardian decision
14109
- description: Submit a guardian action decision (approve/reject).
14109
+ description: Submit a guardian decision on a pending request.
14110
14110
  tags:
14111
14111
  - guardian
14112
14112
  requestBody:
@@ -14118,9 +14118,17 @@ paths:
14118
14118
  properties:
14119
14119
  requestId:
14120
14120
  type: string
14121
+ minLength: 1
14121
14122
  description: Guardian request ID
14122
14123
  action:
14123
14124
  type: string
14125
+ enum:
14126
+ - approve_once
14127
+ - reject
14128
+ - trust
14129
+ - verify_code
14130
+ - leave_unverified
14131
+ - block
14124
14132
  description: Decision action
14125
14133
  conversationId:
14126
14134
  type: string
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vellumai/assistant",
3
- "version": "0.12.4-dev.202609232017.d454a3d",
3
+ "version": "0.12.4-dev.202609232115.73e8b74",
4
4
  "license": "MIT",
5
5
  "type": "module",
6
6
  "exports": {
@@ -2,6 +2,7 @@ import { afterEach, beforeEach, describe, expect, mock, test } from "bun:test";
2
2
 
3
3
  import type { WakeOptions } from "../runtime/agent-wake.js";
4
4
  import type { BackgroundTool } from "../tools/background-tool-registry.js";
5
+ import type { ToolContext } from "../tools/types.js";
5
6
 
6
7
  // ── Mock modules ────────────────────────────────────────────────────────────
7
8
 
@@ -239,4 +240,37 @@ describe("bash tool background mode", () => {
239
240
  expect(mockRegisterBackgroundTool).not.toHaveBeenCalled();
240
241
  expect(mockWakeAgentForOpportunity).not.toHaveBeenCalled();
241
242
  });
243
+
244
+ for (const cronRunId of [undefined, null, "cron-run-123"]) {
245
+ for (const outcome of ["completed", "failed", "cancelled"] as const) {
246
+ test(`preserves originating schedule ${String(cronRunId)} on ${outcome}`, async () => {
247
+ const context: ToolContext = {
248
+ ...baseContext,
249
+ cronRunId,
250
+ };
251
+ await shellTool.execute(
252
+ {
253
+ command:
254
+ outcome === "cancelled"
255
+ ? "sleep 30"
256
+ : `sleep 0.1; exit ${outcome === "failed" ? "1" : "0"}`,
257
+ activity: "test",
258
+ background: true,
259
+ },
260
+ context,
261
+ );
262
+ context.cronRunId = "cron-run-later";
263
+ expect(registeredTools[0]!.cronRunId).toBe(cronRunId ?? undefined);
264
+ if (outcome === "cancelled") {
265
+ registeredTools[0]!.cancel();
266
+ }
267
+ await waitForWake(mockWakeAgentForOpportunity);
268
+
269
+ expect(mockWakeAgentForOpportunity).toHaveBeenCalledTimes(1);
270
+ const wake = mockWakeAgentForOpportunity.mock.calls[0]![0];
271
+ expect(wake.cronRunId).toBe(cronRunId ?? undefined);
272
+ expect(wake.backgroundToolCompletion?.status).toBe(outcome);
273
+ });
274
+ }
275
+ }
242
276
  });
@@ -84,7 +84,11 @@ mock.module("../daemon/host-bash-proxy.js", () => ({
84
84
  // Import under test — MUST come after mock.module calls.
85
85
  // ---------------------------------------------------------------------------
86
86
 
87
- import type { CompletedBackgroundTool } from "../tools/background-tool-registry.js";
87
+ import type { WakeOptions } from "../runtime/agent-wake.js";
88
+ import type {
89
+ BackgroundTool,
90
+ CompletedBackgroundTool,
91
+ } from "../tools/background-tool-registry.js";
88
92
  import { hostShellTool } from "../tools/host-terminal/host-shell.js";
89
93
  import type { ToolContext, ToolExecutionResult } from "../tools/types.js";
90
94
 
@@ -148,6 +152,56 @@ afterEach(() => {
148
152
  // ---------------------------------------------------------------------------
149
153
 
150
154
  describe("host_bash background mode — proxy path", () => {
155
+ for (const cronRunId of [undefined, null, "cron-run-123"]) {
156
+ for (const outcome of [
157
+ "completed",
158
+ "failed",
159
+ "rejected",
160
+ "cancelled",
161
+ "cancelled-rejection",
162
+ ] as const) {
163
+ test(`preserves originating schedule ${String(cronRunId)} on ${outcome}`, async () => {
164
+ mockProxyAvailable = true;
165
+ const result = Promise.withResolvers<ToolExecutionResult>();
166
+ mockProxyRequestImpl = () => result.promise;
167
+ const context = makeContext({ cronRunId });
168
+
169
+ await hostShellTool.execute(
170
+ { command: "echo bg-proxy", background: true },
171
+ context,
172
+ );
173
+ context.cronRunId = "cron-run-later";
174
+ const ownedCommand = (
175
+ mockRegisterBackgroundTool.mock.calls as unknown[][]
176
+ )[0]![0] as BackgroundTool;
177
+ expect(ownedCommand.cronRunId).toBe(cronRunId ?? undefined);
178
+ const cancelled =
179
+ outcome === "cancelled" || outcome === "cancelled-rejection";
180
+ if (cancelled) {
181
+ const registered = (
182
+ mockRegisterBackgroundTool.mock.calls as unknown[][]
183
+ )[0]![0] as BackgroundTool;
184
+ registered.cancel();
185
+ }
186
+ if (outcome === "rejected" || outcome === "cancelled-rejection") {
187
+ result.reject(new Error("proxy transport error"));
188
+ } else {
189
+ result.resolve({ content: "done", isError: outcome === "failed" });
190
+ }
191
+ await new Promise((resolve) => setTimeout(resolve, 10));
192
+
193
+ expect(mockWakeAgentForOpportunity).toHaveBeenCalledTimes(1);
194
+ const wake = (
195
+ mockWakeAgentForOpportunity.mock.calls as unknown[][]
196
+ )[0]![0] as WakeOptions;
197
+ expect(wake.cronRunId).toBe(cronRunId ?? undefined);
198
+ expect(wake.backgroundToolCompletion?.status).toBe(
199
+ cancelled ? "cancelled" : outcome === "rejected" ? "failed" : outcome,
200
+ );
201
+ });
202
+ }
203
+ }
204
+
151
205
  test("returns immediately with backgrounded response", async () => {
152
206
  const proxyResult: ToolExecutionResult = {
153
207
  content: "proxy output",
@@ -311,6 +365,49 @@ describe("host_bash background mode — proxy path", () => {
311
365
  // ---------------------------------------------------------------------------
312
366
 
313
367
  describe("host_bash background mode — direct execution path", () => {
368
+ for (const cronRunId of [undefined, null, "cron-run-123"]) {
369
+ for (const outcome of [
370
+ "completed",
371
+ "failed",
372
+ "spawn-error",
373
+ "cancelled",
374
+ ] as const) {
375
+ test(`preserves originating schedule ${String(cronRunId)} on ${outcome}`, async () => {
376
+ const context = makeContext({ cronRunId });
377
+ await hostShellTool.execute(
378
+ { command: "echo bg-local", background: true },
379
+ context,
380
+ );
381
+ context.cronRunId = "cron-run-later";
382
+ const ownedCommand = (
383
+ mockRegisterBackgroundTool.mock.calls as unknown[][]
384
+ )[0]![0] as BackgroundTool;
385
+ expect(ownedCommand.cronRunId).toBe(cronRunId ?? undefined);
386
+ if (outcome === "cancelled") {
387
+ const registered = (
388
+ mockRegisterBackgroundTool.mock.calls as unknown[][]
389
+ )[0]![0] as BackgroundTool;
390
+ registered.cancel();
391
+ }
392
+ if (outcome === "spawn-error") {
393
+ latestChild!.emit("error", new Error("spawn ENOENT"));
394
+ latestChild!.emit("close", null);
395
+ } else {
396
+ latestChild!.emit("close", outcome === "failed" ? 1 : 0);
397
+ }
398
+
399
+ expect(mockWakeAgentForOpportunity).toHaveBeenCalledTimes(1);
400
+ const wake = (
401
+ mockWakeAgentForOpportunity.mock.calls as unknown[][]
402
+ )[0]![0] as WakeOptions;
403
+ expect(wake.cronRunId).toBe(cronRunId ?? undefined);
404
+ expect(wake.backgroundToolCompletion?.status).toBe(
405
+ outcome === "spawn-error" ? "failed" : outcome,
406
+ );
407
+ });
408
+ }
409
+ }
410
+
314
411
  test("returns immediately with backgrounded response", async () => {
315
412
  const ctx = makeContext();
316
413