@vellumai/assistant 0.11.8-dev.202609021923.ac39943 → 0.11.8-dev.202609022016.7bd8f87

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 (58) hide show
  1. package/ARCHITECTURE.md +1 -1
  2. package/docs/trusted-contact-access.md +1 -7
  3. package/node_modules/@vellumai/ces-client/node_modules/@vellumai/service-contracts/package.json +2 -1
  4. package/node_modules/@vellumai/ces-client/node_modules/@vellumai/service-contracts/src/index.ts +1 -0
  5. package/node_modules/@vellumai/ces-client/node_modules/@vellumai/service-contracts/src/platform-credential.ts +41 -0
  6. package/node_modules/@vellumai/gateway-client/node_modules/@vellumai/service-contracts/package.json +2 -1
  7. package/node_modules/@vellumai/gateway-client/node_modules/@vellumai/service-contracts/src/index.ts +1 -0
  8. package/node_modules/@vellumai/gateway-client/node_modules/@vellumai/service-contracts/src/platform-credential.ts +41 -0
  9. package/node_modules/@vellumai/service-contracts/package.json +2 -1
  10. package/node_modules/@vellumai/service-contracts/src/index.ts +1 -0
  11. package/node_modules/@vellumai/service-contracts/src/platform-credential.ts +41 -0
  12. package/openapi.yaml +33 -1
  13. package/package.json +1 -1
  14. package/src/__tests__/access-request-card-view.test.ts +6 -5
  15. package/src/__tests__/access-request-seed-content-blocks.test.ts +5 -2
  16. package/src/__tests__/channel-retry-sweep.test.ts +31 -0
  17. package/src/__tests__/conversation-sync-tags.test.ts +2 -37
  18. package/src/__tests__/credential-health-service.test.ts +98 -10
  19. package/src/__tests__/delete-propagation.test.ts +16 -0
  20. package/src/__tests__/guardian-routing-invariants.test.ts +26 -7
  21. package/src/__tests__/list-messages-system-card.test.ts +51 -0
  22. package/src/__tests__/notification-decision-fallback.test.ts +52 -49
  23. package/src/__tests__/notification-decision-strategy.test.ts +177 -197
  24. package/src/__tests__/platform-client-verify-credential.test.ts +100 -0
  25. package/src/__tests__/run-conversation-turn-persistence.test.ts +5 -2
  26. package/src/__tests__/unified-turn-context-location.test.ts +76 -0
  27. package/src/__tests__/workspace-migration-152-repair-retired-fireworks-minimax-m2p7-model-id.test.ts +233 -0
  28. package/src/api/index.ts +0 -8
  29. package/src/api/responses/conversation-message.ts +5 -0
  30. package/src/cli/commands/platform/__tests__/connect.test.ts +29 -0
  31. package/src/cli/commands/platform/connect.ts +14 -5
  32. package/src/conversations/__tests__/message-consolidation.test.ts +39 -0
  33. package/src/conversations/message-consolidation.ts +4 -3
  34. package/src/credential-health/credential-health-service.ts +130 -0
  35. package/src/notifications/README.md +1 -1
  36. package/src/notifications/access-request-copy.ts +126 -114
  37. package/src/notifications/approval-card-data.ts +5 -2
  38. package/src/notifications/broadcaster.ts +2 -2
  39. package/src/notifications/copy-composer.ts +2 -2
  40. package/src/notifications/decision-engine.ts +21 -118
  41. package/src/notifications/notification-utils.ts +83 -4
  42. package/src/persistence/conversation-crud.ts +20 -3
  43. package/src/platform/client.ts +55 -0
  44. package/src/plugins/defaults/turn-context/injectors.ts +3 -0
  45. package/src/plugins/defaults/turn-context/unified-turn-context.ts +24 -0
  46. package/src/providers/model-catalog.ts +4 -11
  47. package/src/runtime/assistant-event-hub.ts +2 -39
  48. package/src/runtime/channel-retry-sweep.ts +7 -0
  49. package/src/runtime/routes/conversation-routes.ts +20 -6
  50. package/src/runtime/routes/guardian-action-routes.ts +10 -14
  51. package/src/runtime/routes/inbound-message-handler.ts +6 -2
  52. package/src/runtime/routes/platform-routes.ts +99 -10
  53. package/src/runtime/sync/resource-sync-events.ts +14 -37
  54. package/src/runtime/sync/sync-publisher.test.ts +5 -4
  55. package/src/watch/watch-retro.ts +3 -3
  56. package/src/workspace/migrations/152-repair-retired-fireworks-minimax-m2p7-model-id.ts +198 -0
  57. package/src/workspace/migrations/registry.ts +2 -0
  58. package/src/api/events/conversation-list-invalidated.ts +0 -38
package/ARCHITECTURE.md CHANGED
@@ -146,7 +146,7 @@ All guardian approval decisions, regardless of how they arrive, route through a
146
146
  **Text fallback path (always available):**
147
147
 
148
148
  - Every prompt includes a `requestCode` (6-char alphanumeric). Guardians can reply with `<requestCode> approve` or `<requestCode> reject` on any channel.
149
- - `access_request` prompts additionally embed explicit text directives in `questionText`: the request-code approve/reject directive and the `"open invite flow"` phrase for starting the Trusted Contacts invite flow.
149
+ - `access_request` prompts carry the request-code verify/trust/reject/block directive in the card's `plainTextFallback`, appended by a transport only when it sends text without buttons; the `"open invite flow"` phrase for starting the Trusted Contacts invite flow stays in the text on every surface because nothing offers a button for it.
150
150
  - `pending_question` prompts (voice-originated) support `<requestCode> <your answer>` for free-text answers.
151
151
  - The `routeGuardianReply` router processes text replies through a priority-ordered pipeline: callback parsing -> request code parsing -> NL classification. All paths converge on `applyGuardianDecision`.
152
152
 
@@ -24,13 +24,7 @@ Design doc defining how unknown users gain access to a Vellum assistant via chan
24
24
  2. **The message is denied at the ingress ACL.** The gateway resolves the actor against its ACL DB and stamps a trust verdict onto the inbound metadata; the daemon's ACL stage (`runtime/routes/inbound-stages/acl-enforcement.ts`) consumes that verdict, finds no active member, replies _"Hmm looks like you don't have access to talk to me. I'll let &lt;guardian&gt; know you tried talking to me and get back to you."_ and returns `{ denied: true, reason: 'not_a_member' }`.
25
25
  3. **Notification pipeline alerts the guardian.** The denial triggers `notifyGuardianOfAccessRequest()` (`runtime/access-request-helper.ts`), which creates an access request in the gateway (`guardian_requests`, `kind: 'access_request'`, `toolName: 'ingress_access_request'`, via the `channels/gateway-guardian-requests.ts` client) and calls `emitNotificationSignal()` with `sourceEventName: 'ingress.access_request'`. The notification routes through the decision engine to the guardian's surfaces (vellum app, Telegram, Slack, etc.). The guardian sees who is requesting access, including a request code for approve/reject and an `open invite flow` option to start the Trusted Contacts invite flow.
26
26
 
27
- **Access-request copy contract:** Every guardian-facing access-request notification must contain:
28
- 1. **Requester context** — best-available identity (display name, username, external ID, source channel), sanitized to prevent control-character injection.
29
- 2. **Request-code decision directive** — e.g., `Reply "A1B2C3 approve" to grant access or "A1B2C3 reject" to deny.`
30
- 3. **Invite directive** — the exact phrase `Reply "open invite flow" to start Trusted Contacts invite flow.`
31
- 4. **Revoked-member warning** (when applicable) — `Note: this user was previously revoked.`
32
-
33
- Model-generated phrasing is permitted for the surrounding copy, but a post-generation enforcement step in the decision engine validates that all required directive elements are present. If any are missing, the full deterministic contract text is appended. This ensures the guardian can always parse and act on the notification regardless of LLM output quality.
27
+ **Access-request copy contract:** Every guardian-facing access-request notification carries the requester context (`buildAccessRequestContextText`): best-available identity (display name, username, external ID, source channel) sanitized to prevent control-character injection, the message preview, and a revoked-member warning when applicable (`Note: this user was previously revoked.`). The context also carries the invite directive (`Reply "open invite flow" to start Trusted Contacts invite flow.`), because no surface offers a button for the invite flow and typing the phrase is the only way to start it. The typed-reply mechanics are separate (`buildAccessRequestReplyMechanics`): the request-code directive (`Reply "A1B2C3 verify" ... "A1B2C3 trust" ... "A1B2C3 reject" ... "A1B2C3 block"`, without `verify` when no handshake is offered). It rides only in the card's `plainTextFallback`; a transport appends it when it sends text without buttons, and the decision engine strips any copy of it the model wrote into the composed text, so surfaces with buttons show the context alone.
34
28
 
35
29
  **Guardian identity resolution** is anchored on the assistant's vellum principal (`resolveAnchoredGuardian`), so access requests cannot bind to stale or cross-assistant contacts:
36
30
  1. A source-channel guardian binding (from the gateway contact list) that matches the vellum anchor principal.
@@ -24,7 +24,8 @@
24
24
  "./stripe-currency": "./src/stripe-currency.ts",
25
25
  "./error": "./src/error.ts",
26
26
  "./reactions": "./src/reactions.ts",
27
- "./guardian-requests": "./src/guardian-requests.ts"
27
+ "./guardian-requests": "./src/guardian-requests.ts",
28
+ "./platform-credential": "./src/platform-credential.ts"
28
29
  },
29
30
  "scripts": {
30
31
  "typecheck": "bunx tsc --noEmit",
@@ -28,6 +28,7 @@ export * from "./rpc.js";
28
28
  export * from "./trust-rules.js";
29
29
  export * from "./ingress.js";
30
30
  export * from "./no-response.js";
31
+ export * from "./platform-credential.js";
31
32
  export * from "./remote-web-pairing.js";
32
33
  export * from "./twilio-ingress.js";
33
34
  export * from "./url-normalization.js";
@@ -0,0 +1,41 @@
1
+ /**
2
+ * Wire contract for asking an assistant whether its stored platform-managed
3
+ * credential still authenticates.
4
+ *
5
+ * The daemon route is the authoritative serving side:
6
+ * - `POST /v1/platform/verify-credential`
7
+ * (`assistant/src/runtime/routes/platform-routes.ts`)
8
+ *
9
+ * The daemon's platform client produces the verdict, the route reports it,
10
+ * and the `vellum` CLI reads it before deciding whether a stored key can be
11
+ * re-injected (`cli/src/lib/assistant-api-key-resolution.ts`), so all three
12
+ * share one definition and cannot silently drift. The web app reads the same
13
+ * shape through its generated daemon client.
14
+ */
15
+ import { z } from "zod";
16
+
17
+ /**
18
+ * What the platform said about the stored credential, right now.
19
+ *
20
+ * - `valid`: the platform accepted it.
21
+ * - `rejected`: the platform refused it (unauthorized or forbidden); the
22
+ * credential needs replacing.
23
+ * - `unknown`: the check itself could not run (no credential stored, the
24
+ * platform unreachable, a server error). Not evidence either way.
25
+ */
26
+ export const PlatformCredentialVerificationStatusSchema = z.enum([
27
+ "valid",
28
+ "rejected",
29
+ "unknown",
30
+ ]);
31
+ export type PlatformCredentialVerificationStatus = z.infer<
32
+ typeof PlatformCredentialVerificationStatusSchema
33
+ >;
34
+
35
+ /** `POST /v1/platform/verify-credential` response body. */
36
+ export const PlatformVerifyCredentialResponseSchema = z.object({
37
+ status: PlatformCredentialVerificationStatusSchema,
38
+ });
39
+ export type PlatformVerifyCredentialResponse = z.infer<
40
+ typeof PlatformVerifyCredentialResponseSchema
41
+ >;
@@ -24,7 +24,8 @@
24
24
  "./stripe-currency": "./src/stripe-currency.ts",
25
25
  "./error": "./src/error.ts",
26
26
  "./reactions": "./src/reactions.ts",
27
- "./guardian-requests": "./src/guardian-requests.ts"
27
+ "./guardian-requests": "./src/guardian-requests.ts",
28
+ "./platform-credential": "./src/platform-credential.ts"
28
29
  },
29
30
  "scripts": {
30
31
  "typecheck": "bunx tsc --noEmit",
@@ -28,6 +28,7 @@ export * from "./rpc.js";
28
28
  export * from "./trust-rules.js";
29
29
  export * from "./ingress.js";
30
30
  export * from "./no-response.js";
31
+ export * from "./platform-credential.js";
31
32
  export * from "./remote-web-pairing.js";
32
33
  export * from "./twilio-ingress.js";
33
34
  export * from "./url-normalization.js";
@@ -0,0 +1,41 @@
1
+ /**
2
+ * Wire contract for asking an assistant whether its stored platform-managed
3
+ * credential still authenticates.
4
+ *
5
+ * The daemon route is the authoritative serving side:
6
+ * - `POST /v1/platform/verify-credential`
7
+ * (`assistant/src/runtime/routes/platform-routes.ts`)
8
+ *
9
+ * The daemon's platform client produces the verdict, the route reports it,
10
+ * and the `vellum` CLI reads it before deciding whether a stored key can be
11
+ * re-injected (`cli/src/lib/assistant-api-key-resolution.ts`), so all three
12
+ * share one definition and cannot silently drift. The web app reads the same
13
+ * shape through its generated daemon client.
14
+ */
15
+ import { z } from "zod";
16
+
17
+ /**
18
+ * What the platform said about the stored credential, right now.
19
+ *
20
+ * - `valid`: the platform accepted it.
21
+ * - `rejected`: the platform refused it (unauthorized or forbidden); the
22
+ * credential needs replacing.
23
+ * - `unknown`: the check itself could not run (no credential stored, the
24
+ * platform unreachable, a server error). Not evidence either way.
25
+ */
26
+ export const PlatformCredentialVerificationStatusSchema = z.enum([
27
+ "valid",
28
+ "rejected",
29
+ "unknown",
30
+ ]);
31
+ export type PlatformCredentialVerificationStatus = z.infer<
32
+ typeof PlatformCredentialVerificationStatusSchema
33
+ >;
34
+
35
+ /** `POST /v1/platform/verify-credential` response body. */
36
+ export const PlatformVerifyCredentialResponseSchema = z.object({
37
+ status: PlatformCredentialVerificationStatusSchema,
38
+ });
39
+ export type PlatformVerifyCredentialResponse = z.infer<
40
+ typeof PlatformVerifyCredentialResponseSchema
41
+ >;
@@ -24,7 +24,8 @@
24
24
  "./stripe-currency": "./src/stripe-currency.ts",
25
25
  "./error": "./src/error.ts",
26
26
  "./reactions": "./src/reactions.ts",
27
- "./guardian-requests": "./src/guardian-requests.ts"
27
+ "./guardian-requests": "./src/guardian-requests.ts",
28
+ "./platform-credential": "./src/platform-credential.ts"
28
29
  },
29
30
  "scripts": {
30
31
  "typecheck": "bunx tsc --noEmit",
@@ -28,6 +28,7 @@ export * from "./rpc.js";
28
28
  export * from "./trust-rules.js";
29
29
  export * from "./ingress.js";
30
30
  export * from "./no-response.js";
31
+ export * from "./platform-credential.js";
31
32
  export * from "./remote-web-pairing.js";
32
33
  export * from "./twilio-ingress.js";
33
34
  export * from "./url-normalization.js";
@@ -0,0 +1,41 @@
1
+ /**
2
+ * Wire contract for asking an assistant whether its stored platform-managed
3
+ * credential still authenticates.
4
+ *
5
+ * The daemon route is the authoritative serving side:
6
+ * - `POST /v1/platform/verify-credential`
7
+ * (`assistant/src/runtime/routes/platform-routes.ts`)
8
+ *
9
+ * The daemon's platform client produces the verdict, the route reports it,
10
+ * and the `vellum` CLI reads it before deciding whether a stored key can be
11
+ * re-injected (`cli/src/lib/assistant-api-key-resolution.ts`), so all three
12
+ * share one definition and cannot silently drift. The web app reads the same
13
+ * shape through its generated daemon client.
14
+ */
15
+ import { z } from "zod";
16
+
17
+ /**
18
+ * What the platform said about the stored credential, right now.
19
+ *
20
+ * - `valid`: the platform accepted it.
21
+ * - `rejected`: the platform refused it (unauthorized or forbidden); the
22
+ * credential needs replacing.
23
+ * - `unknown`: the check itself could not run (no credential stored, the
24
+ * platform unreachable, a server error). Not evidence either way.
25
+ */
26
+ export const PlatformCredentialVerificationStatusSchema = z.enum([
27
+ "valid",
28
+ "rejected",
29
+ "unknown",
30
+ ]);
31
+ export type PlatformCredentialVerificationStatus = z.infer<
32
+ typeof PlatformCredentialVerificationStatusSchema
33
+ >;
34
+
35
+ /** `POST /v1/platform/verify-credential` response body. */
36
+ export const PlatformVerifyCredentialResponseSchema = z.object({
37
+ status: PlatformCredentialVerificationStatusSchema,
38
+ });
39
+ export type PlatformVerifyCredentialResponse = z.infer<
40
+ typeof PlatformVerifyCredentialResponseSchema
41
+ >;
package/openapi.yaml CHANGED
@@ -21189,6 +21189,8 @@ paths:
21189
21189
  - channelId
21190
21190
  - channelTs
21191
21191
  additionalProperties: false
21192
+ deletedAt:
21193
+ type: number
21192
21194
  queueStatus:
21193
21195
  type: string
21194
21196
  enum:
@@ -24408,7 +24410,9 @@ paths:
24408
24410
  post:
24409
24411
  operationId: platform_connect_post
24410
24412
  summary: Connect to the Vellum Platform
24411
- description: Checks existing credentials and emits the show_platform_login signal for connected clients to show a login UI.
24413
+ description:
24414
+ Checks whether the stored platform credentials still connect. Reports a stored key the platform rejects as
24415
+ credentialRejected, so the caller can direct the user to sign in again and replace it.
24412
24416
  tags:
24413
24417
  - platform
24414
24418
  responses:
@@ -24425,6 +24429,8 @@ paths:
24425
24429
  type: string
24426
24430
  showPlatformLogin:
24427
24431
  type: boolean
24432
+ credentialRejected:
24433
+ type: boolean
24428
24434
  additionalProperties: false
24429
24435
  /v1/platform/credits:
24430
24436
  get:
@@ -24793,6 +24799,32 @@ paths:
24793
24799
  - package
24794
24800
  - entitlements
24795
24801
  additionalProperties: false
24802
+ /v1/platform/verify-credential:
24803
+ post:
24804
+ operationId: platform_verifycredential_post
24805
+ summary: Verify the Vellum-managed credential against the platform
24806
+ description:
24807
+ Asks the platform whether the stored assistant API key authenticates. POST because it performs a check; the
24808
+ result is returned, not stored.
24809
+ tags:
24810
+ - platform
24811
+ responses:
24812
+ "200":
24813
+ description: Successful response
24814
+ content:
24815
+ application/json:
24816
+ schema:
24817
+ type: object
24818
+ properties:
24819
+ status:
24820
+ type: string
24821
+ enum:
24822
+ - valid
24823
+ - rejected
24824
+ - unknown
24825
+ required:
24826
+ - status
24827
+ additionalProperties: false
24796
24828
  /v1/playground/seed-conversation:
24797
24829
  post:
24798
24830
  operationId: playground_seedconversation_post
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vellumai/assistant",
3
- "version": "0.11.8-dev.202609021923.ac39943",
3
+ "version": "0.11.8-dev.202609022016.7bd8f87",
4
4
  "license": "MIT",
5
5
  "type": "module",
6
6
  "exports": {
@@ -2,7 +2,8 @@ import { describe, expect, test } from "bun:test";
2
2
 
3
3
  import {
4
4
  buildAccessRequestCardView,
5
- buildAccessRequestContractText,
5
+ buildAccessRequestContextText,
6
+ buildAccessRequestReplyMechanics,
6
7
  parseAccessRequestPayload,
7
8
  } from "../notifications/access-request-copy.js";
8
9
 
@@ -111,10 +112,10 @@ describe("admitted-mode introduction nudge copy", () => {
111
112
  senderIdentifier: "Alice",
112
113
  sourceChannel: "telegram",
113
114
  };
114
- expect(buildAccessRequestContractText(base)).toContain(
115
+ expect(buildAccessRequestContextText(base)).toContain(
115
116
  "is requesting access to the assistant.",
116
117
  );
117
- const admitted = buildAccessRequestContractText({
118
+ const admitted = buildAccessRequestContextText({
118
119
  ...base,
119
120
  trigger: "admitted",
120
121
  });
@@ -122,8 +123,8 @@ describe("admitted-mode introduction nudge copy", () => {
122
123
  expect(admitted).not.toContain("is requesting access");
123
124
  });
124
125
 
125
- test("admitted contract text keeps the decision directives", () => {
126
- const text = buildAccessRequestContractText({
126
+ test("admitted reply mechanics keep the decision directives", () => {
127
+ const text = buildAccessRequestReplyMechanics({
127
128
  actorDisplayName: "Alice",
128
129
  sourceChannel: "telegram",
129
130
  requestCode: "ab12cd",
@@ -136,10 +136,13 @@ describe("buildAccessRequestSeedContentBlocks", () => {
136
136
  );
137
137
  });
138
138
 
139
- test("text fallback block contains contract text", () => {
139
+ test("text fallback block is the text-only rendering: context, invite directive, and the typed directive", () => {
140
140
  const textBlock = textOf(buildAccessRequestSeedContentBlocks(basePayload));
141
141
  expect(textBlock.text).toContain("requesting access to the assistant");
142
- expect(textBlock.text).toContain("ABC123");
142
+ expect(textBlock.text).toContain('Reply "open invite flow"');
143
+ // A client that cannot draw the card's buttons still gets the directive.
144
+ expect(textBlock.text).toContain('"ABC123 trust"');
145
+ expect(textBlock.text).toContain('"ABC123 block"');
143
146
  });
144
147
 
145
148
  test("body shows fallback when no preview/warnings/permalink", () => {
@@ -401,6 +401,37 @@ describe("channel-retry-sweep", () => {
401
401
  expect(eventRow?.processingStatus).toBe("processed");
402
402
  });
403
403
 
404
+ test("restores the triggering message's id and thread on replay", async () => {
405
+ seedFailedEventWithTrustClass("guardian", {
406
+ sourceMessageId: "1700000000.000200",
407
+ sourceThreadId: "1700000000.000100",
408
+ });
409
+
410
+ let capturedTrust: Record<string, unknown> | undefined;
411
+ await sweepFailedEvents(async (conversationId, _content, options) => {
412
+ capturedTrust = (options as { trustContext?: Record<string, unknown> })
413
+ .trustContext;
414
+ const messageId = "message-location";
415
+ getDb()
416
+ .insert(messages)
417
+ .values({
418
+ id: messageId,
419
+ conversationId,
420
+ role: "user",
421
+ content: JSON.stringify([{ type: "text", text: "retry me" }]),
422
+ createdAt: Date.now(),
423
+ })
424
+ .run();
425
+ return { messageId };
426
+ });
427
+
428
+ // The replayed turn names the same message and thread the live turn
429
+ // did, so the turn's chat and thread lines and approval-card source
430
+ // links read the same on a retry as on the first attempt.
431
+ expect(capturedTrust?.sourceMessageId).toBe("1700000000.000200");
432
+ expect(capturedTrust?.sourceThreadId).toBe("1700000000.000100");
433
+ });
434
+
404
435
  test("fences a non-guardian Slack replay in external_content and carries the ingress idempotency key", async () => {
405
436
  seedFailedSlackEvent({
406
437
  trustClass: "unverified_contact",
@@ -85,9 +85,7 @@ describe("conversation sync tags", () => {
85
85
  // (`patchConversation` in `metadata-handlers.ts`) and the per-
86
86
  // conversation `sync_changed` metadata tag is included as a belt-and-
87
87
  // suspenders signal for sibling-tab consumers that missed the typed
88
- // event. The legacy `conversation_list_invalidated` broadcast is
89
- // scoped to `targetInterfaceId: "macos"` and therefore not visible to
90
- // this process-type subscriber.
88
+ // event. No `conversation_list_invalidated` broadcast is emitted.
91
89
  const conversation = createConversation("Old title");
92
90
  const route = findRoute(
93
91
  CONVERSATION_MANAGEMENT_ROUTES,
@@ -115,13 +113,6 @@ describe("conversation sync tags", () => {
115
113
  expect((received[1]!.message as { tags: string[] }).tags).not.toContain(
116
114
  SYNC_TAGS.conversationsList,
117
115
  );
118
- // The legacy invalidation broadcast is macOS-scoped and must not
119
- // reach this process-type subscriber.
120
- expect(
121
- received.some(
122
- (event) => event.message.type === "conversation_list_invalidated",
123
- ),
124
- ).toBe(false);
125
116
  });
126
117
 
127
118
  test("auto-title generation emits the typed title event and a metadata-only sync tag (no list umbrella)", async () => {
@@ -159,8 +150,7 @@ describe("conversation sync tags", () => {
159
150
  // fetched). The umbrella `conversationsList` tag is the signal for
160
151
  // that redrain. Per-conversation metadata is included so any future
161
152
  // single-row consumer can patch the freshly added row in place
162
- // without an extra GET. The legacy macOS-only invalidation broadcast
163
- // is not visible to process subscribers.
153
+ // without an extra GET.
164
154
  const route = findRoute(
165
155
  CONVERSATION_MANAGEMENT_ROUTES,
166
156
  "createConversation",
@@ -185,11 +175,6 @@ describe("conversation sync tags", () => {
185
175
  conversationMetadataSyncTag(conversationId!),
186
176
  ],
187
177
  });
188
- expect(
189
- received.some(
190
- (event) => event.message.type === "conversation_list_invalidated",
191
- ),
192
- ).toBe(false);
193
178
  });
194
179
 
195
180
  test("reorder emits a sync_changed with the umbrella tag and per-conversation metadata tags", async () => {
@@ -228,11 +213,6 @@ describe("conversation sync tags", () => {
228
213
  conversationMetadataSyncTag(second.id),
229
214
  ],
230
215
  });
231
- expect(
232
- received.some(
233
- (event) => event.message.type === "conversation_list_invalidated",
234
- ),
235
- ).toBe(false);
236
216
  });
237
217
 
238
218
  test("record seen emits only a per-conversation metadata sync tag (no list umbrella)", async () => {
@@ -245,11 +225,6 @@ describe("conversation sync tags", () => {
245
225
  // single `sync_changed` with only the per-conversation
246
226
  // `conversation:<id>:metadata` tag, which web consumes by GET-and-
247
227
  // patching the single cached row via `refreshConversationRow`.
248
- //
249
- // The legacy `conversation_list_invalidated` broadcast is still
250
- // emitted for macOS (which has no per-row patcher) but is scoped
251
- // to `targetInterfaceId: "macos"` and therefore not visible to
252
- // this process-type subscriber.
253
228
  const conversation = createConversation("Attention");
254
229
  projectAssistantMessage({
255
230
  conversationId: conversation.id,
@@ -283,11 +258,6 @@ describe("conversation sync tags", () => {
283
258
  expect((received[0]!.message as { tags: string[] }).tags).not.toContain(
284
259
  SYNC_TAGS.conversationsList,
285
260
  );
286
- expect(
287
- received.some(
288
- (event) => event.message.type === "conversation_list_invalidated",
289
- ),
290
- ).toBe(false);
291
261
  });
292
262
 
293
263
  test("mark unread emits only a per-conversation metadata sync tag (no list umbrella)", async () => {
@@ -327,11 +297,6 @@ describe("conversation sync tags", () => {
327
297
  expect((received[0]!.message as { tags: string[] }).tags).not.toContain(
328
298
  SYNC_TAGS.conversationsList,
329
299
  );
330
- expect(
331
- received.some(
332
- (event) => event.message.type === "conversation_list_invalidated",
333
- ),
334
- ).toBe(false);
335
300
  });
336
301
 
337
302
  test("addMessage('assistant') emits a metadata sync tag when attention state transitions", async () => {
@@ -53,6 +53,12 @@ let managedPingOutcome:
53
53
  | { kind: "credential_required" }
54
54
  | { kind: "throw"; message: string } = { kind: "status", status: 200 };
55
55
 
56
+ // Managed assistant-credential mock state. The verdict is what the platform
57
+ // answers about the stored assistant API key; `platformClientAvailable` covers
58
+ // the case where the client cannot be built at all.
59
+ let assistantKeyVerdict: "valid" | "rejected" | "unknown" = "valid";
60
+ let platformClientAvailable = true;
61
+
56
62
  // ── Module mocks ─────────────────────────────────────────────────────
57
63
 
58
64
  mock.module("../security/secure-keys.js", () => ({
@@ -123,14 +129,20 @@ mock.module("../security/token-manager.js", () => ({
123
129
  // seeded into the real workspace config via `seedGoogleOAuthMode` below.
124
130
  mock.module("../platform/client.js", () => ({
125
131
  VellumPlatformClient: {
126
- create: async () => ({
127
- platformAssistantId: "test-assistant",
128
- fetch: async (_path: string) => ({
129
- ok: managedListResponse.ok,
130
- status: managedListResponse.status,
131
- json: async () => managedListResponse.body ?? { results: [] },
132
- }),
133
- }),
132
+ create: async () => {
133
+ if (!platformClientAvailable) {
134
+ return null;
135
+ }
136
+ return {
137
+ platformAssistantId: "test-assistant",
138
+ fetch: async (_path: string) => ({
139
+ ok: managedListResponse.ok,
140
+ status: managedListResponse.status,
141
+ json: async () => managedListResponse.body ?? { results: [] },
142
+ }),
143
+ verifyCredential: async () => assistantKeyVerdict,
144
+ };
145
+ },
134
146
  },
135
147
  }));
136
148
 
@@ -164,8 +176,12 @@ mock.module("../oauth/platform-connection.js", () => ({
164
176
 
165
177
  import { setConfig } from "./helpers/set-config.js";
166
178
 
167
- const { checkAllCredentials, checkCredentialForProvider, _setFetchFn } =
168
- await import("../credential-health/credential-health-service.js");
179
+ const {
180
+ checkAllCredentials,
181
+ checkAssistantApiKey,
182
+ checkCredentialForProvider,
183
+ _setFetchFn,
184
+ } = await import("../credential-health/credential-health-service.js");
169
185
 
170
186
  /** Seed the real workspace config's `services.google-oauth.mode`. */
171
187
  function seedGoogleOAuthMode(mode: "managed" | "your-own"): void {
@@ -259,6 +275,8 @@ describe("credential-health-service", () => {
259
275
  seedGoogleOAuthMode("your-own");
260
276
  managedListResponse = { ok: true, status: 200, body: { results: [] } };
261
277
  managedPingOutcome = { kind: "status", status: 200 };
278
+ assistantKeyVerdict = "valid";
279
+ platformClientAvailable = true;
262
280
  });
263
281
 
264
282
  test("returns empty report when no providers exist", async () => {
@@ -763,4 +781,74 @@ describe("credential-health-service", () => {
763
781
  expect(result!.status).toBe("missing_token");
764
782
  });
765
783
  });
784
+
785
+ describe("managed assistant credential", () => {
786
+ const KEY = "credential/vellum/assistant_api_key";
787
+ const BASE_URL = "credential/vellum/platform_base_url";
788
+
789
+ // Checked directly rather than through `checkAllCredentials`: the managed
790
+ // credential is not enrolled in the heartbeat pass yet, because the alert
791
+ // that pass raises has nowhere to send the reader until the notification
792
+ // carries the repair.
793
+ const assistantKeyResult = checkAssistantApiKey;
794
+
795
+ test("is not reported when the workspace was never connected", async () => {
796
+ expect(await assistantKeyResult()).toBeNull();
797
+ });
798
+
799
+ test("is missing_token when a platform identity exists without a key", async () => {
800
+ secureKeyValues.set(BASE_URL, "https://platform.example");
801
+
802
+ const result = await assistantKeyResult();
803
+ expect(result!.status).toBe("missing_token");
804
+ expect(result!.canAutoRecover).toBe(true);
805
+ });
806
+
807
+ test("is healthy when the platform accepts the stored key", async () => {
808
+ secureKeyValues.set(BASE_URL, "https://platform.example");
809
+ secureKeyValues.set(KEY, "stored-key");
810
+ assistantKeyVerdict = "valid";
811
+
812
+ expect((await assistantKeyResult())!.status).toBe("healthy");
813
+ });
814
+
815
+ test("is revoked when the platform rejects the stored key", async () => {
816
+ secureKeyValues.set(BASE_URL, "https://platform.example");
817
+ secureKeyValues.set(KEY, "stored-key");
818
+ assistantKeyVerdict = "rejected";
819
+
820
+ const result = await assistantKeyResult();
821
+ expect(result!.status).toBe("revoked");
822
+ // Recovery needs a signed-in client to rotate the key, but no
823
+ // re-authorization from the user.
824
+ expect(result!.canAutoRecover).toBe(true);
825
+ });
826
+
827
+ // The anti-churn invariant: only a settled rejection may read as revoked,
828
+ // because `revoked` is what drives a rotation. An unsettled answer (server
829
+ // error, transport failure) must degrade to `unreachable`, which the
830
+ // heartbeat drops before notifying.
831
+ test("an unsettled platform answer is unreachable, never revoked", async () => {
832
+ secureKeyValues.set(BASE_URL, "https://platform.example");
833
+ secureKeyValues.set(KEY, "stored-key");
834
+ assistantKeyVerdict = "unknown";
835
+
836
+ expect((await assistantKeyResult())!.status).toBe("unreachable");
837
+ });
838
+
839
+ test("an unreadable credential store is unreachable, never revoked", async () => {
840
+ secureKeyValues.set(BASE_URL, "https://platform.example");
841
+ markUnreachable(KEY);
842
+
843
+ expect((await assistantKeyResult())!.status).toBe("unreachable");
844
+ });
845
+
846
+ test("is unreachable when the platform client cannot be built", async () => {
847
+ secureKeyValues.set(BASE_URL, "https://platform.example");
848
+ secureKeyValues.set(KEY, "stored-key");
849
+ platformClientAvailable = false;
850
+
851
+ expect((await assistantKeyResult())!.status).toBe("unreachable");
852
+ });
853
+ });
766
854
  });
@@ -16,6 +16,18 @@ mock.module("../config/env.js", () => ({
16
16
  getGatewayInternalBaseUrl: () => "http://127.0.0.1:7830",
17
17
  }));
18
18
 
19
+ // A client showing the conversation learns of the deletion through the
20
+ // messages-changed invalidation; capture it rather than opening the SSE hub.
21
+ const publishedConversationIds: string[] = [];
22
+ const actualSyncEvents =
23
+ await import("../runtime/sync/resource-sync-events.js");
24
+ mock.module("../runtime/sync/resource-sync-events.js", () => ({
25
+ ...actualSyncEvents,
26
+ publishConversationMessagesChanged: (conversationId: string) => {
27
+ publishedConversationIds.push(conversationId);
28
+ },
29
+ }));
30
+
19
31
  import { eq } from "drizzle-orm";
20
32
 
21
33
  import {
@@ -44,6 +56,7 @@ const SLACK_DELETE_ACTOR_ID = "U_DELETE_ACTOR";
44
56
  const SLACK_DELETE_ACTOR_DISPLAY_NAME = "Delete Actor";
45
57
 
46
58
  function resetState(): void {
59
+ publishedConversationIds.length = 0;
47
60
  const db = getDb();
48
61
  db.run("DELETE FROM channel_inbound_events");
49
62
  db.run("DELETE FROM messages");
@@ -291,6 +304,7 @@ describe("Discord delete propagation (unattributed)", () => {
291
304
  const neutral = readProviderMetadata(row!.metadata);
292
305
  expect(neutral!.deletedAt).toBeDefined();
293
306
  expect(neutral!.messageId).toBe(sentId);
307
+ expect(publishedConversationIds).toEqual([minted.conversationId]);
294
308
  });
295
309
 
296
310
  test("deleting one post of a split reply keeps the surviving posts live", async () => {
@@ -614,6 +628,8 @@ describe("Slack delete propagation", () => {
614
628
  expect(slackMeta!.channelTs).toBe("1234.5678");
615
629
  expect(slackMeta!.eventKind).toBe("message");
616
630
  expect(slackMeta!.displayName).toBe("Test User");
631
+ // A client showing the conversation is told to refetch.
632
+ expect(publishedConversationIds).toEqual([seeded.conversationId]);
617
633
  });
618
634
 
619
635
  test("deleting the assistant's own Slack post is recorded without an actor", async () => {