@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.
Files changed (68) hide show
  1. package/package.json +13 -12
  2. package/src/__tests__/resource-keys.test.ts +1 -0
  3. package/src/action-items/README.md +12 -4
  4. package/src/action-items/schemas.ts +10 -0
  5. package/src/affordances/README.md +52 -1
  6. package/src/affordances/__tests__/README.md +10 -1
  7. package/src/affordances/__tests__/affordances.test.ts +13 -1
  8. package/src/affordances/__tests__/catalog-wave.test.ts +237 -0
  9. package/src/affordances/affordance.ts +56 -4
  10. package/src/affordances/args.ts +44 -1
  11. package/src/affordances/available.ts +59 -0
  12. package/src/affordances/coverage.ts +51 -0
  13. package/src/affordances/ids.ts +28 -0
  14. package/src/affordances/index.ts +17 -0
  15. package/src/api/README.md +1 -0
  16. package/src/api/generated-spec-hash.ts +2 -2
  17. package/src/api/generated.ts +506 -1
  18. package/src/api/primitives.ts +13 -0
  19. package/src/billing/README.md +1 -0
  20. package/src/billing/refusals.ts +12 -0
  21. package/src/chat/__tests__/page-context.test.ts +22 -43
  22. package/src/chat/__tests__/runtime-profile.test.ts +20 -44
  23. package/src/generated/openapi-routes.ts +1 -0
  24. package/src/message-parts/README.md +5 -0
  25. package/src/message-parts/affordance-offer.ts +44 -0
  26. package/src/message-parts/index.ts +7 -0
  27. package/src/message-parts/types.ts +7 -1
  28. package/src/message-parts/wire.ts +18 -0
  29. package/src/notifications/README.md +17 -3
  30. package/src/notifications/__tests__/README.md +4 -0
  31. package/src/notifications/__tests__/__snapshots__/render-snapshot.test.ts.snap +27 -25
  32. package/src/notifications/__tests__/fixtures.ts +7 -1
  33. package/src/notifications/__tests__/grounded-ctas.test.ts +103 -0
  34. package/src/notifications/__tests__/render-snapshot.test.ts +1 -1
  35. package/src/notifications/content.ts +9 -0
  36. package/src/notifications/kinds/billing-auto-recharge-failed.ts +4 -2
  37. package/src/notifications/kinds/billing-payment-past-due.ts +2 -0
  38. package/src/notifications/kinds/billing-suspended.ts +2 -0
  39. package/src/notifications/kinds/chat-shared.ts +10 -3
  40. package/src/notifications/kinds/company-md-access-approved.ts +13 -6
  41. package/src/notifications/kinds/share-granted.ts +1 -1
  42. package/src/notifications/kinds/unit-owner-granted.ts +9 -2
  43. package/src/notifications/payloads.ts +6 -0
  44. package/src/proactive/README.md +18 -2
  45. package/src/proactive/__tests__/README.md +11 -1
  46. package/src/proactive/__tests__/chat-templates.test.ts +7 -6
  47. package/src/proactive/__tests__/proactive-offers.test.ts +124 -0
  48. package/src/proactive/__tests__/vocabulary.test.ts +1 -1
  49. package/src/proactive/composer.ts +11 -0
  50. package/src/proactive/templates/README.md +11 -3
  51. package/src/proactive/templates/billing-auto-recharge-failed.ts +1 -0
  52. package/src/proactive/templates/billing-balance-exhausted.ts +2 -6
  53. package/src/proactive/templates/billing-payment-past-due.ts +1 -0
  54. package/src/proactive/templates/billing-subscription-canceled.ts +1 -5
  55. package/src/proactive/templates/billing-suspended.ts +1 -0
  56. package/src/proactive/templates/morning-brief.ts +1 -0
  57. package/src/proactive/templates/org-became-shared.ts +4 -3
  58. package/src/proactive/templates/source-sync-review-ready.ts +3 -8
  59. package/src/proactive/templates/workflow-needs-attention.ts +1 -0
  60. package/src/rate-limit/README.md +27 -1
  61. package/src/rate-limit/__tests__/README.md +23 -0
  62. package/src/rate-limit/__tests__/rate-limited-error.test.ts +53 -0
  63. package/src/rate-limit/index.ts +33 -0
  64. package/src/resource-key-tables.ts +1 -0
  65. package/src/resource-key-types.ts +3 -0
  66. package/src/resource-keys.ts +1 -0
  67. package/src/root-exports/agent-runtime.ts +14 -2
  68. 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 "Open to view." prompt rather than stacking
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 rather than running the same card again — so " +
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 } = payload;
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
- { type: "callToAction", label: "OPEN CHAT", href: chatUrl },
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. You can take part in it."
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 "Open to
5
- * view." prompt — never both. An approver who wrote a note has already said what
6
- * the prompt would say, and stacking the two reads as a form letter with a
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 } = payload;
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: "Open to view.",
46
+ text: "The document is ready for you.",
46
47
  },
47
- { type: "callToAction", label: "OPEN", href: docUrl },
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
  {
@@ -52,7 +52,7 @@ export const shareGrantedDefinition: NotificationDefinition<"share.granted"> = {
52
52
  : {
53
53
  type: "message",
54
54
  role: "assistant",
55
- text: "Open to view.",
55
+ text: "It is ready for you.",
56
56
  },
57
57
  { type: "callToAction", label: "OPEN", href: ctaUrl },
58
58
  ],
@@ -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: "Open the team to get started.",
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`
@@ -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 `replies` chips, and an optional settled `outcome` |
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. Type-only imports of `WorkflowOutcomeData`
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 leave My settings for Org settings, the
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.text).toContain("Org settings");
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.text, trigger).toContain("Org settings");
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 out of My " +
153
- "settings and into Org settings. Nothing was removed — the five sections now " +
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(3);
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 leave My
35
- settings for Org settings. Unannounced, that reads as things going missing
36
- (the app ADR with slug `nav-posture-opens-on-invite-send`).
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`
@@ -49,5 +49,6 @@ export const billingAutoRechargeFailedComposer: ProactiveChatComposer<"billingAu
49
49
  prompt: "Can I add AI funds manually instead of waiting for a retry?",
50
50
  },
51
51
  ],
52
+ affordances: [{ id: "billing.open" }],
52
53
  }),
53
54
  };
@@ -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 starts answering again as soon ` +
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
  };