@company-semantics/contracts 58.1.0 → 58.3.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 +1 -1
- package/src/__tests__/resource-keys.test.ts +30 -0
- package/src/api/generated-spec-hash.ts +2 -2
- package/src/api/generated.ts +77 -1
- package/src/chat/README.md +15 -4
- package/src/chat/__tests__/proactive-kind.test.ts +51 -0
- package/src/chat/index.ts +9 -0
- package/src/chat/proactive-kind.ts +51 -0
- package/src/chat/schemas.ts +92 -1
- package/src/chat/types.ts +19 -1
- package/src/generated/openapi-routes.ts +1 -0
- package/src/index.ts +58 -0
- package/src/message-parts/README.md +5 -0
- package/src/message-parts/__tests__/suggested-replies.test.ts +52 -0
- package/src/message-parts/__tests__/wire.test.ts +48 -0
- package/src/message-parts/index.ts +8 -0
- package/src/message-parts/suggested-replies.ts +48 -0
- package/src/message-parts/types.ts +7 -1
- package/src/message-parts/wire.ts +26 -0
- package/src/proactive/README.md +125 -0
- package/src/proactive/__tests__/README.md +56 -0
- package/src/proactive/__tests__/chat-templates.test.ts +167 -0
- package/src/proactive/__tests__/compile-fixtures.ts +110 -0
- package/src/proactive/__tests__/vocabulary.test.ts +279 -0
- package/src/proactive/classes.ts +125 -0
- package/src/proactive/composer.ts +104 -0
- package/src/proactive/facts.ts +87 -0
- package/src/proactive/index.ts +52 -0
- package/src/proactive/kinds.ts +127 -0
- package/src/proactive/plan.ts +79 -0
- package/src/proactive/registry.ts +71 -0
- package/src/proactive/surfaces.ts +59 -0
- package/src/proactive/templates/README.md +58 -0
- package/src/proactive/templates/index.ts +32 -0
- package/src/proactive/templates/morning-brief.ts +77 -0
- package/src/proactive/templates/org-became-shared.ts +54 -0
- package/src/resource-key-types.ts +9 -0
- package/src/resource-keys.ts +2 -0
- package/src/user-notifications/README.md +10 -0
- package/src/user-notifications/kinds.ts +28 -0
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Negative typecheck fixtures — the compile-error claims the proactive
|
|
3
|
+
* vocabulary makes (ADR-CONTRACTS-142), each pinned with `@ts-expect-error`.
|
|
4
|
+
*
|
|
5
|
+
* The directive is the proof. If a claim ever stops being true — say the plan
|
|
6
|
+
* union in `../plan` loosens `bannerType?: never` to a plain optional — the
|
|
7
|
+
* directive goes UNUSED and `pnpm typecheck` fails with TS2578. A claim that is
|
|
8
|
+
* only stated in a doc comment cannot fail that way.
|
|
9
|
+
*
|
|
10
|
+
* WHY A COMPILED MODULE AND NOT `vocabulary.test.ts`. `src/tsconfig.json`
|
|
11
|
+
* excludes every `*.test.ts` file and vitest does not typecheck, so a directive
|
|
12
|
+
* written inside a test file is never checked at all: an unused one would prove
|
|
13
|
+
* nothing and fail nothing. This file is not a `*.test.ts`, so `tsc -b`
|
|
14
|
+
* compiles it (the same placement as `../../notifications/__tests__/fixtures.ts`),
|
|
15
|
+
* and `vocabulary.test.ts` imports the fixtures to prove the RUNTIME half of
|
|
16
|
+
* each claim — that the class-table check rejects the same plans the compiler
|
|
17
|
+
* rejects.
|
|
18
|
+
*
|
|
19
|
+
* Each directive sits IMMEDIATELY above the line the compiler reports on: a
|
|
20
|
+
* wrongly-typed or excess property is reported at that property, a missing
|
|
21
|
+
* property at the initializer.
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
import type { ProactiveEventKind } from "../kinds";
|
|
25
|
+
import type { ProactiveEventDefinition } from "../plan";
|
|
26
|
+
import { PROACTIVE_EVENT_KINDS } from "../registry";
|
|
27
|
+
|
|
28
|
+
/** The registry's declared shape, so the totality fixtures fit on one line. */
|
|
29
|
+
type ProactiveRegistry = Record<ProactiveEventKind, ProactiveEventDefinition>;
|
|
30
|
+
|
|
31
|
+
// =============================================================================
|
|
32
|
+
// Plans the class table forbids
|
|
33
|
+
// =============================================================================
|
|
34
|
+
|
|
35
|
+
/** CLAIM 1 — a `notice` has no banner, so naming a `bannerType` is an error. */
|
|
36
|
+
const noticeNamingBanner: ProactiveEventDefinition = {
|
|
37
|
+
class: "notice",
|
|
38
|
+
inboxKind: "proactive.org_became_shared",
|
|
39
|
+
// @ts-expect-error a notice has no banner; its `bannerType` is typed `never`
|
|
40
|
+
bannerType: "first_member_joined",
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
/** CLAIM 2 — an `announcement` claims a banner, so omitting one is an error. */
|
|
44
|
+
// @ts-expect-error an announcement MUST name the banner its class earns
|
|
45
|
+
const announcementOmittingBanner: ProactiveEventDefinition = {
|
|
46
|
+
class: "announcement",
|
|
47
|
+
chatTemplate: "orgBecameShared",
|
|
48
|
+
inboxKind: "proactive.org_became_shared",
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
/** CLAIM 3 — `explained` has no banner either; naming one is an error. */
|
|
52
|
+
const explainedNamingBanner: ProactiveEventDefinition = {
|
|
53
|
+
class: "explained",
|
|
54
|
+
chatTemplate: "orgBecameShared",
|
|
55
|
+
inboxKind: "proactive.org_became_shared",
|
|
56
|
+
// @ts-expect-error explained has no banner; its `bannerType` is typed `never`
|
|
57
|
+
bannerType: "first_member_joined",
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* CLAIM 3, for the class the second real kind uses — `briefing` shares the
|
|
62
|
+
* `explained` member, so `brief.morning` could not name a banner even if it
|
|
63
|
+
* tried. This is the compile-time proof that adding it needed no machinery.
|
|
64
|
+
*/
|
|
65
|
+
const briefingNamingBanner: ProactiveEventDefinition = {
|
|
66
|
+
class: "briefing",
|
|
67
|
+
chatTemplate: "morningBrief",
|
|
68
|
+
inboxKind: "proactive.brief_morning",
|
|
69
|
+
// @ts-expect-error a briefing has no banner; its `bannerType` is typed `never`
|
|
70
|
+
bannerType: "first_member_joined",
|
|
71
|
+
};
|
|
72
|
+
|
|
73
|
+
// =============================================================================
|
|
74
|
+
// Registries that are not total
|
|
75
|
+
// =============================================================================
|
|
76
|
+
|
|
77
|
+
/** CLAIM 4 — a kind with no plan: the registry is not total, so it is an error. */
|
|
78
|
+
// @ts-expect-error a registry missing a kind's plan does not satisfy the Record
|
|
79
|
+
const registryMissingKind = {} as const satisfies ProactiveRegistry;
|
|
80
|
+
|
|
81
|
+
/** CLAIM 4, other direction — a plan for a string that is not a kind. */
|
|
82
|
+
const registryNamingNonKind = {
|
|
83
|
+
...PROACTIVE_EVENT_KINDS,
|
|
84
|
+
// @ts-expect-error a plan for a non-kind is an excess property
|
|
85
|
+
"made.up": PROACTIVE_EVENT_KINDS["org.became_shared"],
|
|
86
|
+
} as const satisfies ProactiveRegistry;
|
|
87
|
+
|
|
88
|
+
// =============================================================================
|
|
89
|
+
// Exports — the runtime half lives in vocabulary.test.ts
|
|
90
|
+
// =============================================================================
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* The plans above, typed as the union they failed to satisfy. At runtime they
|
|
94
|
+
* are ordinary objects, which is what lets the vocabulary test show that the
|
|
95
|
+
* class-table check rejects every one of them.
|
|
96
|
+
*/
|
|
97
|
+
export const PLAN_FIXTURES_THE_COMPILER_REJECTS: Readonly<
|
|
98
|
+
Record<string, ProactiveEventDefinition>
|
|
99
|
+
> = {
|
|
100
|
+
noticeNamingBanner,
|
|
101
|
+
announcementOmittingBanner,
|
|
102
|
+
explainedNamingBanner,
|
|
103
|
+
briefingNamingBanner,
|
|
104
|
+
};
|
|
105
|
+
|
|
106
|
+
/** The two ways a registry stops being total. */
|
|
107
|
+
export const REGISTRY_FIXTURES_THE_COMPILER_REJECTS = {
|
|
108
|
+
registryMissingKind,
|
|
109
|
+
registryNamingNonKind,
|
|
110
|
+
} as const;
|
|
@@ -0,0 +1,279 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The proactive vocabulary's invariants, as tests rather than prose
|
|
3
|
+
* (ADR-CONTRACTS-142).
|
|
4
|
+
*
|
|
5
|
+
* THE load-bearing test is `plan surfaces agree with the class table`. A kind
|
|
6
|
+
* carries ids and its surfaces come from its class, and nothing in the type
|
|
7
|
+
* system can check that the ids PRESENT on a plan match the surfaces its class
|
|
8
|
+
* DECLARES in `CLASS_SURFACES`. The union in `../plan` fixes that per member;
|
|
9
|
+
* this file checks the members against the table itself.
|
|
10
|
+
*
|
|
11
|
+
* The negative typecheck fixtures — the `@ts-expect-error` directives proving
|
|
12
|
+
* the four compile-error claims — live in `./compile-fixtures.ts`, a COMPILED
|
|
13
|
+
* module: `src/tsconfig.json` excludes `*.test.ts`, so a directive written here
|
|
14
|
+
* would never be checked, and an unused one would fail nothing. They are
|
|
15
|
+
* imported below so the runtime check is shown to reject exactly the plans the
|
|
16
|
+
* compiler rejects.
|
|
17
|
+
*/
|
|
18
|
+
import { describe, it, expect } from "vitest";
|
|
19
|
+
import {
|
|
20
|
+
CLASS_SURFACES,
|
|
21
|
+
PROACTIVE_PRESENTATION_CLASSES,
|
|
22
|
+
bornRead,
|
|
23
|
+
} from "../classes";
|
|
24
|
+
import type { ClassSurfacePlan, ProactivePresentationClass } from "../classes";
|
|
25
|
+
import type { ProactiveChatMessage } from "../composer";
|
|
26
|
+
import type { ProactiveChatFacts } from "../facts";
|
|
27
|
+
import { PROACTIVE_EVENT_KIND_IDS } from "../kinds";
|
|
28
|
+
import type { ProactiveEventDefinition } from "../plan";
|
|
29
|
+
import { PROACTIVE_EVENT_KINDS } from "../registry";
|
|
30
|
+
import { PROACTIVE_CHAT_TEMPLATES } from "../surfaces";
|
|
31
|
+
import type { ProactiveChatTemplate } from "../surfaces";
|
|
32
|
+
import { PROACTIVE_CHAT_COMPOSERS } from "../templates/index";
|
|
33
|
+
import { USER_NOTIFICATION_KINDS } from "../../user-notifications/kinds";
|
|
34
|
+
import {
|
|
35
|
+
PLAN_FIXTURES_THE_COMPILER_REJECTS,
|
|
36
|
+
REGISTRY_FIXTURES_THE_COMPILER_REJECTS,
|
|
37
|
+
} from "./compile-fixtures";
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* The registry, read as the union it satisfies rather than as its literal
|
|
41
|
+
* shape. Every member of `ProactiveEventDefinition` carries every id field
|
|
42
|
+
* (the absent ones as optional `never`), so a plan can be asked for an id it
|
|
43
|
+
* does not have and answer `undefined` — which is what the checks below need.
|
|
44
|
+
*/
|
|
45
|
+
const REGISTRY_ENTRIES: ReadonlyArray<
|
|
46
|
+
readonly [kind: string, plan: ProactiveEventDefinition]
|
|
47
|
+
> = Object.entries(PROACTIVE_EVENT_KINDS);
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* The ONE check the compiler cannot do, as a predicate: the ids present on a
|
|
51
|
+
* plan agree with the surfaces its class earns, and the inbox id is always
|
|
52
|
+
* there. Applied positively to the real registry and negatively to the plans
|
|
53
|
+
* the compiler rejects, so the two halves are shown to agree.
|
|
54
|
+
*/
|
|
55
|
+
function planAgreesWithClassTable(plan: ProactiveEventDefinition): boolean {
|
|
56
|
+
const surfaces: ClassSurfacePlan = CLASS_SURFACES[plan.class];
|
|
57
|
+
return (
|
|
58
|
+
(plan.bannerType !== undefined) === surfaces.banner &&
|
|
59
|
+
(plan.chatTemplate !== undefined) === surfaces.chat &&
|
|
60
|
+
plan.inboxKind !== undefined &&
|
|
61
|
+
surfaces.inbox
|
|
62
|
+
);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* One facts factory per template, total over `ProactiveChatTemplate`. A
|
|
67
|
+
* FACTORY rather than a value so the purity test can hand a composer two
|
|
68
|
+
* structurally-equal but distinct objects — a composer that keyed anything on
|
|
69
|
+
* object identity would show up as a difference.
|
|
70
|
+
*/
|
|
71
|
+
const FRESH_FACTS: {
|
|
72
|
+
readonly [T in ProactiveChatTemplate]: () => ProactiveChatFacts[T];
|
|
73
|
+
} = {
|
|
74
|
+
orgBecameShared: () => ({ orgName: "Acme", joinerDisplayName: "Ada" }),
|
|
75
|
+
morningBrief: () => ({
|
|
76
|
+
recipientDisplayName: "Ada",
|
|
77
|
+
orgName: "Acme",
|
|
78
|
+
orgLocalDateLabel: "Tuesday 25 August",
|
|
79
|
+
pendingActionItemCount: 2,
|
|
80
|
+
unreadNotificationCount: 5,
|
|
81
|
+
}),
|
|
82
|
+
};
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Compose a template with fresh facts. The registry is a union of composers
|
|
86
|
+
* whose `compose` parameters differ per template, and TypeScript cannot
|
|
87
|
+
* correlate `PROACTIVE_CHAT_COMPOSERS[t]` with `FRESH_FACTS[t]` through a
|
|
88
|
+
* variable `t` — the cast is the one place the correlation is asserted, and
|
|
89
|
+
* the total factory record above is what keeps it honest.
|
|
90
|
+
*/
|
|
91
|
+
function composeFresh(template: ProactiveChatTemplate): ProactiveChatMessage {
|
|
92
|
+
const compose = PROACTIVE_CHAT_COMPOSERS[template].compose as (
|
|
93
|
+
facts: ProactiveChatFacts[ProactiveChatTemplate],
|
|
94
|
+
) => ProactiveChatMessage;
|
|
95
|
+
return compose(FRESH_FACTS[template]());
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
describe("the class table", () => {
|
|
99
|
+
it("is total over PROACTIVE_PRESENTATION_CLASSES in practice", () => {
|
|
100
|
+
// `satisfies Record<...>` proves totality at compile time; this proves no
|
|
101
|
+
// entry is an accidental `undefined` and that no key is a non-class.
|
|
102
|
+
for (const cls of PROACTIVE_PRESENTATION_CLASSES) {
|
|
103
|
+
expect(CLASS_SURFACES[cls], cls).toBeDefined();
|
|
104
|
+
}
|
|
105
|
+
expect(Object.keys(CLASS_SURFACES).sort()).toEqual(
|
|
106
|
+
[...PROACTIVE_PRESENTATION_CLASSES].sort(),
|
|
107
|
+
);
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
it("every badge names a surface its class actually has", () => {
|
|
111
|
+
for (const cls of PROACTIVE_PRESENTATION_CLASSES) {
|
|
112
|
+
const surfaces: ClassSurfacePlan = CLASS_SURFACES[cls];
|
|
113
|
+
// Every class writes a durable row — `inbox` is literal true throughout.
|
|
114
|
+
expect(surfaces.inbox, `${cls} inbox`).toBe(true);
|
|
115
|
+
// The badge carrier must be a surface the class earns; the union makes
|
|
116
|
+
// the wrong pairing a compile error, and this is the same claim in
|
|
117
|
+
// practice.
|
|
118
|
+
expect(surfaces[surfaces.badge], `${cls} badge=${surfaces.badge}`).toBe(
|
|
119
|
+
true,
|
|
120
|
+
);
|
|
121
|
+
}
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
it("states the born-read rule once, and it matches the doctrine table", () => {
|
|
125
|
+
// The table in `../classes`: the badge carrier is the attention carrier,
|
|
126
|
+
// every other surface the class HAS is the paper trail. Pinned as the
|
|
127
|
+
// concrete rows the doctrine names rather than as a restatement of the
|
|
128
|
+
// helper's body, so a rewrite of `bornRead` that changed a row fails here.
|
|
129
|
+
// A surface the class does not earn has no row to be born into — `chat`
|
|
130
|
+
// is absent for `notice` — so it is not asked.
|
|
131
|
+
const expected: Record<
|
|
132
|
+
ProactivePresentationClass,
|
|
133
|
+
{ readonly chat?: boolean; readonly inbox: boolean }
|
|
134
|
+
> = {
|
|
135
|
+
announcement: { chat: true, inbox: true },
|
|
136
|
+
explained: { chat: false, inbox: true },
|
|
137
|
+
briefing: { chat: false, inbox: true },
|
|
138
|
+
notice: { inbox: false },
|
|
139
|
+
};
|
|
140
|
+
for (const cls of PROACTIVE_PRESENTATION_CLASSES) {
|
|
141
|
+
const surfaces: ClassSurfacePlan = CLASS_SURFACES[cls];
|
|
142
|
+
const row = expected[cls];
|
|
143
|
+
expect(surfaces.chat, `${cls} has a chat`).toBe(row.chat !== undefined);
|
|
144
|
+
if (row.chat !== undefined) {
|
|
145
|
+
expect(bornRead(surfaces, "chat"), `${cls} chat`).toBe(row.chat);
|
|
146
|
+
}
|
|
147
|
+
expect(bornRead(surfaces, "inbox"), `${cls} inbox`).toBe(row.inbox);
|
|
148
|
+
}
|
|
149
|
+
});
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
describe("the kind registry", () => {
|
|
153
|
+
it("is total over PROACTIVE_EVENT_KIND_IDS in practice", () => {
|
|
154
|
+
for (const kind of PROACTIVE_EVENT_KIND_IDS) {
|
|
155
|
+
expect(PROACTIVE_EVENT_KINDS[kind], kind).toBeDefined();
|
|
156
|
+
}
|
|
157
|
+
expect(Object.keys(PROACTIVE_EVENT_KINDS).sort()).toEqual(
|
|
158
|
+
[...PROACTIVE_EVENT_KIND_IDS].sort(),
|
|
159
|
+
);
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
/**
|
|
163
|
+
* THE load-bearing test. A kind carries ids; its surfaces come from its
|
|
164
|
+
* class. Nothing in the type system can check that the ids present match the
|
|
165
|
+
* surfaces the class declares, so it is checked here.
|
|
166
|
+
*/
|
|
167
|
+
it("plan surfaces agree with the class table", () => {
|
|
168
|
+
for (const [kind, plan] of REGISTRY_ENTRIES) {
|
|
169
|
+
const surfaces: ClassSurfacePlan = CLASS_SURFACES[plan.class];
|
|
170
|
+
expect(plan.bannerType !== undefined, `${kind} bannerType`).toBe(
|
|
171
|
+
surfaces.banner,
|
|
172
|
+
);
|
|
173
|
+
expect(plan.chatTemplate !== undefined, `${kind} chatTemplate`).toBe(
|
|
174
|
+
surfaces.chat,
|
|
175
|
+
);
|
|
176
|
+
expect(plan.inboxKind, `${kind} inboxKind`).toBeDefined();
|
|
177
|
+
expect(planAgreesWithClassTable(plan), kind).toBe(true);
|
|
178
|
+
}
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
it("every inbox kind is a real user notification kind", () => {
|
|
182
|
+
// Every class writes a durable row, so every plan names a row kind — and
|
|
183
|
+
// it must be one the inbox vocabulary actually has, or the backend writes
|
|
184
|
+
// a row no renderer owns.
|
|
185
|
+
for (const [kind, plan] of REGISTRY_ENTRIES) {
|
|
186
|
+
expect(USER_NOTIFICATION_KINDS, kind).toContain(plan.inboxKind);
|
|
187
|
+
}
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
it("names its inbox row in the proactive domain, never by the kind string", () => {
|
|
191
|
+
// Identical names across two unions is how one quietly becomes derived
|
|
192
|
+
// from the other (ADR-CONT-108). The producing domain is `proactive` —
|
|
193
|
+
// `src/proactive/` in the backend owns the renderer — matching the
|
|
194
|
+
// `companyMd.*` / `comment.*` convention.
|
|
195
|
+
for (const [kind, plan] of REGISTRY_ENTRIES) {
|
|
196
|
+
expect(plan.inboxKind, kind).not.toBe(kind);
|
|
197
|
+
expect(plan.inboxKind, kind).toMatch(/^proactive\./);
|
|
198
|
+
}
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
it("names kinds in {domain}.{type} form, matching the three unions", () => {
|
|
202
|
+
for (const kind of PROACTIVE_EVENT_KIND_IDS) {
|
|
203
|
+
expect(kind).toMatch(/^[a-z][a-zA-Z0-9]*\.[a-z][a-z0-9_]*$/);
|
|
204
|
+
}
|
|
205
|
+
});
|
|
206
|
+
|
|
207
|
+
it("every chat template has a composer", () => {
|
|
208
|
+
// The composer registry is total over PROACTIVE_CHAT_TEMPLATES at compile
|
|
209
|
+
// time; this proves the id a KIND names reaches a real composer whose
|
|
210
|
+
// `template` is that id, so the backend cannot write an empty message.
|
|
211
|
+
for (const [kind, plan] of REGISTRY_ENTRIES) {
|
|
212
|
+
if (plan.chatTemplate === undefined) continue;
|
|
213
|
+
const composer = PROACTIVE_CHAT_COMPOSERS[plan.chatTemplate];
|
|
214
|
+
expect(composer, kind).toBeDefined();
|
|
215
|
+
expect(composer.template, kind).toBe(plan.chatTemplate);
|
|
216
|
+
}
|
|
217
|
+
});
|
|
218
|
+
|
|
219
|
+
it("sets no emailKind in this wave", () => {
|
|
220
|
+
// notify/'s INV-1: every notification needs an explicit trigger; nothing
|
|
221
|
+
// sends autonomously. A kind that also emailed needs INV-PROACTIVE-TRIGGER
|
|
222
|
+
// reconciled with INV-1 in an ADR — lifting this assertion IS that ADR's
|
|
223
|
+
// job, not a tidy-up.
|
|
224
|
+
for (const [kind, plan] of REGISTRY_ENTRIES) {
|
|
225
|
+
expect(plan.emailKind, kind).toBeUndefined();
|
|
226
|
+
}
|
|
227
|
+
});
|
|
228
|
+
});
|
|
229
|
+
|
|
230
|
+
describe("the chat composers", () => {
|
|
231
|
+
it("composers are pure", () => {
|
|
232
|
+
// Same facts in, byte-identical message out — across two calls handed two
|
|
233
|
+
// distinct-but-equal facts objects. A clock, an env read, a counter or an
|
|
234
|
+
// identity-keyed cache would all surface as a difference here.
|
|
235
|
+
for (const template of PROACTIVE_CHAT_TEMPLATES) {
|
|
236
|
+
const first = composeFresh(template);
|
|
237
|
+
const second = composeFresh(template);
|
|
238
|
+
expect(JSON.stringify(first), template).toBe(JSON.stringify(second));
|
|
239
|
+
expect(first, template).toEqual(second);
|
|
240
|
+
}
|
|
241
|
+
});
|
|
242
|
+
});
|
|
243
|
+
|
|
244
|
+
describe("what the compiler rejects, the class-table check rejects too", () => {
|
|
245
|
+
it("rejects inconsistent plans at compile time", () => {
|
|
246
|
+
// The `@ts-expect-error` directives sit in ./compile-fixtures.ts, where
|
|
247
|
+
// `tsc -b` checks them. Here the same plans go through the runtime check:
|
|
248
|
+
// a notice naming a bannerType, an announcement omitting one, and an
|
|
249
|
+
// explained or a briefing naming one must all disagree with the class
|
|
250
|
+
// table.
|
|
251
|
+
const names = Object.keys(PLAN_FIXTURES_THE_COMPILER_REJECTS);
|
|
252
|
+
expect(names).toEqual([
|
|
253
|
+
"noticeNamingBanner",
|
|
254
|
+
"announcementOmittingBanner",
|
|
255
|
+
"explainedNamingBanner",
|
|
256
|
+
"briefingNamingBanner",
|
|
257
|
+
]);
|
|
258
|
+
for (const [name, plan] of Object.entries(
|
|
259
|
+
PLAN_FIXTURES_THE_COMPILER_REJECTS,
|
|
260
|
+
)) {
|
|
261
|
+
expect(planAgreesWithClassTable(plan), name).toBe(false);
|
|
262
|
+
}
|
|
263
|
+
});
|
|
264
|
+
|
|
265
|
+
it("rejects a registry that is not total at compile time", () => {
|
|
266
|
+
// The fourth claim, both directions: a kind absent from the registry, and
|
|
267
|
+
// a plan for a string that is not a kind. The real registry passes the
|
|
268
|
+
// same two checks in `is total over PROACTIVE_EVENT_KIND_IDS in practice`.
|
|
269
|
+
const { registryMissingKind, registryNamingNonKind } =
|
|
270
|
+
REGISTRY_FIXTURES_THE_COMPILER_REJECTS;
|
|
271
|
+
const ids = new Set<string>(PROACTIVE_EVENT_KIND_IDS);
|
|
272
|
+
expect(
|
|
273
|
+
PROACTIVE_EVENT_KIND_IDS.some((kind) => !(kind in registryMissingKind)),
|
|
274
|
+
).toBe(true);
|
|
275
|
+
expect(
|
|
276
|
+
Object.keys(registryNamingNonKind).some((key) => !ids.has(key)),
|
|
277
|
+
).toBe(true);
|
|
278
|
+
});
|
|
279
|
+
});
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Presentation classes — the TOTAL FUNCTION from "how loud is this" to "which
|
|
3
|
+
* surfaces does it earn" (control ADR slug
|
|
4
|
+
* `proactive-delivery-occurrence-audience-projection`, ADR-CONTRACTS-142).
|
|
5
|
+
*
|
|
6
|
+
* A proactive event kind carries its `class` plus the IDS each surface needs.
|
|
7
|
+
* It never restates the surface booleans; those are derived from this table.
|
|
8
|
+
* If a kind ever duplicated them, the compiler would be left having to prove
|
|
9
|
+
* two declarations agree — and it cannot, which is the guarantee this module
|
|
10
|
+
* exists to provide.
|
|
11
|
+
*
|
|
12
|
+
* INVARIANTS:
|
|
13
|
+
* - TOTAL in both directions. `as const satisfies Record<...>` means a class
|
|
14
|
+
* with no entry fails to compile, and an entry for a non-class fails too.
|
|
15
|
+
* - EXACTLY ONE badge carrier per class. Never zero, never two — the badge is
|
|
16
|
+
* the attention count, and a second carrier would double-count it.
|
|
17
|
+
* - EVERY class writes a durable inbox row. What varies is whether that row
|
|
18
|
+
* is born read, and `bornRead` is the one place that rule is stated.
|
|
19
|
+
* - This is a LOUDNESS axis, not a severity axis. `error|warning|info` exists
|
|
20
|
+
* in this package scoped to org-transformation findings and must not be
|
|
21
|
+
* overloaded here.
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
// =============================================================================
|
|
25
|
+
// ProactivePresentationClass
|
|
26
|
+
// =============================================================================
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* The presentation class of a proactive occurrence — how LOUD it is and which
|
|
30
|
+
* surfaces it earns. Deliberately NOT a severity axis: `error|warning|info`
|
|
31
|
+
* exists in this package scoped to org-transformation findings and must not be
|
|
32
|
+
* overloaded.
|
|
33
|
+
*
|
|
34
|
+
* | class | banner | chat | inbox row | badge carrier |
|
|
35
|
+
* | -------------- | --------- | ---- | --------- | ------------- |
|
|
36
|
+
* | `announcement` | ack-gated | yes | born read | banner |
|
|
37
|
+
* | `explained` | no | yes | born read | chat |
|
|
38
|
+
* | `briefing` | no | yes | born read | chat |
|
|
39
|
+
* | `notice` | no | no | unread | inbox |
|
|
40
|
+
*/
|
|
41
|
+
export const PROACTIVE_PRESENTATION_CLASSES = [
|
|
42
|
+
"announcement",
|
|
43
|
+
"explained",
|
|
44
|
+
"briefing",
|
|
45
|
+
"notice",
|
|
46
|
+
] as const;
|
|
47
|
+
export type ProactivePresentationClass =
|
|
48
|
+
(typeof PROACTIVE_PRESENTATION_CLASSES)[number];
|
|
49
|
+
|
|
50
|
+
// =============================================================================
|
|
51
|
+
// ClassSurfacePlan
|
|
52
|
+
// =============================================================================
|
|
53
|
+
|
|
54
|
+
/** Exactly one surface owns the unread badge. Never zero, never two. */
|
|
55
|
+
export type BadgeCarrier = "banner" | "chat" | "inbox";
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* A UNION, not a flat interface, so "the badge names a surface this class does
|
|
59
|
+
* not have" is a COMPILE error rather than a runtime surprise. Each member
|
|
60
|
+
* fixes the surfaces its badge carrier presupposes: a `banner` badge fixes
|
|
61
|
+
* `banner: true`; a `chat` badge fixes `banner: false, chat: true`; an `inbox`
|
|
62
|
+
* badge fixes both false.
|
|
63
|
+
*
|
|
64
|
+
* `inbox` is literal-true in every member: EVERY proactive event writes a
|
|
65
|
+
* durable row. What varies is whether that row is born read.
|
|
66
|
+
*/
|
|
67
|
+
export type ClassSurfacePlan =
|
|
68
|
+
| {
|
|
69
|
+
readonly badge: "banner";
|
|
70
|
+
readonly banner: true;
|
|
71
|
+
readonly chat: boolean;
|
|
72
|
+
readonly inbox: true;
|
|
73
|
+
}
|
|
74
|
+
| {
|
|
75
|
+
readonly badge: "chat";
|
|
76
|
+
readonly banner: false;
|
|
77
|
+
readonly chat: true;
|
|
78
|
+
readonly inbox: true;
|
|
79
|
+
}
|
|
80
|
+
| {
|
|
81
|
+
readonly badge: "inbox";
|
|
82
|
+
readonly banner: false;
|
|
83
|
+
readonly chat: false;
|
|
84
|
+
readonly inbox: true;
|
|
85
|
+
};
|
|
86
|
+
|
|
87
|
+
// =============================================================================
|
|
88
|
+
// CLASS_SURFACES
|
|
89
|
+
// =============================================================================
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* The class table. Totality is checked in BOTH directions by
|
|
93
|
+
* `satisfies Record<ProactivePresentationClass, ClassSurfacePlan>`: a new
|
|
94
|
+
* class with no entry fails, and an entry for a non-class fails. `as const`
|
|
95
|
+
* keeps each entry's literal shape, so `CLASS_SURFACES.notice.chat` is the
|
|
96
|
+
* type `false`, not `boolean`.
|
|
97
|
+
*/
|
|
98
|
+
export const CLASS_SURFACES = {
|
|
99
|
+
announcement: { banner: true, chat: true, inbox: true, badge: "banner" },
|
|
100
|
+
explained: { banner: false, chat: true, inbox: true, badge: "chat" },
|
|
101
|
+
briefing: { banner: false, chat: true, inbox: true, badge: "chat" },
|
|
102
|
+
notice: { banner: false, chat: false, inbox: true, badge: "inbox" },
|
|
103
|
+
} as const satisfies Record<ProactivePresentationClass, ClassSurfacePlan>;
|
|
104
|
+
|
|
105
|
+
// =============================================================================
|
|
106
|
+
// bornRead
|
|
107
|
+
// =============================================================================
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* "Born read whenever a louder surface owns the badge" — stated ONCE.
|
|
111
|
+
*
|
|
112
|
+
* A backend that re-derives this per call site is how the two halves drift.
|
|
113
|
+
* The badge carrier is the attention carrier; every other surface is the paper
|
|
114
|
+
* trail (the rule generalised from the backend ADR slug
|
|
115
|
+
* `access-requested-inbox-born-read`).
|
|
116
|
+
*
|
|
117
|
+
* PURE: no clock, no env, no I/O. Only `chat` and `inbox` can be asked — the
|
|
118
|
+
* banner is ack-gated per recipient and has no read state to be born into.
|
|
119
|
+
*/
|
|
120
|
+
export function bornRead(
|
|
121
|
+
surfaces: ClassSurfacePlan,
|
|
122
|
+
surface: "chat" | "inbox",
|
|
123
|
+
): boolean {
|
|
124
|
+
return surfaces.badge !== surface;
|
|
125
|
+
}
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The pushed-chat composer port — what one chat template SAYS, given its facts
|
|
3
|
+
* (ADR-CONTRACTS-142).
|
|
4
|
+
*
|
|
5
|
+
* Copy lives here, in contracts, and not in the backend domain that writes the
|
|
6
|
+
* chat. That is the precedent notify/'s README states for outbound
|
|
7
|
+
* notifications — there is no templates/ directory in the sending domain;
|
|
8
|
+
* copy belongs to contracts' `compose` — applied to the third surface. The
|
|
9
|
+
* backend hands a composer facts and writes what comes back; it never owns a
|
|
10
|
+
* sentence.
|
|
11
|
+
*
|
|
12
|
+
* Members are ARROW PROPERTIES, never method shorthand — the contracts
|
|
13
|
+
* vocabulary guard reads a method signature as behaviour smuggled into the
|
|
14
|
+
* vocabulary. Same rule, same reason, as `NotificationDefinition.compose`.
|
|
15
|
+
*
|
|
16
|
+
* INVARIANTS — three inherited from `NotificationDefinition`, one of its own:
|
|
17
|
+
*
|
|
18
|
+
* - PURE. `(facts)` in, message out. No clock, no environment, no I/O, no
|
|
19
|
+
* ambient constant of its own. The same facts produce a byte-identical
|
|
20
|
+
* message every time, which is what lets a test prove purity.
|
|
21
|
+
* - CONTENT ONLY. No markup, no styling, no channel names, no hrefs the app
|
|
22
|
+
* owns. If a value is a formatting choice a renderer could reasonably make
|
|
23
|
+
* differently, it is the renderer's.
|
|
24
|
+
* - TOTAL REGISTRY. `PROACTIVE_CHAT_COMPOSERS` in `./templates/index` is
|
|
25
|
+
* checked in both directions over `PROACTIVE_CHAT_TEMPLATES`: a template
|
|
26
|
+
* with no composer fails to compile, and a composer for a non-template
|
|
27
|
+
* fails too.
|
|
28
|
+
* - FROZEN AT WRITE TIME (INV-PROACTIVE-CONTENT). The message is written ONCE
|
|
29
|
+
* into `chat_messages.content` and never re-renders. It may therefore only
|
|
30
|
+
* interpolate facts the recipient was entitled to at compose time. This is
|
|
31
|
+
* the deliberate OPPOSITE of the durable-inbox rule — an inbox row holds
|
|
32
|
+
* identifiers and is rendered at read time under the reader's CURRENT
|
|
33
|
+
* authority — and both are correct: a transcript that changed under the
|
|
34
|
+
* reader would be a lie about the conversation.
|
|
35
|
+
*/
|
|
36
|
+
|
|
37
|
+
import type { SuggestedReply } from "../message-parts/suggested-replies";
|
|
38
|
+
import type { ProactiveChatFacts } from "./facts";
|
|
39
|
+
import type { ProactiveChatTemplate } from "./surfaces";
|
|
40
|
+
|
|
41
|
+
// =============================================================================
|
|
42
|
+
// ProactiveChatMessage
|
|
43
|
+
// =============================================================================
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* At most this many chips under a pushed message. More than four is a menu,
|
|
47
|
+
* not a chip row.
|
|
48
|
+
*/
|
|
49
|
+
export const PROACTIVE_CHAT_MAX_REPLIES = 4;
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* What a composer returns: the chat's title, the assistant turn's prose, and
|
|
53
|
+
* the chips beneath it.
|
|
54
|
+
*
|
|
55
|
+
* `replies` reuses the `SuggestedReply` vocabulary from `../message-parts`
|
|
56
|
+
* rather than redeclaring it — the backend writes these straight into a
|
|
57
|
+
* `suggested-replies` part, and one name for one shape keeps the two halves
|
|
58
|
+
* from drifting. A chip carries a `label` and an optional `prompt` because a
|
|
59
|
+
* short chip should be able to fire a full question.
|
|
60
|
+
*/
|
|
61
|
+
export interface ProactiveChatMessage {
|
|
62
|
+
/** The chat's `title` column. */
|
|
63
|
+
readonly title: string;
|
|
64
|
+
/** The assistant turn's prose — also `chat_messages.content`. */
|
|
65
|
+
readonly text: string;
|
|
66
|
+
/** 0..`PROACTIVE_CHAT_MAX_REPLIES` chips. */
|
|
67
|
+
readonly replies: readonly SuggestedReply[];
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// =============================================================================
|
|
71
|
+
// ProactiveChatComposer
|
|
72
|
+
// =============================================================================
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* One template's composer, parameterised by the template so `compose`
|
|
76
|
+
* receives that template's facts precisely rather than a union of every
|
|
77
|
+
* template's facts.
|
|
78
|
+
*
|
|
79
|
+
* A DISTRIBUTIVE conditional type rather than a generic object type with a
|
|
80
|
+
* union default, and the difference is load-bearing: `compose` is an arrow
|
|
81
|
+
* property, so it is checked contravariantly, and a composer that takes ONE
|
|
82
|
+
* template's facts is not assignable to one that takes the union of all facts.
|
|
83
|
+
* Distributing over `T` makes the bare `ProactiveChatComposer` a union of
|
|
84
|
+
* per-template composers, which is what `satisfies Record<ProactiveChatTemplate,
|
|
85
|
+
* ProactiveChatComposer>` in `./templates/index` needs to stay total once a
|
|
86
|
+
* second template with different facts joins the registry.
|
|
87
|
+
*
|
|
88
|
+
* `ProactiveChatFacts[T]` is the structural lock: a template without facts in
|
|
89
|
+
* `./facts` cannot be given a composer.
|
|
90
|
+
*/
|
|
91
|
+
export type ProactiveChatComposer<
|
|
92
|
+
T extends ProactiveChatTemplate = ProactiveChatTemplate,
|
|
93
|
+
> = T extends unknown
|
|
94
|
+
? {
|
|
95
|
+
/** The template this composes. MUST match its registry key. */
|
|
96
|
+
readonly template: T;
|
|
97
|
+
/**
|
|
98
|
+
* Say what this pushed chat says. Pure: `(facts)` in, message out —
|
|
99
|
+
* and frozen at write time, so only facts the recipient was entitled
|
|
100
|
+
* to at compose time (INV-PROACTIVE-CONTENT).
|
|
101
|
+
*/
|
|
102
|
+
readonly compose: (facts: ProactiveChatFacts[T]) => ProactiveChatMessage;
|
|
103
|
+
}
|
|
104
|
+
: never;
|