@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.
- package/ARCHITECTURE.md +1 -1
- package/docs/trusted-contact-access.md +1 -7
- package/node_modules/@vellumai/ces-client/node_modules/@vellumai/service-contracts/package.json +2 -1
- package/node_modules/@vellumai/ces-client/node_modules/@vellumai/service-contracts/src/index.ts +1 -0
- package/node_modules/@vellumai/ces-client/node_modules/@vellumai/service-contracts/src/platform-credential.ts +41 -0
- package/node_modules/@vellumai/gateway-client/node_modules/@vellumai/service-contracts/package.json +2 -1
- package/node_modules/@vellumai/gateway-client/node_modules/@vellumai/service-contracts/src/index.ts +1 -0
- package/node_modules/@vellumai/gateway-client/node_modules/@vellumai/service-contracts/src/platform-credential.ts +41 -0
- package/node_modules/@vellumai/service-contracts/package.json +2 -1
- package/node_modules/@vellumai/service-contracts/src/index.ts +1 -0
- package/node_modules/@vellumai/service-contracts/src/platform-credential.ts +41 -0
- package/openapi.yaml +33 -1
- package/package.json +1 -1
- package/src/__tests__/access-request-card-view.test.ts +6 -5
- package/src/__tests__/access-request-seed-content-blocks.test.ts +5 -2
- package/src/__tests__/channel-retry-sweep.test.ts +31 -0
- package/src/__tests__/conversation-sync-tags.test.ts +2 -37
- package/src/__tests__/credential-health-service.test.ts +98 -10
- package/src/__tests__/delete-propagation.test.ts +16 -0
- package/src/__tests__/guardian-routing-invariants.test.ts +26 -7
- package/src/__tests__/list-messages-system-card.test.ts +51 -0
- package/src/__tests__/notification-decision-fallback.test.ts +52 -49
- package/src/__tests__/notification-decision-strategy.test.ts +177 -197
- package/src/__tests__/platform-client-verify-credential.test.ts +100 -0
- package/src/__tests__/run-conversation-turn-persistence.test.ts +5 -2
- package/src/__tests__/unified-turn-context-location.test.ts +76 -0
- package/src/__tests__/workspace-migration-152-repair-retired-fireworks-minimax-m2p7-model-id.test.ts +233 -0
- package/src/api/index.ts +0 -8
- package/src/api/responses/conversation-message.ts +5 -0
- package/src/cli/commands/platform/__tests__/connect.test.ts +29 -0
- package/src/cli/commands/platform/connect.ts +14 -5
- package/src/conversations/__tests__/message-consolidation.test.ts +39 -0
- package/src/conversations/message-consolidation.ts +4 -3
- package/src/credential-health/credential-health-service.ts +130 -0
- package/src/notifications/README.md +1 -1
- package/src/notifications/access-request-copy.ts +126 -114
- package/src/notifications/approval-card-data.ts +5 -2
- package/src/notifications/broadcaster.ts +2 -2
- package/src/notifications/copy-composer.ts +2 -2
- package/src/notifications/decision-engine.ts +21 -118
- package/src/notifications/notification-utils.ts +83 -4
- package/src/persistence/conversation-crud.ts +20 -3
- package/src/platform/client.ts +55 -0
- package/src/plugins/defaults/turn-context/injectors.ts +3 -0
- package/src/plugins/defaults/turn-context/unified-turn-context.ts +24 -0
- package/src/providers/model-catalog.ts +4 -11
- package/src/runtime/assistant-event-hub.ts +2 -39
- package/src/runtime/channel-retry-sweep.ts +7 -0
- package/src/runtime/routes/conversation-routes.ts +20 -6
- package/src/runtime/routes/guardian-action-routes.ts +10 -14
- package/src/runtime/routes/inbound-message-handler.ts +6 -2
- package/src/runtime/routes/platform-routes.ts +99 -10
- package/src/runtime/sync/resource-sync-events.ts +14 -37
- package/src/runtime/sync/sync-publisher.test.ts +5 -4
- package/src/watch/watch-retro.ts +3 -3
- package/src/workspace/migrations/152-repair-retired-fireworks-minimax-m2p7-model-id.ts +198 -0
- package/src/workspace/migrations/registry.ts +2 -0
- 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
|
|
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 <guardian> 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
|
|
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.
|
package/node_modules/@vellumai/ces-client/node_modules/@vellumai/service-contracts/package.json
CHANGED
|
@@ -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",
|
package/node_modules/@vellumai/ces-client/node_modules/@vellumai/service-contracts/src/index.ts
CHANGED
|
@@ -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/node_modules/@vellumai/gateway-client/node_modules/@vellumai/service-contracts/package.json
CHANGED
|
@@ -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",
|
package/node_modules/@vellumai/gateway-client/node_modules/@vellumai/service-contracts/src/index.ts
CHANGED
|
@@ -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:
|
|
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
|
@@ -2,7 +2,8 @@ import { describe, expect, test } from "bun:test";
|
|
|
2
2
|
|
|
3
3
|
import {
|
|
4
4
|
buildAccessRequestCardView,
|
|
5
|
-
|
|
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(
|
|
115
|
+
expect(buildAccessRequestContextText(base)).toContain(
|
|
115
116
|
"is requesting access to the assistant.",
|
|
116
117
|
);
|
|
117
|
-
const admitted =
|
|
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
|
|
126
|
-
const text =
|
|
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
|
|
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("
|
|
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.
|
|
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.
|
|
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
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
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 {
|
|
168
|
-
|
|
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 () => {
|