@company-semantics/contracts 92.0.0 → 93.1.0
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/package.json +13 -12
- package/src/__tests__/resource-keys.test.ts +1 -0
- package/src/action-items/README.md +12 -4
- package/src/action-items/schemas.ts +10 -0
- package/src/affordances/README.md +52 -1
- package/src/affordances/__tests__/README.md +10 -1
- package/src/affordances/__tests__/affordances.test.ts +13 -1
- package/src/affordances/__tests__/catalog-wave.test.ts +237 -0
- package/src/affordances/affordance.ts +56 -4
- package/src/affordances/args.ts +44 -1
- package/src/affordances/available.ts +59 -0
- package/src/affordances/coverage.ts +51 -0
- package/src/affordances/ids.ts +28 -0
- package/src/affordances/index.ts +17 -0
- package/src/api/README.md +1 -0
- package/src/api/generated-spec-hash.ts +2 -2
- package/src/api/generated.ts +506 -1
- package/src/api/primitives.ts +13 -0
- package/src/billing/README.md +1 -0
- package/src/billing/refusals.ts +12 -0
- package/src/chat/__tests__/page-context.test.ts +22 -43
- package/src/chat/__tests__/runtime-profile.test.ts +20 -44
- package/src/generated/openapi-routes.ts +1 -0
- package/src/message-parts/README.md +5 -0
- package/src/message-parts/affordance-offer.ts +44 -0
- package/src/message-parts/index.ts +7 -0
- package/src/message-parts/types.ts +7 -1
- package/src/message-parts/wire.ts +18 -0
- package/src/notifications/README.md +17 -3
- package/src/notifications/__tests__/README.md +4 -0
- package/src/notifications/__tests__/__snapshots__/render-snapshot.test.ts.snap +27 -25
- package/src/notifications/__tests__/fixtures.ts +7 -1
- package/src/notifications/__tests__/grounded-ctas.test.ts +103 -0
- package/src/notifications/__tests__/render-snapshot.test.ts +1 -1
- package/src/notifications/content.ts +9 -0
- package/src/notifications/kinds/billing-auto-recharge-failed.ts +4 -2
- package/src/notifications/kinds/billing-payment-past-due.ts +2 -0
- package/src/notifications/kinds/billing-suspended.ts +2 -0
- package/src/notifications/kinds/chat-shared.ts +10 -3
- package/src/notifications/kinds/company-md-access-approved.ts +13 -6
- package/src/notifications/kinds/share-granted.ts +1 -1
- package/src/notifications/kinds/unit-owner-granted.ts +9 -2
- package/src/notifications/payloads.ts +6 -0
- package/src/proactive/README.md +18 -2
- package/src/proactive/__tests__/README.md +11 -1
- package/src/proactive/__tests__/chat-templates.test.ts +7 -6
- package/src/proactive/__tests__/proactive-offers.test.ts +124 -0
- package/src/proactive/__tests__/vocabulary.test.ts +1 -1
- package/src/proactive/composer.ts +11 -0
- package/src/proactive/templates/README.md +11 -3
- package/src/proactive/templates/billing-auto-recharge-failed.ts +1 -0
- package/src/proactive/templates/billing-balance-exhausted.ts +2 -6
- package/src/proactive/templates/billing-payment-past-due.ts +1 -0
- package/src/proactive/templates/billing-subscription-canceled.ts +1 -5
- package/src/proactive/templates/billing-suspended.ts +1 -0
- package/src/proactive/templates/morning-brief.ts +1 -0
- package/src/proactive/templates/org-became-shared.ts +4 -3
- package/src/proactive/templates/source-sync-review-ready.ts +3 -8
- package/src/proactive/templates/workflow-needs-attention.ts +1 -0
- package/src/rate-limit/README.md +27 -1
- package/src/rate-limit/__tests__/README.md +23 -0
- package/src/rate-limit/__tests__/rate-limited-error.test.ts +53 -0
- package/src/rate-limit/index.ts +33 -0
- package/src/resource-key-tables.ts +1 -0
- package/src/resource-key-types.ts +3 -0
- package/src/resource-keys.ts +1 -0
- package/src/root-exports/agent-runtime.ts +14 -2
- package/src/root-exports/delivery.ts +15 -0
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
|
|
3
|
+
import type {
|
|
4
|
+
CallToAction,
|
|
5
|
+
NotificationContent,
|
|
6
|
+
NotificationElement,
|
|
7
|
+
} from "../content";
|
|
8
|
+
import { createRenderContext } from "../context";
|
|
9
|
+
import type { NotificationKind } from "../kinds";
|
|
10
|
+
import { NOTIFICATION_DEFINITIONS } from "../registry";
|
|
11
|
+
import { NOTIFICATION_FIXTURES } from "./fixtures";
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* A notification link that does what a catalog id names carries that
|
|
15
|
+
* affordance, and no notification prose tells the reader what to do
|
|
16
|
+
* (control ADR affordance-system; PRD-01137).
|
|
17
|
+
*/
|
|
18
|
+
const CALL_TO_ACTION =
|
|
19
|
+
/\byou can\b|\btry\b|\bagain\b|\bsettings\b|\breconnect\b|\badd funds\b|\bclick\b|\bgo to\b|\bopen\b/i;
|
|
20
|
+
const CONTEXT = createRenderContext({ brand: { copyrightYear: 2026 } });
|
|
21
|
+
|
|
22
|
+
function composeFixture(
|
|
23
|
+
kind: NotificationKind,
|
|
24
|
+
payload: unknown,
|
|
25
|
+
): NotificationContent {
|
|
26
|
+
const compose = NOTIFICATION_DEFINITIONS[kind].compose as (
|
|
27
|
+
p: unknown,
|
|
28
|
+
c: typeof CONTEXT,
|
|
29
|
+
) => NotificationContent;
|
|
30
|
+
return compose(payload, CONTEXT);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function elements(content: NotificationContent): NotificationElement[] {
|
|
34
|
+
const out: NotificationElement[] = [];
|
|
35
|
+
for (const section of content.sections) {
|
|
36
|
+
for (const element of section.elements) {
|
|
37
|
+
out.push(element);
|
|
38
|
+
if (element.type === "chatUnit")
|
|
39
|
+
out.push(...(element.items as unknown as NotificationElement[]));
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
return out;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const ctas = (content: NotificationContent) =>
|
|
46
|
+
elements(content).filter((e): e is CallToAction => e.type === "callToAction");
|
|
47
|
+
|
|
48
|
+
const GROUNDED: Partial<
|
|
49
|
+
Record<NotificationKind, (payload: Record<string, unknown>) => unknown>
|
|
50
|
+
> = {
|
|
51
|
+
"billing.auto_recharge_failed": () => ({ id: "billing.open" }),
|
|
52
|
+
"billing.payment_past_due": () => ({ id: "billing.open" }),
|
|
53
|
+
"billing.suspended": () => ({ id: "billing.open" }),
|
|
54
|
+
"chat.shared": (p) => ({ id: "chat.open", args: { chatId: p.chatId } }),
|
|
55
|
+
"companyMd.access_request_approved": (p) => ({
|
|
56
|
+
id: "companyMd.openDoc",
|
|
57
|
+
args: { docId: p.docId },
|
|
58
|
+
}),
|
|
59
|
+
"org.unit_owner_granted": (p) => ({
|
|
60
|
+
id: "team.open",
|
|
61
|
+
args: { unitId: p.unitId },
|
|
62
|
+
}),
|
|
63
|
+
};
|
|
64
|
+
|
|
65
|
+
describe("grounded notification calls to action", () => {
|
|
66
|
+
it("a linked call to action whose link a catalog id names carries that affordance, targeting the payload's own id", () => {
|
|
67
|
+
for (const fixture of NOTIFICATION_FIXTURES) {
|
|
68
|
+
const expected = GROUNDED[fixture.kind];
|
|
69
|
+
if (!expected) continue;
|
|
70
|
+
const linked = ctas(composeFixture(fixture.kind, fixture.payload)).filter(
|
|
71
|
+
(c) => c.href !== undefined,
|
|
72
|
+
);
|
|
73
|
+
expect(linked, fixture.name).toHaveLength(1);
|
|
74
|
+
expect(
|
|
75
|
+
linked[0]?.affordance,
|
|
76
|
+
`${fixture.kind} · ${fixture.name}`,
|
|
77
|
+
).toEqual(expected(fixture.payload as Record<string, unknown>));
|
|
78
|
+
}
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
it("a code to copy never carries an affordance", () => {
|
|
82
|
+
for (const fixture of NOTIFICATION_FIXTURES) {
|
|
83
|
+
for (const cta of ctas(composeFixture(fixture.kind, fixture.payload))) {
|
|
84
|
+
if (cta.href === undefined)
|
|
85
|
+
expect(cta.affordance, fixture.name).toBeUndefined();
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
it("no grounded or share notification body tells the reader what to do", () => {
|
|
91
|
+
for (const fixture of NOTIFICATION_FIXTURES) {
|
|
92
|
+
if (!GROUNDED[fixture.kind] && fixture.kind !== "share.granted") continue;
|
|
93
|
+
for (const element of elements(
|
|
94
|
+
composeFixture(fixture.kind, fixture.payload),
|
|
95
|
+
)) {
|
|
96
|
+
if (element.type === "body")
|
|
97
|
+
expect(element.text, fixture.name).not.toMatch(CALL_TO_ACTION);
|
|
98
|
+
if (element.type === "message")
|
|
99
|
+
expect(element.text, fixture.name).not.toMatch(CALL_TO_ACTION);
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
});
|
|
103
|
+
});
|
|
@@ -220,7 +220,7 @@ add("companyMd.access_request_approved", "Editor", {
|
|
|
220
220
|
accessLevel: "editor",
|
|
221
221
|
docUrl: `${APP}/doc/handbook`,
|
|
222
222
|
});
|
|
223
|
-
// The approver's note replaces the "
|
|
223
|
+
// The approver's note replaces the "The document is ready for you." line rather than stacking
|
|
224
224
|
// on it — the branch this variant exists to pin, across every channel.
|
|
225
225
|
add("companyMd.access_request_approved", "Editor with message", {
|
|
226
226
|
approverName: "Jordan Lee",
|
|
@@ -23,6 +23,8 @@
|
|
|
23
23
|
* security notice", not "render a red box".
|
|
24
24
|
*/
|
|
25
25
|
|
|
26
|
+
import type { OfferedAffordance } from "../affordances/affordance";
|
|
27
|
+
|
|
26
28
|
// =============================================================================
|
|
27
29
|
// Shared value types
|
|
28
30
|
// =============================================================================
|
|
@@ -73,11 +75,18 @@ export interface KeyValueTable {
|
|
|
73
75
|
* The one action the notification is asking for. `href` absent means the label
|
|
74
76
|
* IS the payload (an OTP code), not a link — the renderer must not fabricate a
|
|
75
77
|
* destination for it.
|
|
78
|
+
*
|
|
79
|
+
* `affordance` names the catalog affordance the link performs (control ADR
|
|
80
|
+
* affordance-system): present exactly when the link does what a catalog id
|
|
81
|
+
* names, so the label is that affordance's label (`// cta-grounded: <id>` at
|
|
82
|
+
* the composer). Absent for an OTP code and for a link no catalog id names (an
|
|
83
|
+
* invite acceptance, a comment). Renderers draw the link as before.
|
|
76
84
|
*/
|
|
77
85
|
export interface CallToAction {
|
|
78
86
|
type: "callToAction";
|
|
79
87
|
label: string;
|
|
80
88
|
href?: string;
|
|
89
|
+
affordance?: OfferedAffordance;
|
|
81
90
|
}
|
|
82
91
|
|
|
83
92
|
/** A sequence of items. `ordered` when the sequence itself carries meaning. */
|
|
@@ -52,13 +52,15 @@ export const billingAutoRechargeFailedDefinition: NotificationDefinition<"billin
|
|
|
52
52
|
text:
|
|
53
53
|
"AI work carries on until the remaining balance is spent, " +
|
|
54
54
|
"and then pauses. The next attempt is not immediate — the " +
|
|
55
|
-
"system waits
|
|
56
|
-
"updating the payment method is what resolves this.",
|
|
55
|
+
"system waits before charging the same card a second time — " +
|
|
56
|
+
"so updating the payment method is what resolves this.",
|
|
57
57
|
},
|
|
58
58
|
{
|
|
59
59
|
type: "callToAction",
|
|
60
|
+
// cta-grounded: billing.open
|
|
60
61
|
label: "OPEN BILLING",
|
|
61
62
|
href: billingUrl,
|
|
63
|
+
affordance: { id: "billing.open" },
|
|
62
64
|
},
|
|
63
65
|
{
|
|
64
66
|
type: "notice",
|
|
@@ -59,8 +59,10 @@ export const billingPaymentPastDueDefinition: NotificationDefinition<"billing.pa
|
|
|
59
59
|
},
|
|
60
60
|
{
|
|
61
61
|
type: "callToAction",
|
|
62
|
+
// cta-grounded: billing.open
|
|
62
63
|
label: "OPEN BILLING",
|
|
63
64
|
href: billingUrl,
|
|
65
|
+
affordance: { id: "billing.open" },
|
|
64
66
|
},
|
|
65
67
|
{
|
|
66
68
|
type: "notice",
|
|
@@ -57,8 +57,10 @@ export const billingSuspendedDefinition: NotificationDefinition<"billing.suspend
|
|
|
57
57
|
},
|
|
58
58
|
{
|
|
59
59
|
type: "callToAction",
|
|
60
|
+
// cta-grounded: billing.open
|
|
60
61
|
label: "OPEN BILLING",
|
|
61
62
|
href: billingUrl,
|
|
63
|
+
affordance: { id: "billing.open" },
|
|
62
64
|
},
|
|
63
65
|
{
|
|
64
66
|
type: "notice",
|
|
@@ -15,7 +15,8 @@ import { NOTICE } from "../text";
|
|
|
15
15
|
export const chatSharedDefinition: NotificationDefinition<"chat.shared"> = {
|
|
16
16
|
kind: "chat.shared",
|
|
17
17
|
compose: (payload, context) => {
|
|
18
|
-
const { chatTitle, chatUrl, access, previewText, sharedByName } =
|
|
18
|
+
const { chatTitle, chatUrl, chatId, access, previewText, sharedByName } =
|
|
19
|
+
payload;
|
|
19
20
|
|
|
20
21
|
const items: ChatUnitItem[] = [
|
|
21
22
|
{ type: "message", role: "user", text: chatTitle, from: sharedByName },
|
|
@@ -29,7 +30,13 @@ export const chatSharedDefinition: NotificationDefinition<"chat.shared"> = {
|
|
|
29
30
|
]
|
|
30
31
|
: []),
|
|
31
32
|
{ type: "continuation" },
|
|
32
|
-
{
|
|
33
|
+
{
|
|
34
|
+
type: "callToAction",
|
|
35
|
+
// cta-grounded: chat.open
|
|
36
|
+
label: "OPEN CHAT",
|
|
37
|
+
href: chatUrl,
|
|
38
|
+
affordance: { id: "chat.open", args: { chatId } },
|
|
39
|
+
},
|
|
33
40
|
];
|
|
34
41
|
|
|
35
42
|
return {
|
|
@@ -45,7 +52,7 @@ export const chatSharedDefinition: NotificationDefinition<"chat.shared"> = {
|
|
|
45
52
|
type: "body",
|
|
46
53
|
text:
|
|
47
54
|
access === "collaborate"
|
|
48
|
-
? "A chat was shared with you
|
|
55
|
+
? "A chat was shared with you, and you are a participant."
|
|
49
56
|
: "A chat was shared with you to read.",
|
|
50
57
|
},
|
|
51
58
|
{ type: "chatUnit", items },
|
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Company.md access-approved notification (sent to the requester).
|
|
3
3
|
*
|
|
4
|
-
* The chat unit carries EITHER the approver's own words or the bare "
|
|
5
|
-
*
|
|
6
|
-
* the
|
|
4
|
+
* The chat unit carries EITHER the approver's own words or the bare "The
|
|
5
|
+
* document is ready for you." line — never both. An approver who wrote a note
|
|
6
|
+
* has already said what the line would say, and stacking the two reads as a form letter with a
|
|
7
7
|
* comment bolted on. Same shape as `share.granted` (ADR-CONTRACTS-106); the call
|
|
8
8
|
* to action stays in place either way, since approval always resolves to a doc
|
|
9
9
|
* the requester can now open.
|
|
@@ -17,7 +17,8 @@ export const accessApprovedDefinition: NotificationDefinition<"companyMd.access_
|
|
|
17
17
|
{
|
|
18
18
|
kind: "companyMd.access_request_approved",
|
|
19
19
|
compose: (payload, context) => {
|
|
20
|
-
const { approverName, docTitle, accessLevel, docUrl, message } =
|
|
20
|
+
const { approverName, docTitle, accessLevel, docUrl, docId, message } =
|
|
21
|
+
payload;
|
|
21
22
|
|
|
22
23
|
return {
|
|
23
24
|
metadata: {
|
|
@@ -42,9 +43,15 @@ export const accessApprovedDefinition: NotificationDefinition<"companyMd.access_
|
|
|
42
43
|
: {
|
|
43
44
|
type: "message",
|
|
44
45
|
role: "assistant",
|
|
45
|
-
text: "
|
|
46
|
+
text: "The document is ready for you.",
|
|
46
47
|
},
|
|
47
|
-
{
|
|
48
|
+
{
|
|
49
|
+
type: "callToAction",
|
|
50
|
+
// cta-grounded: companyMd.openDoc
|
|
51
|
+
label: "OPEN",
|
|
52
|
+
href: docUrl,
|
|
53
|
+
affordance: { id: "companyMd.openDoc", args: { docId } },
|
|
54
|
+
},
|
|
48
55
|
],
|
|
49
56
|
},
|
|
50
57
|
{
|
|
@@ -28,6 +28,7 @@ export const unitOwnerGrantedDefinition: NotificationDefinition<"org.unit_owner_
|
|
|
28
28
|
unitName,
|
|
29
29
|
roleLabel,
|
|
30
30
|
ctaUrl,
|
|
31
|
+
unitId,
|
|
31
32
|
message,
|
|
32
33
|
expiresAt,
|
|
33
34
|
} = payload;
|
|
@@ -50,9 +51,15 @@ export const unitOwnerGrantedDefinition: NotificationDefinition<"org.unit_owner_
|
|
|
50
51
|
{
|
|
51
52
|
type: "message",
|
|
52
53
|
role: "assistant",
|
|
53
|
-
text:
|
|
54
|
+
text: `${unitName} is ready for you.`,
|
|
55
|
+
},
|
|
56
|
+
{
|
|
57
|
+
type: "callToAction",
|
|
58
|
+
// cta-grounded: team.open
|
|
59
|
+
label: "MANAGE TEAM",
|
|
60
|
+
href: ctaUrl,
|
|
61
|
+
affordance: { id: "team.open", args: { unitId } },
|
|
54
62
|
},
|
|
55
|
-
{ type: "callToAction", label: "MANAGE TEAM", href: ctaUrl },
|
|
56
63
|
];
|
|
57
64
|
|
|
58
65
|
const rows: KeyValueRow[] = [
|
|
@@ -72,6 +72,8 @@ export interface NotificationPayloads {
|
|
|
72
72
|
roleLabel: "Unit owner" | "Delegate";
|
|
73
73
|
/** Full URL to view the org unit in the app */
|
|
74
74
|
ctaUrl: string;
|
|
75
|
+
/** The org unit's id — the `team.open` affordance the link performs. */
|
|
76
|
+
unitId: string;
|
|
75
77
|
/** Optional message from the granter, shown in the notification and recorded with the grant */
|
|
76
78
|
message?: string;
|
|
77
79
|
/** ISO timestamp when the grant expires. Absent = permanent (formal unit owners; delegations with no expiry). Rendered as the last key/value row ("Expires: Jun 13, 2026") when present. */
|
|
@@ -110,6 +112,8 @@ export interface NotificationPayloads {
|
|
|
110
112
|
chatTitle: string;
|
|
111
113
|
/** Full URL of the chat itself — the link that opens it in the recipient's app */
|
|
112
114
|
chatUrl: string;
|
|
115
|
+
/** The chat's id — the `chat.open` affordance the link performs. */
|
|
116
|
+
chatId: string;
|
|
113
117
|
/** What the share granted: Read only or Collaborate */
|
|
114
118
|
access: "read" | "collaborate";
|
|
115
119
|
/** First ~200 chars of chat content for preview. Generated server-side. */
|
|
@@ -158,6 +162,8 @@ export interface NotificationPayloads {
|
|
|
158
162
|
accessLevel: "editor" | "commenter" | "viewer";
|
|
159
163
|
/** Full URL to view the node */
|
|
160
164
|
docUrl: string;
|
|
165
|
+
/** The company.md node's id — the `companyMd.openDoc` affordance the link performs. */
|
|
166
|
+
docId: string;
|
|
161
167
|
/**
|
|
162
168
|
* Optional note from the approver, included in the notification only (not
|
|
163
169
|
* persisted) — the approval counterpart of the deny kind's `reason`
|
package/src/proactive/README.md
CHANGED
|
@@ -176,6 +176,21 @@ the three from another.
|
|
|
176
176
|
time. The deliberate opposite of the durable-inbox rule, and both are
|
|
177
177
|
correct: a transcript that changed under the reader would be a lie about the
|
|
178
178
|
conversation.
|
|
179
|
+
- **A pushed chat offers its next steps as affordance instances, never as
|
|
180
|
+
chips or prose** (control ADR `affordance-system`; contracts ADR
|
|
181
|
+
`affordance-catalog-wave-and-grounded-ctas`). `ProactiveChatMessage.affordances`
|
|
182
|
+
is REQUIRED on every composer — an empty list is an explicit decision — and
|
|
183
|
+
holds `OfferedAffordance` instances (ids and args, never labels or hrefs),
|
|
184
|
+
frozen with the message. The backend chat writer persists them as a
|
|
185
|
+
`data-affordance-offer` part (`../message-parts/affordance-offer.ts`) and the
|
|
186
|
+
app renders them as real controls, re-gated per viewer. A chip only sends a
|
|
187
|
+
chat prompt, so a next step is never a chip, and the prose states facts
|
|
188
|
+
rather than telling the reader where to go. A projected id (`review.open`)
|
|
189
|
+
is never offered: the five billing chats offer `billing.open`,
|
|
190
|
+
`orgBecameShared` offers `workspace.openOrgSettings`, and `morningBrief`,
|
|
191
|
+
`sourceSyncReviewReady` (opening its review is the projected `review.open`,
|
|
192
|
+
and its facts carry no request id) and
|
|
193
|
+
`workflowNeedsAttention` (its outcome carries its own offer) offer nothing.
|
|
179
194
|
|
|
180
195
|
## Public API
|
|
181
196
|
|
|
@@ -211,7 +226,7 @@ the three from another.
|
|
|
211
226
|
| `SourceSyncReviewReadyFacts` | `orgName` + `checkpoint`; no count, no document name, no source name |
|
|
212
227
|
| `ExplainedWorkflowOutcomeData` | The settlement data part with an explained, fact-free outcome |
|
|
213
228
|
| `WorkflowNeedsAttentionFacts` | `narration` (backend settlement copy) + the fact-free `outcome` data part |
|
|
214
|
-
| `ProactiveChatMessage` | `title`, `text`, 0..4
|
|
229
|
+
| `ProactiveChatMessage` | `title`, `text`, 0..4 chips, the offered `affordances`, an optional `outcome` |
|
|
215
230
|
| `ProactiveChatComposer` | The composer port — `template` plus an arrow-property `compose(facts)` |
|
|
216
231
|
| `PROACTIVE_CHAT_MAX_REPLIES` | At most this many chips (4); more is a menu, not a chip row |
|
|
217
232
|
| `PROACTIVE_CHAT_COMPOSERS` | The total template → composer registry (see `templates/README.md`) |
|
|
@@ -222,7 +237,8 @@ Type-only imports of `UserNotificationKind` (`../user-notifications`),
|
|
|
222
237
|
`NotificationKind` (`../notifications`) and `ActionItemKind`
|
|
223
238
|
(`../action-items`) — the three vocabularies this one composes — and of
|
|
224
239
|
`SuggestedReply` (`../message-parts`), the chip shape a composer's `replies`
|
|
225
|
-
reuse rather than redeclare
|
|
240
|
+
reuse rather than redeclare, and of `OfferedAffordance` (`../affordances`),
|
|
241
|
+
the instance shape a composer's `affordances` offer. Type-only imports of `WorkflowOutcomeData`
|
|
226
242
|
(`../message-parts`), `ExplainedWorkflowOutcome` (`../workflows`) and
|
|
227
243
|
`WorkflowType` for the workflow settlement chat; the dependency runs
|
|
228
244
|
proactive → workflows, never the reverse. No `zod`, no runtime imports; pure vocabulary
|
|
@@ -42,7 +42,8 @@ cannot.
|
|
|
42
42
|
every template in `PROACTIVE_CHAT_TEMPLATES` has a composer whose `template`
|
|
43
43
|
equals its key (the type proves totality; this proves no entry is an
|
|
44
44
|
accidental `undefined`), the org-became-shared prose NAMES THE RELOCATION and
|
|
45
|
-
every one of the five sections that
|
|
45
|
+
every one of the five sections that moved to the workspace (offering
|
|
46
|
+
`workspace.openOrgSettings` as the destination), the
|
|
46
47
|
morning brief PERSONALIZES THROUGH ITS FACTS (two recipients of one
|
|
47
48
|
occurrence get different prose from one composer), chips stay within
|
|
48
49
|
`PROACTIVE_CHAT_MAX_REPLIES`, and composers emit content — no markup, no
|
|
@@ -58,6 +59,15 @@ cannot.
|
|
|
58
59
|
fact-free outcome (schema-valid, `facts: null`) and no chips, frames the
|
|
59
60
|
backend narration verbatim, has a title per workflow type with no digit and
|
|
60
61
|
no next-step wording; and both inbox rows supersede per workflow type.
|
|
62
|
+
- `proactive-offers.test.ts` — pushed chats offer next steps as affordance
|
|
63
|
+
instances, never as chips or prose (control ADR `affordance-system`): every
|
|
64
|
+
composer returns an explicit `affordances` list, the billing chats offer
|
|
65
|
+
`billing.open` and carry no add-funds or resubscribe chip, org-became-shared
|
|
66
|
+
offers `workspace.openOrgSettings`, no composer offers a projected id, no
|
|
67
|
+
title, text, chip label or chip prompt carries call-to-action wording (the
|
|
68
|
+
cta-prose-ratchet regex; `workflowNeedsAttention` excepted, since it frames
|
|
69
|
+
the backend's narration verbatim), and `WireSurfaceBuilder.affordanceOffer`
|
|
70
|
+
carries the offered instances verbatim.
|
|
61
71
|
- `workflow-attention-fixtures.ts` — explained, fact-free outcomes for those
|
|
62
72
|
tests and the registry-wide fixtures. A COMPILED module, so each fixture is
|
|
63
73
|
typechecked against `ExplainedWorkflowOutcome`.
|
|
@@ -82,7 +82,7 @@ describe("proactive chat templates", () => {
|
|
|
82
82
|
orgName: "Acme",
|
|
83
83
|
joinerDisplayName: "Ada",
|
|
84
84
|
});
|
|
85
|
-
expect(msg.
|
|
85
|
+
expect(msg.affordances).toEqual([{ id: "workspace.openOrgSettings" }]);
|
|
86
86
|
expect(msg.text).toContain("relocation");
|
|
87
87
|
expect(msg.replies.length).toBeLessThanOrEqual(PROACTIVE_CHAT_MAX_REPLIES);
|
|
88
88
|
});
|
|
@@ -102,7 +102,6 @@ describe("proactive chat templates", () => {
|
|
|
102
102
|
"AI usage",
|
|
103
103
|
"Spending controls",
|
|
104
104
|
"Billing",
|
|
105
|
-
"My settings",
|
|
106
105
|
]) {
|
|
107
106
|
expect(msg.text, section).toContain(section);
|
|
108
107
|
}
|
|
@@ -131,7 +130,9 @@ describe("proactive chat templates", () => {
|
|
|
131
130
|
expect(msg.text, trigger).not.toContain("accepted your invite");
|
|
132
131
|
// The half that does not vary: the relocation is the same either way.
|
|
133
132
|
expect(msg.text, trigger).toContain("relocation, not an addition");
|
|
134
|
-
expect(msg.
|
|
133
|
+
expect(msg.affordances, trigger).toEqual([
|
|
134
|
+
{ id: "workspace.openOrgSettings" },
|
|
135
|
+
]);
|
|
135
136
|
}
|
|
136
137
|
});
|
|
137
138
|
|
|
@@ -149,9 +150,9 @@ describe("proactive chat templates", () => {
|
|
|
149
150
|
"Ada accepted your invite, so Acme moved from a personal workspace to a " +
|
|
150
151
|
"shared one.\n\n" +
|
|
151
152
|
"This is a relocation, not an addition: Integrations, Invite team " +
|
|
152
|
-
"members, AI usage, Spending controls and Billing have moved
|
|
153
|
-
"
|
|
154
|
-
"belong to the workspace rather than to you.",
|
|
153
|
+
"members, AI usage, Spending controls and Billing have moved from your " +
|
|
154
|
+
"own area to the workspace's. Nothing was removed — the five sections " +
|
|
155
|
+
"now belong to the workspace rather than to you.",
|
|
155
156
|
);
|
|
156
157
|
});
|
|
157
158
|
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
|
|
3
|
+
import { PROJECTED_AFFORDANCE_IDS } from "../../affordances/affordance";
|
|
4
|
+
import { WireSurfaceBuilder } from "../../message-parts/wire";
|
|
5
|
+
import type { ProactiveChatMessage } from "../composer";
|
|
6
|
+
import type { ProactiveChatFacts } from "../facts";
|
|
7
|
+
import {
|
|
8
|
+
PROACTIVE_CHAT_TEMPLATES,
|
|
9
|
+
type ProactiveChatTemplate,
|
|
10
|
+
} from "../surfaces";
|
|
11
|
+
import { PROACTIVE_CHAT_COMPOSERS } from "../templates/index";
|
|
12
|
+
import {
|
|
13
|
+
DRIVE_AUTH_NARRATION,
|
|
14
|
+
DRIVE_AUTH_OUTCOME,
|
|
15
|
+
} from "./workflow-attention-fixtures";
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Pushed chats offer next steps as affordance instances, never as chips or
|
|
19
|
+
* prose (control ADR affordance-system; PRD-01137).
|
|
20
|
+
*/
|
|
21
|
+
const CALL_TO_ACTION =
|
|
22
|
+
/\byou can\b|\btry\b|\bagain\b|\bsettings\b|\breconnect\b|\badd funds\b|\bclick\b|\bgo to\b|\bopen\b/i;
|
|
23
|
+
|
|
24
|
+
const FACTS: { readonly [T in ProactiveChatTemplate]: ProactiveChatFacts[T] } =
|
|
25
|
+
{
|
|
26
|
+
orgBecameShared: {
|
|
27
|
+
trigger: "member_added",
|
|
28
|
+
orgName: "Acme",
|
|
29
|
+
joinerDisplayName: "Ada",
|
|
30
|
+
},
|
|
31
|
+
morningBrief: {
|
|
32
|
+
recipientDisplayName: "Ada",
|
|
33
|
+
orgName: "Acme",
|
|
34
|
+
orgLocalDateLabel: "Tuesday 25 August",
|
|
35
|
+
pendingActionItemCount: 2,
|
|
36
|
+
unreadNotificationCount: 5,
|
|
37
|
+
},
|
|
38
|
+
billingBalanceExhausted: { orgName: "Acme", autoRechargeEnabled: false },
|
|
39
|
+
billingAutoRechargeFailed: { orgName: "Acme", paymentMethodPresent: true },
|
|
40
|
+
billingPaymentPastDue: { orgName: "Acme", paymentMethodPresent: false },
|
|
41
|
+
billingSuspended: { orgName: "Acme" },
|
|
42
|
+
billingSubscriptionCanceled: { orgName: "Acme" },
|
|
43
|
+
sourceSyncReviewReady: { orgName: "Acme", checkpoint: "synthesis" },
|
|
44
|
+
workflowNeedsAttention: {
|
|
45
|
+
narration: DRIVE_AUTH_NARRATION,
|
|
46
|
+
outcome: { workflowId: "wf-1", outcome: DRIVE_AUTH_OUTCOME },
|
|
47
|
+
},
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
function compose(template: ProactiveChatTemplate): ProactiveChatMessage {
|
|
51
|
+
const fn = PROACTIVE_CHAT_COMPOSERS[template].compose as (
|
|
52
|
+
facts: ProactiveChatFacts[ProactiveChatTemplate],
|
|
53
|
+
) => ProactiveChatMessage;
|
|
54
|
+
return fn(FACTS[template]);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
const BILLING = [
|
|
58
|
+
"billingBalanceExhausted",
|
|
59
|
+
"billingAutoRechargeFailed",
|
|
60
|
+
"billingPaymentPastDue",
|
|
61
|
+
"billingSuspended",
|
|
62
|
+
"billingSubscriptionCanceled",
|
|
63
|
+
] as const;
|
|
64
|
+
|
|
65
|
+
describe("proactive chat offers", () => {
|
|
66
|
+
it("every composer returns an explicit affordance offer", () => {
|
|
67
|
+
for (const template of PROACTIVE_CHAT_TEMPLATES) {
|
|
68
|
+
expect(Array.isArray(compose(template).affordances), template).toBe(true);
|
|
69
|
+
}
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
it("billing chats offer billing.open and no add-funds chip", () => {
|
|
73
|
+
for (const template of BILLING) {
|
|
74
|
+
const msg = compose(template);
|
|
75
|
+
expect(msg.affordances, template).toEqual([{ id: "billing.open" }]);
|
|
76
|
+
expect(
|
|
77
|
+
msg.replies.map((r) => r.id),
|
|
78
|
+
template,
|
|
79
|
+
).not.toContain("add-funds");
|
|
80
|
+
expect(
|
|
81
|
+
msg.replies.map((r) => r.id),
|
|
82
|
+
template,
|
|
83
|
+
).not.toContain("resubscribe");
|
|
84
|
+
}
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
it("org became shared offers the org settings", () => {
|
|
88
|
+
expect(compose("orgBecameShared").affordances).toEqual([
|
|
89
|
+
{ id: "workspace.openOrgSettings" },
|
|
90
|
+
]);
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
it("no composer offers a projected affordance", () => {
|
|
94
|
+
for (const template of PROACTIVE_CHAT_TEMPLATES) {
|
|
95
|
+
for (const affordance of compose(template).affordances) {
|
|
96
|
+
expect(
|
|
97
|
+
PROJECTED_AFFORDANCE_IDS as readonly string[],
|
|
98
|
+
template,
|
|
99
|
+
).not.toContain(affordance.id);
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
it("no composer text, chip label or chip prompt carries call-to-action wording", () => {
|
|
105
|
+
for (const template of PROACTIVE_CHAT_TEMPLATES) {
|
|
106
|
+
if (template === "workflowNeedsAttention") continue; // frames the backend's narration verbatim
|
|
107
|
+
const msg = compose(template);
|
|
108
|
+
expect(msg.title, template).not.toMatch(CALL_TO_ACTION);
|
|
109
|
+
expect(msg.text, template).not.toMatch(CALL_TO_ACTION);
|
|
110
|
+
for (const reply of msg.replies) {
|
|
111
|
+
expect(reply.label, template).not.toMatch(CALL_TO_ACTION);
|
|
112
|
+
expect(reply.prompt ?? "", template).not.toMatch(CALL_TO_ACTION);
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
it("the affordance offer part carries the offered instances verbatim", () => {
|
|
118
|
+
const affordances = compose("billingSuspended").affordances;
|
|
119
|
+
expect(WireSurfaceBuilder.affordanceOffer({ affordances })).toEqual({
|
|
120
|
+
type: "data-affordance-offer",
|
|
121
|
+
data: { affordances: [{ id: "billing.open" }] },
|
|
122
|
+
});
|
|
123
|
+
});
|
|
124
|
+
});
|
|
@@ -490,7 +490,7 @@ describe("the kind registry", () => {
|
|
|
490
490
|
expect(reply.label, checkpoint).not.toMatch(/[0-9]/);
|
|
491
491
|
expect(reply.prompt ?? "", checkpoint).not.toMatch(/[0-9]/);
|
|
492
492
|
}
|
|
493
|
-
expect(msg.replies, checkpoint).toHaveLength(
|
|
493
|
+
expect(msg.replies, checkpoint).toHaveLength(2);
|
|
494
494
|
}
|
|
495
495
|
});
|
|
496
496
|
|
|
@@ -34,6 +34,7 @@
|
|
|
34
34
|
* reader would be a lie about the conversation.
|
|
35
35
|
*/
|
|
36
36
|
|
|
37
|
+
import type { OfferedAffordance } from "../affordances/affordance";
|
|
37
38
|
import type { SuggestedReply } from "../message-parts/suggested-replies";
|
|
38
39
|
import type { WorkflowOutcomeData } from "../message-parts/workflow-outcome";
|
|
39
40
|
import type { ProactiveChatFacts } from "./facts";
|
|
@@ -66,6 +67,16 @@ export interface ProactiveChatMessage {
|
|
|
66
67
|
readonly text: string;
|
|
67
68
|
/** 0..`PROACTIVE_CHAT_MAX_REPLIES` chips. */
|
|
68
69
|
readonly replies: readonly SuggestedReply[];
|
|
70
|
+
/**
|
|
71
|
+
* The next steps this chat OFFERS, frozen with it (control ADR
|
|
72
|
+
* affordance-system). A chip only sends a chat prompt, so a next step is
|
|
73
|
+
* never a chip: it is an affordance instance the chat writer persists as a
|
|
74
|
+
* `data-affordance-offer` part and the app renders as a control, re-gated per
|
|
75
|
+
* viewer. REQUIRED — an empty list is an explicit decision. A projected id
|
|
76
|
+
* (`review.open`) is never offered here. A template that reports a workflow
|
|
77
|
+
* outcome leaves this empty: the outcome carries its own offer.
|
|
78
|
+
*/
|
|
79
|
+
readonly affordances: readonly OfferedAffordance[];
|
|
69
80
|
/**
|
|
70
81
|
* The settled workflow a template reports, when it reports one. The chat
|
|
71
82
|
* writer persists it as a `data-workflow-outcome` part beside the prose, so
|
|
@@ -31,9 +31,17 @@ chat. That is the precedent notify/'s README states for outbound notifications
|
|
|
31
31
|
- At most `PROACTIVE_CHAT_MAX_REPLIES` chips. More is a menu, not a chip row.
|
|
32
32
|
- A file per template, named after it in kebab-case. Templates do not share a
|
|
33
33
|
module — the point is that a template is self-contained.
|
|
34
|
-
- `org-became-shared.ts` NAMES THE RELOCATION: the five sections that
|
|
35
|
-
|
|
36
|
-
(the app ADR with slug
|
|
34
|
+
- `org-became-shared.ts` NAMES THE RELOCATION: the five sections that moved
|
|
35
|
+
from the person's own area to the workspace's. Unannounced, that reads as
|
|
36
|
+
things going missing (the app ADR with slug
|
|
37
|
+
`nav-posture-opens-on-invite-send`). The destination is the
|
|
38
|
+
`workspace.openOrgSettings` affordance it offers, not prose.
|
|
39
|
+
- **Every composer returns an explicit `affordances` offer** (control ADR
|
|
40
|
+
`affordance-system`): the five billing templates `[{ id: "billing.open" }]`,
|
|
41
|
+
`org-became-shared.ts` `[{ id: "workspace.openOrgSettings" }]`, the rest
|
|
42
|
+
`[]`. A next step is never a chip (a chip only sends a prompt) and never
|
|
43
|
+
prose telling the reader where to go; a projected id is never offered.
|
|
44
|
+
`../__tests__/proactive-offers.test.ts` pins all of it.
|
|
37
45
|
- `morning-brief.ts` PERSONALIZES THROUGH THE FACTS, never the occurrence. One
|
|
38
46
|
`brief.morning` occurrence per org per org-local day is shared by every
|
|
39
47
|
recipient; two people's briefs differ only because the `MorningBriefFacts`
|
|
@@ -29,7 +29,7 @@ export const billingBalanceExhaustedComposer: ProactiveChatComposer<"billingBala
|
|
|
29
29
|
text:
|
|
30
30
|
`${facts.orgName} has run out of AI balance, so AI work is paused. ` +
|
|
31
31
|
`Nothing was lost — every chat, document and work item is exactly ` +
|
|
32
|
-
`where you left it, and the assistant
|
|
32
|
+
`where you left it, and the assistant resumes answering as soon ` +
|
|
33
33
|
`as the balance is topped up.\n\n` +
|
|
34
34
|
(facts.autoRechargeEnabled
|
|
35
35
|
? `Automatic top-up is switched on, so this usually clears itself ` +
|
|
@@ -41,11 +41,6 @@ export const billingBalanceExhaustedComposer: ProactiveChatComposer<"billingBala
|
|
|
41
41
|
`\n\nThe current balance is on the billing page, which is the only ` +
|
|
42
42
|
`place it is guaranteed to be up to date.`,
|
|
43
43
|
replies: [
|
|
44
|
-
{
|
|
45
|
-
id: "add-funds",
|
|
46
|
-
label: "How do I add funds?",
|
|
47
|
-
prompt: "How do I add AI funds to this workspace?",
|
|
48
|
-
},
|
|
49
44
|
{
|
|
50
45
|
id: "auto-top-up",
|
|
51
46
|
label: "Explain automatic top-up",
|
|
@@ -59,5 +54,6 @@ export const billingBalanceExhaustedComposer: ProactiveChatComposer<"billingBala
|
|
|
59
54
|
"What exactly stops working while AI is paused, and what keeps working?",
|
|
60
55
|
},
|
|
61
56
|
],
|
|
57
|
+
affordances: [{ id: "billing.open" }],
|
|
62
58
|
}),
|
|
63
59
|
};
|