@company-semantics/contracts 58.1.0 → 58.2.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 +21 -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/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
|
@@ -3,6 +3,7 @@ import { WireSurfaceBuilder } from "../wire.js";
|
|
|
3
3
|
import type { MCPToolDescriptor } from "../../mcp/index.js";
|
|
4
4
|
import type { ExecutionResultData } from "../execution.js";
|
|
5
5
|
import type { UndoResultData } from "../execution.js";
|
|
6
|
+
import type { SuggestedRepliesData } from "../suggested-replies.js";
|
|
6
7
|
|
|
7
8
|
// Deliberately partial fixtures: toolList is a pure wrapper that passes `tools`
|
|
8
9
|
// straight through, so these assert the wrapping, not descriptor validity. Cast
|
|
@@ -90,6 +91,53 @@ describe("WireSurfaceBuilder.undoResult", () => {
|
|
|
90
91
|
});
|
|
91
92
|
});
|
|
92
93
|
|
|
94
|
+
describe("WireSurfaceBuilder.suggestedReplies", () => {
|
|
95
|
+
const data: SuggestedRepliesData = {
|
|
96
|
+
replies: [
|
|
97
|
+
{ id: "r1", label: "Show me what moved" },
|
|
98
|
+
{
|
|
99
|
+
id: "r2",
|
|
100
|
+
label: "Why?",
|
|
101
|
+
prompt: "Why did these settings move out of My settings?",
|
|
102
|
+
},
|
|
103
|
+
],
|
|
104
|
+
};
|
|
105
|
+
|
|
106
|
+
it("returns data part with type data-suggested-replies", () => {
|
|
107
|
+
const result = WireSurfaceBuilder.suggestedReplies(data);
|
|
108
|
+
expect(result.type).toBe("data-suggested-replies");
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
it("data matches input SuggestedRepliesData exactly", () => {
|
|
112
|
+
const result = WireSurfaceBuilder.suggestedReplies(data);
|
|
113
|
+
expect(result.data).toEqual(data);
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
// A chip is an ordinary user turn: the part carries nothing that would make
|
|
117
|
+
// it a governed surface. Pinning the key set catches an accidental
|
|
118
|
+
// executionId/submitEndpoint creeping onto the payload.
|
|
119
|
+
it("carries no governed-surface fields (no executionId, no submitEndpoint)", () => {
|
|
120
|
+
const result = WireSurfaceBuilder.suggestedReplies(data);
|
|
121
|
+
expect(Object.keys(result.data)).toEqual(["replies"]);
|
|
122
|
+
expect(result.data).not.toHaveProperty("executionId");
|
|
123
|
+
expect(result.data).not.toHaveProperty("submitEndpoint");
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
// Stateless: building twice from the same facts yields identical output, so
|
|
127
|
+
// a historical message re-rendered later cannot differ from the original.
|
|
128
|
+
it("is deterministic across repeated builds", () => {
|
|
129
|
+
expect(WireSurfaceBuilder.suggestedReplies(data)).toEqual(
|
|
130
|
+
WireSurfaceBuilder.suggestedReplies(data),
|
|
131
|
+
);
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
it("works with an empty replies array", () => {
|
|
135
|
+
const result = WireSurfaceBuilder.suggestedReplies({ replies: [] });
|
|
136
|
+
expect(result.type).toBe("data-suggested-replies");
|
|
137
|
+
expect(result.data.replies).toEqual([]);
|
|
138
|
+
});
|
|
139
|
+
});
|
|
140
|
+
|
|
93
141
|
describe("WireSurfaceBuilder.messageStart", () => {
|
|
94
142
|
it("returns data part with type data-message-start", () => {
|
|
95
143
|
const result = WireSurfaceBuilder.messageStart("msg-123");
|
|
@@ -56,6 +56,14 @@ export type {
|
|
|
56
56
|
InteractiveTaskDataPart,
|
|
57
57
|
} from "./interactive";
|
|
58
58
|
|
|
59
|
+
// Suggested replies surface types (non-governed chips)
|
|
60
|
+
export type {
|
|
61
|
+
SuggestedReply,
|
|
62
|
+
SuggestedRepliesData,
|
|
63
|
+
SuggestedRepliesPart,
|
|
64
|
+
SuggestedRepliesDataPart,
|
|
65
|
+
} from "./suggested-replies";
|
|
66
|
+
|
|
59
67
|
// Execution result types
|
|
60
68
|
export type {
|
|
61
69
|
ExecutionArtifactStatus,
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Suggested follow-up chips beneath an assistant turn.
|
|
3
|
+
*
|
|
4
|
+
* INVARIANTS — these are what keep this OUT of the governed
|
|
5
|
+
* preview/confirmation/interactive triad:
|
|
6
|
+
*
|
|
7
|
+
* - A chip fires an ORDINARY USER TURN. No `executionId`, no `submitEndpoint`,
|
|
8
|
+
* no side effect of its own. That is precisely why it is not a governed
|
|
9
|
+
* surface and is not subject to the at-most-one-governed-surface-per-turn
|
|
10
|
+
* rule.
|
|
11
|
+
* - STATELESS. Clicking does not consume it; the historical message renders
|
|
12
|
+
* identically forever, because the turn it produced is already in the
|
|
13
|
+
* transcript below it.
|
|
14
|
+
* - ADVISORY and non-exhaustive. The composer box stays enabled.
|
|
15
|
+
*
|
|
16
|
+
* Purely additive: an app that predates this part normalizes unknown part
|
|
17
|
+
* types to null, so it drops the chips and still renders the prose.
|
|
18
|
+
*
|
|
19
|
+
* @see decisions/ADR-CONT-026.md for the message-parts design
|
|
20
|
+
* @see decisions/ADR-CONTRACTS-142-proactive-vocabulary-composes-the-three-notification-unions.md
|
|
21
|
+
*/
|
|
22
|
+
export interface SuggestedReply {
|
|
23
|
+
/** Stable within the part. Telemetry key and React key; never a server id. */
|
|
24
|
+
readonly id: string;
|
|
25
|
+
/** What the chip SAYS. */
|
|
26
|
+
readonly label: string;
|
|
27
|
+
/** What the chip SENDS, when it differs from the label. */
|
|
28
|
+
readonly prompt?: string;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Suggested replies surface data payload.
|
|
33
|
+
*/
|
|
34
|
+
export interface SuggestedRepliesData {
|
|
35
|
+
readonly replies: readonly SuggestedReply[];
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** Normalized form, as rendered. */
|
|
39
|
+
export interface SuggestedRepliesPart {
|
|
40
|
+
type: "suggested-replies";
|
|
41
|
+
data: SuggestedRepliesData;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** Wire form, as persisted in `chat_messages.parts`. */
|
|
45
|
+
export interface SuggestedRepliesDataPart {
|
|
46
|
+
type: "data-suggested-replies";
|
|
47
|
+
data: SuggestedRepliesData;
|
|
48
|
+
}
|
|
@@ -17,6 +17,7 @@ import type { ToolListMessagePart } from "../mcp/index";
|
|
|
17
17
|
import type { PreviewPart } from "./preview";
|
|
18
18
|
import type { ConfirmationPart } from "./confirmation";
|
|
19
19
|
import type { InteractiveTaskPart } from "./interactive";
|
|
20
|
+
import type { SuggestedRepliesPart } from "./suggested-replies";
|
|
20
21
|
|
|
21
22
|
// =============================================================================
|
|
22
23
|
// Narrative Parts (Streamable)
|
|
@@ -112,6 +113,10 @@ export interface TablePart {
|
|
|
112
113
|
/**
|
|
113
114
|
* Surface parts are rendered atomically (never streamed).
|
|
114
115
|
* Extensible: add new surface types to this union.
|
|
116
|
+
*
|
|
117
|
+
* SuggestedRepliesPart is a surface part but NOT a governed one: a chip fires
|
|
118
|
+
* an ordinary user turn, so it is outside the preview/confirmation/interactive
|
|
119
|
+
* triad and its at-most-one-governed-surface-per-turn rule.
|
|
115
120
|
*/
|
|
116
121
|
export type SurfacePart =
|
|
117
122
|
| ToolListPart
|
|
@@ -120,7 +125,8 @@ export type SurfacePart =
|
|
|
120
125
|
| TablePart
|
|
121
126
|
| ConfirmationPart
|
|
122
127
|
| PreviewPart
|
|
123
|
-
| InteractiveTaskPart
|
|
128
|
+
| InteractiveTaskPart
|
|
129
|
+
| SuggestedRepliesPart;
|
|
124
130
|
|
|
125
131
|
/**
|
|
126
132
|
* All assistant message part types.
|
|
@@ -17,6 +17,10 @@ import type {
|
|
|
17
17
|
InteractiveTaskData,
|
|
18
18
|
InteractiveTaskDataPart,
|
|
19
19
|
} from "./interactive";
|
|
20
|
+
import type {
|
|
21
|
+
SuggestedRepliesData,
|
|
22
|
+
SuggestedRepliesDataPart,
|
|
23
|
+
} from "./suggested-replies";
|
|
20
24
|
import type {
|
|
21
25
|
ExecutionResultData,
|
|
22
26
|
ExecutionResultDataPart,
|
|
@@ -112,6 +116,28 @@ export const WireSurfaceBuilder = {
|
|
|
112
116
|
};
|
|
113
117
|
},
|
|
114
118
|
|
|
119
|
+
/**
|
|
120
|
+
* Build a suggested-replies data part for streaming.
|
|
121
|
+
* Chips beneath an assistant turn that each fire an ORDINARY user turn.
|
|
122
|
+
*
|
|
123
|
+
* INVARIANTS:
|
|
124
|
+
* - Not a governed surface: no executionId, no submitEndpoint, no side
|
|
125
|
+
* effect of its own — so it does not count against the
|
|
126
|
+
* at-most-one-governed-surface-per-turn rule
|
|
127
|
+
* - Stateless: clicking does not consume it; the historical message renders
|
|
128
|
+
* identically forever
|
|
129
|
+
* - Advisory and non-exhaustive: the composer box stays enabled
|
|
130
|
+
*
|
|
131
|
+
* @param data - Suggested replies data (the chip list)
|
|
132
|
+
* @returns Wire-format suggested replies part ready for stream
|
|
133
|
+
*/
|
|
134
|
+
suggestedReplies(data: SuggestedRepliesData): SuggestedRepliesDataPart {
|
|
135
|
+
return {
|
|
136
|
+
type: "data-suggested-replies",
|
|
137
|
+
data,
|
|
138
|
+
};
|
|
139
|
+
},
|
|
140
|
+
|
|
115
141
|
/**
|
|
116
142
|
* Build an execution result data part for streaming.
|
|
117
143
|
* Reports the outcome of an executed action.
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
# proactive/
|
|
2
|
+
|
|
3
|
+
## Purpose
|
|
4
|
+
|
|
5
|
+
The vocabulary of **proactive delivery** — how the system speaks first. It
|
|
6
|
+
encodes the doctrine in the control ADR with slug
|
|
7
|
+
`proactive-delivery-occurrence-audience-projection` (ADR-CTRL-408) as a
|
|
8
|
+
compile-checked vocabulary: a **presentation class** is a TOTAL FUNCTION from
|
|
9
|
+
class to surfaces, and an **event kind** carries only the IDS each surface
|
|
10
|
+
needs. See ADR-CONTRACTS-142.
|
|
11
|
+
|
|
12
|
+
This vocabulary COMPOSES the three notification vocabularies beside it — it
|
|
13
|
+
adds no member to `NotificationKind` or `ActionItemKind`, and it derives none of
|
|
14
|
+
the three from another.
|
|
15
|
+
|
|
16
|
+
## Invariants
|
|
17
|
+
|
|
18
|
+
- **`CLASS_SURFACES` is total in both directions.** Checked by
|
|
19
|
+
`as const satisfies Record<ProactivePresentationClass, ClassSurfacePlan>`: a
|
|
20
|
+
class with no entry fails to compile, and an entry for a non-class fails too.
|
|
21
|
+
- **Exactly one badge carrier per class.** `ClassSurfacePlan` is a UNION, not a
|
|
22
|
+
flat interface, so a badge naming a surface the class does not have is a
|
|
23
|
+
compile error.
|
|
24
|
+
- **Every class writes a durable inbox row.** `inbox` is literal `true` in every
|
|
25
|
+
union member. What varies is whether the row is born read.
|
|
26
|
+
- **The born-read rule is stated once.** `bornRead(surfaces, surface)` is the
|
|
27
|
+
only place "born read whenever a louder surface owns the badge" lives. A
|
|
28
|
+
backend that re-derives it per call site is how the two halves drift.
|
|
29
|
+
- **Loudness, not severity.** `error|warning|info` exists in this package scoped
|
|
30
|
+
to org-transformation findings and is not overloaded here.
|
|
31
|
+
- **Kinds carry ids, never surface booleans.** A kind names its `class` and the
|
|
32
|
+
identifiers each surface needs; the surfaces themselves are derived from the
|
|
33
|
+
class table. Restating them would leave the compiler having to prove two
|
|
34
|
+
declarations agree.
|
|
35
|
+
- **`PROACTIVE_EVENT_KINDS` is total in both directions.** Same
|
|
36
|
+
`as const satisfies Record<ProactiveEventKind, ProactiveEventDefinition>`
|
|
37
|
+
idiom: a kind with no plan fails to compile, and a plan for a non-kind fails.
|
|
38
|
+
- **The plan union types the ids a class lacks as `never`.** An `announcement`
|
|
39
|
+
MUST name a `bannerType` and a `chatTemplate`; `explained` and `briefing`
|
|
40
|
+
cannot name a banner; a `notice` can name neither. Every member requires an
|
|
41
|
+
`inboxKind`. The one consistency the compiler cannot express — a plan's ids
|
|
42
|
+
agreeing with `CLASS_SURFACES[plan.class]` — is pinned by the vocabulary
|
|
43
|
+
test.
|
|
44
|
+
- **Email and action items are orthogonal axes.** `emailKind` is off-platform
|
|
45
|
+
delivery governed by notify/'s policy; `actionItemKind` is a work-state
|
|
46
|
+
projection, not a loudness tier. No kind sets `emailKind` in this wave.
|
|
47
|
+
- **The kind string and the inbox kind string are distinct.**
|
|
48
|
+
`org.became_shared` names its row `proactive.org_became_shared` and
|
|
49
|
+
`brief.morning` names its row `proactive.brief_morning`; identical names
|
|
50
|
+
across two unions is how one quietly becomes derived from the other.
|
|
51
|
+
- **A `briefing` needs no new machinery.** `brief.morning` is the proof: its
|
|
52
|
+
class already types `bannerType` as `never`, so it cannot name a banner, and
|
|
53
|
+
the plan/surface test covers it for free. ONE occurrence per org per
|
|
54
|
+
org-local day, with N recipients — personalisation lives in the per-recipient
|
|
55
|
+
`MorningBriefFacts`, never in occurrence identity, or the system would have
|
|
56
|
+
one event row per person per day and two shapes of occurrence to reason
|
|
57
|
+
about.
|
|
58
|
+
- **`ORG_SYSTEM_EVENT_TYPES` mirrors the backend pgEnum value for value.**
|
|
59
|
+
`org.became_shared` names the EXISTING `first_member_joined` banner rather
|
|
60
|
+
than minting a third value — it fires at exactly the personal-to-shared flip.
|
|
61
|
+
- **The occurrence vocabulary is closed.** `ProactiveEventId` is branded so a
|
|
62
|
+
raw string cannot stand in for an occurrence; `ProactiveRecipientReason` is a
|
|
63
|
+
closed enum because the recipients table is the frozen audit record of who
|
|
64
|
+
was addressed and why; `ProactiveProjection` is deliberately broader than the
|
|
65
|
+
three surfaces because an occurrence has zero or more PROJECTIONS.
|
|
66
|
+
- **Pushed-chat copy lives here, not in the backend.** The precedent notify/'s
|
|
67
|
+
README states for outbound notifications — copy belongs to contracts'
|
|
68
|
+
`compose` — applied to the third surface. The backend hands a composer facts
|
|
69
|
+
and writes what comes back; it never owns a sentence.
|
|
70
|
+
- **The composer is pure and content-only.** `(facts)` in, message out; no
|
|
71
|
+
clock, no env, no I/O; no markup, no channel names, no hrefs the app owns.
|
|
72
|
+
Members are arrow properties, never method shorthand, because the vocabulary
|
|
73
|
+
guard reads a method signature as behaviour.
|
|
74
|
+
- **`PROACTIVE_CHAT_COMPOSERS` is total in both directions.** Same
|
|
75
|
+
`as const satisfies Record<ProactiveChatTemplate, ProactiveChatComposer>`
|
|
76
|
+
idiom: a template with no composer fails to compile. `ProactiveChatComposer`
|
|
77
|
+
is a DISTRIBUTIVE type so the bare name is a union of per-template composers
|
|
78
|
+
— an arrow `compose` is checked contravariantly, and a composer taking one
|
|
79
|
+
template's facts is not assignable to one taking the union of all facts.
|
|
80
|
+
- **The pushed message is FROZEN AT WRITE TIME (INV-PROACTIVE-CONTENT).** It
|
|
81
|
+
is written once into `chat_messages.content` and never re-renders, so a
|
|
82
|
+
composer may only interpolate facts the recipient was entitled to at compose
|
|
83
|
+
time. The deliberate opposite of the durable-inbox rule, and both are
|
|
84
|
+
correct: a transcript that changed under the reader would be a lie about the
|
|
85
|
+
conversation.
|
|
86
|
+
|
|
87
|
+
## Public API
|
|
88
|
+
|
|
89
|
+
| Export | Description |
|
|
90
|
+
| -------------------------------- | ----------------------------------------------------------------------------- |
|
|
91
|
+
| `PROACTIVE_PRESENTATION_CLASSES` | The closed class vocabulary (announcement, explained, briefing, notice) |
|
|
92
|
+
| `ProactivePresentationClass` | Union derived from the array |
|
|
93
|
+
| `BadgeCarrier` | `banner` \| `chat` \| `inbox` — exactly one per class |
|
|
94
|
+
| `ClassSurfacePlan` | Union of the surface sets a badge carrier presupposes |
|
|
95
|
+
| `CLASS_SURFACES` | The total class → surfaces table |
|
|
96
|
+
| `bornRead` | Whether a class's `chat` or `inbox` row is born read |
|
|
97
|
+
| `ORG_SYSTEM_EVENT_TYPES` | Mirror of the backend `org_system_event_type` pgEnum — the banner ids |
|
|
98
|
+
| `OrgSystemEventType` | Union derived from the array |
|
|
99
|
+
| `PROACTIVE_CHAT_TEMPLATES` | Which prose composes a kind's pushed chat (`orgBecameShared`, `morningBrief`) |
|
|
100
|
+
| `ProactiveChatTemplate` | Union derived from the array |
|
|
101
|
+
| `PROACTIVE_EVENT_KIND_IDS` | The closed kind vocabulary (`org.became_shared`, `brief.morning`) |
|
|
102
|
+
| `ProactiveEventKind` | Union derived from the array |
|
|
103
|
+
| `ProactiveEventId` | Branded id of ONE occurrence — a raw string cannot be passed as one |
|
|
104
|
+
| `PROACTIVE_RECIPIENT_REASONS` | Closed enum of WHY a recipient was addressed (the frozen audit record) |
|
|
105
|
+
| `ProactiveRecipientReason` | Union derived from the array |
|
|
106
|
+
| `PROACTIVE_PROJECTIONS` | What was materialized for one recipient — broader than the three surfaces |
|
|
107
|
+
| `ProactiveProjection` | Union derived from the array |
|
|
108
|
+
| `ProactiveEventDefinition` | A kind's plan: discriminated union on `class`, ids the class lacks as `never` |
|
|
109
|
+
| `PROACTIVE_EVENT_KINDS` | The total kind → plan registry |
|
|
110
|
+
| `ProactiveChatFacts` | The facts each template composes from, keyed by template |
|
|
111
|
+
| `OrgBecameSharedFacts` | Facts for the personal-to-shared flip: `orgName`, `joinerDisplayName` |
|
|
112
|
+
| `MorningBriefFacts` | Per-recipient facts for the morning brief — where personalisation lives |
|
|
113
|
+
| `ProactiveChatMessage` | What a composer returns: `title`, `text`, and 0..4 `replies` chips |
|
|
114
|
+
| `ProactiveChatComposer` | The composer port — `template` plus an arrow-property `compose(facts)` |
|
|
115
|
+
| `PROACTIVE_CHAT_MAX_REPLIES` | At most this many chips (4); more is a menu, not a chip row |
|
|
116
|
+
| `PROACTIVE_CHAT_COMPOSERS` | The total template → composer registry (see `templates/README.md`) |
|
|
117
|
+
|
|
118
|
+
## Dependencies
|
|
119
|
+
|
|
120
|
+
Type-only imports of `UserNotificationKind` (`../user-notifications`),
|
|
121
|
+
`NotificationKind` (`../notifications`) and `ActionItemKind`
|
|
122
|
+
(`../action-items`) — the three vocabularies this one composes — and of
|
|
123
|
+
`SuggestedReply` (`../message-parts`), the chip shape a composer's `replies`
|
|
124
|
+
reuse rather than redeclare. No `zod`, no runtime imports; pure vocabulary
|
|
125
|
+
data and pure helpers.
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
# proactive/\_\_tests\_\_/
|
|
2
|
+
|
|
3
|
+
## Purpose
|
|
4
|
+
|
|
5
|
+
Locks the claims `../README.md` and ADR-CONTRACTS-142 make that the compiler
|
|
6
|
+
cannot.
|
|
7
|
+
|
|
8
|
+
- `vocabulary.test.ts` — the load-bearing one is
|
|
9
|
+
`plan surfaces agree with the class table`, the ONE consistency the type
|
|
10
|
+
system cannot express (the ids PRESENT on a kind's plan agree with the
|
|
11
|
+
surfaces `CLASS_SURFACES[plan.class]` DECLARES, and `inboxKind` is always
|
|
12
|
+
there). Also: `CLASS_SURFACES` is total
|
|
13
|
+
over `PROACTIVE_PRESENTATION_CLASSES` and every badge names a surface its
|
|
14
|
+
class has; `bornRead` matches the doctrine table row for row;
|
|
15
|
+
`PROACTIVE_EVENT_KINDS` is total over `PROACTIVE_EVENT_KIND_IDS`; every
|
|
16
|
+
`inboxKind` is a real `USER_NOTIFICATION_KINDS` member in the `proactive.`
|
|
17
|
+
domain and never the kind string itself; every `chatTemplate` a kind names
|
|
18
|
+
reaches a composer; no kind sets `emailKind`; and composers are pure (same
|
|
19
|
+
facts, byte-identical output across two calls).
|
|
20
|
+
- `compile-fixtures.ts` — the negative typecheck fixtures. A COMPILED module,
|
|
21
|
+
not a `*.test.ts`: `src/tsconfig.json` excludes test files and vitest does
|
|
22
|
+
not typecheck, so a `@ts-expect-error` written in a test would never be
|
|
23
|
+
checked. Here `tsc -b` checks each directive, and an unused one (a claim that
|
|
24
|
+
stopped being true) fails `pnpm typecheck` with TS2578. The four claims: a
|
|
25
|
+
`notice` naming a `bannerType`, an `announcement` omitting one, an
|
|
26
|
+
`explained` (and, by the same union member, a `briefing`) naming one, and a
|
|
27
|
+
registry that is not total (a kind with no plan, and a plan for a non-kind). `vocabulary.test.ts` imports the fixtures
|
|
28
|
+
and shows the runtime class-table check rejects the same plans.
|
|
29
|
+
- `chat-templates.test.ts` — the pushed-chat composer registry in practice:
|
|
30
|
+
every template in `PROACTIVE_CHAT_TEMPLATES` has a composer whose `template`
|
|
31
|
+
equals its key (the type proves totality; this proves no entry is an
|
|
32
|
+
accidental `undefined`), the org-became-shared prose NAMES THE RELOCATION and
|
|
33
|
+
every one of the five sections that leave My settings for Org settings, the
|
|
34
|
+
morning brief PERSONALISES THROUGH ITS FACTS (two recipients of one
|
|
35
|
+
occurrence get different prose from one composer), chips stay within
|
|
36
|
+
`PROACTIVE_CHAT_MAX_REPLIES`, and composers emit content — no markup, no
|
|
37
|
+
hrefs.
|
|
38
|
+
|
|
39
|
+
## Invariants
|
|
40
|
+
|
|
41
|
+
- These assert VOCABULARY and SHAPE, never behaviour. Anything needing a
|
|
42
|
+
database, a clock or an authority gate belongs in backend's
|
|
43
|
+
`src/proactive/__tests__/`.
|
|
44
|
+
- Tests iterate the REAL exported arrays and registries rather than restating
|
|
45
|
+
them. A hand-copied list drifts and starts passing vacuously.
|
|
46
|
+
- A composer is exercised through the registry (`PROACTIVE_CHAT_COMPOSERS`),
|
|
47
|
+
not by importing its module directly, so a template that is written but not
|
|
48
|
+
registered fails here rather than in the backend.
|
|
49
|
+
|
|
50
|
+
## Public API
|
|
51
|
+
|
|
52
|
+
None — test-only.
|
|
53
|
+
|
|
54
|
+
## Dependencies
|
|
55
|
+
|
|
56
|
+
`vitest` and the sibling modules under test.
|
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
import { describe, it, expect } from "vitest";
|
|
2
|
+
import { PROACTIVE_CHAT_MAX_REPLIES } from "../composer";
|
|
3
|
+
import type { ProactiveChatMessage } from "../composer";
|
|
4
|
+
import type { ProactiveChatFacts } from "../facts";
|
|
5
|
+
import { PROACTIVE_CHAT_TEMPLATES } from "../surfaces";
|
|
6
|
+
import type { ProactiveChatTemplate } from "../surfaces";
|
|
7
|
+
import { PROACTIVE_CHAT_COMPOSERS } from "../templates/index";
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* One representative facts fixture per template. Total over
|
|
11
|
+
* `ProactiveChatTemplate`, so a template added without a fixture fails to
|
|
12
|
+
* compile here — the registry-wide tests below then cover it for free.
|
|
13
|
+
*/
|
|
14
|
+
const FACTS: { readonly [T in ProactiveChatTemplate]: ProactiveChatFacts[T] } =
|
|
15
|
+
{
|
|
16
|
+
orgBecameShared: { orgName: "Acme", joinerDisplayName: "Ada" },
|
|
17
|
+
morningBrief: {
|
|
18
|
+
recipientDisplayName: "Ada",
|
|
19
|
+
orgName: "Acme",
|
|
20
|
+
orgLocalDateLabel: "Tuesday 25 August",
|
|
21
|
+
pendingActionItemCount: 2,
|
|
22
|
+
unreadNotificationCount: 5,
|
|
23
|
+
},
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Compose a template with its fixture. The registry is a union of composers
|
|
28
|
+
* whose `compose` parameters differ per template, and TypeScript cannot
|
|
29
|
+
* correlate `PROACTIVE_CHAT_COMPOSERS[t]` with `FACTS[t]` through a variable
|
|
30
|
+
* `t` — the cast is the one place the correlation is asserted, and the
|
|
31
|
+
* fixture record above is what keeps it honest.
|
|
32
|
+
*/
|
|
33
|
+
function composeFixture(template: ProactiveChatTemplate): ProactiveChatMessage {
|
|
34
|
+
const compose = PROACTIVE_CHAT_COMPOSERS[template].compose as (
|
|
35
|
+
facts: ProactiveChatFacts[ProactiveChatTemplate],
|
|
36
|
+
) => ProactiveChatMessage;
|
|
37
|
+
return compose(FACTS[template]);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* The pushed-chat templates: what the composer registry promises in practice
|
|
42
|
+
* and not just in type, and the one editorial claim the org-became-shared
|
|
43
|
+
* prose exists to make.
|
|
44
|
+
*/
|
|
45
|
+
describe("proactive chat templates", () => {
|
|
46
|
+
it("registry is total over PROACTIVE_CHAT_TEMPLATES in practice", () => {
|
|
47
|
+
// The type check proves totality at compile time; this proves no entry is
|
|
48
|
+
// an accidental `undefined` and that every registered composer names the
|
|
49
|
+
// key it sits under — the runtime half of "kind matches registry key".
|
|
50
|
+
for (const template of PROACTIVE_CHAT_TEMPLATES) {
|
|
51
|
+
const composer = PROACTIVE_CHAT_COMPOSERS[template];
|
|
52
|
+
expect(composer, template).toBeDefined();
|
|
53
|
+
expect(composer.template).toBe(template);
|
|
54
|
+
}
|
|
55
|
+
expect(Object.keys(PROACTIVE_CHAT_COMPOSERS).sort()).toEqual(
|
|
56
|
+
[...PROACTIVE_CHAT_TEMPLATES].sort(),
|
|
57
|
+
);
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
it("org became shared template names the relocation", () => {
|
|
61
|
+
const msg = PROACTIVE_CHAT_COMPOSERS.orgBecameShared.compose({
|
|
62
|
+
orgName: "Acme",
|
|
63
|
+
joinerDisplayName: "Ada",
|
|
64
|
+
});
|
|
65
|
+
expect(msg.text).toContain("Org settings");
|
|
66
|
+
expect(msg.text).toContain("relocation");
|
|
67
|
+
expect(msg.replies.length).toBeLessThanOrEqual(PROACTIVE_CHAT_MAX_REPLIES);
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
it("org became shared prose names every relocated section", () => {
|
|
71
|
+
// The five sections the app ADR `nav-posture-opens-on-invite-send` lists
|
|
72
|
+
// as leaving My settings. Naming some and not others is the "things going
|
|
73
|
+
// missing" reading this chat exists to prevent.
|
|
74
|
+
const msg = PROACTIVE_CHAT_COMPOSERS.orgBecameShared.compose({
|
|
75
|
+
orgName: "Acme",
|
|
76
|
+
joinerDisplayName: "Ada",
|
|
77
|
+
});
|
|
78
|
+
for (const section of [
|
|
79
|
+
"Integrations",
|
|
80
|
+
"Invite team members",
|
|
81
|
+
"AI Usage",
|
|
82
|
+
"Budget",
|
|
83
|
+
"Billing",
|
|
84
|
+
"My settings",
|
|
85
|
+
]) {
|
|
86
|
+
expect(msg.text, section).toContain(section);
|
|
87
|
+
}
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
it("org became shared interpolates only the facts it was handed", () => {
|
|
91
|
+
const msg = PROACTIVE_CHAT_COMPOSERS.orgBecameShared.compose({
|
|
92
|
+
orgName: "Acme",
|
|
93
|
+
joinerDisplayName: "Ada",
|
|
94
|
+
});
|
|
95
|
+
expect(msg.title).toBe("Your workspace is now shared");
|
|
96
|
+
expect(msg.text).toContain("Ada accepted your invite");
|
|
97
|
+
expect(msg.text).toContain("Acme");
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
it("chips carry a label and, when they differ, a full prompt", () => {
|
|
101
|
+
const msg = PROACTIVE_CHAT_COMPOSERS.orgBecameShared.compose({
|
|
102
|
+
orgName: "Acme",
|
|
103
|
+
joinerDisplayName: "Ada",
|
|
104
|
+
});
|
|
105
|
+
const ids = msg.replies.map((reply) => reply.id);
|
|
106
|
+
expect(new Set(ids).size).toBe(ids.length);
|
|
107
|
+
for (const reply of msg.replies) {
|
|
108
|
+
expect(reply.label.length).toBeGreaterThan(0);
|
|
109
|
+
if (reply.prompt !== undefined) {
|
|
110
|
+
expect(reply.prompt).not.toBe(reply.label);
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
it("morning brief personalises through the facts, not the occurrence", () => {
|
|
116
|
+
// Two people in one org on one morning share an occurrence and receive
|
|
117
|
+
// different prose because they were handed different facts. The composer
|
|
118
|
+
// knows nothing about who it is composing for — the difference is entirely
|
|
119
|
+
// in what it was given.
|
|
120
|
+
const shared = { orgName: "Acme", orgLocalDateLabel: "Tuesday 25 August" };
|
|
121
|
+
const ada = PROACTIVE_CHAT_COMPOSERS.morningBrief.compose({
|
|
122
|
+
...shared,
|
|
123
|
+
recipientDisplayName: "Ada",
|
|
124
|
+
pendingActionItemCount: 1,
|
|
125
|
+
unreadNotificationCount: 3,
|
|
126
|
+
});
|
|
127
|
+
const grace = PROACTIVE_CHAT_COMPOSERS.morningBrief.compose({
|
|
128
|
+
...shared,
|
|
129
|
+
recipientDisplayName: "Grace",
|
|
130
|
+
pendingActionItemCount: 0,
|
|
131
|
+
unreadNotificationCount: 0,
|
|
132
|
+
});
|
|
133
|
+
expect(ada.title).toBe(grace.title);
|
|
134
|
+
expect(ada.text).toContain("Good morning, Ada.");
|
|
135
|
+
expect(ada.text).toContain("1 decision waiting on you");
|
|
136
|
+
expect(ada.text).toContain("3 unread notifications");
|
|
137
|
+
expect(grace.text).toContain("Good morning, Grace.");
|
|
138
|
+
expect(grace.text).toContain("Nothing is waiting on you");
|
|
139
|
+
expect(grace.text).not.toContain("0 ");
|
|
140
|
+
expect(ada.text).not.toBe(grace.text);
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
it("composers emit content, never markup", () => {
|
|
144
|
+
for (const template of PROACTIVE_CHAT_TEMPLATES) {
|
|
145
|
+
const msg = composeFixture(template);
|
|
146
|
+
for (const value of [msg.title, msg.text]) {
|
|
147
|
+
expect(value, template).not.toMatch(/<[a-z!/]/i);
|
|
148
|
+
expect(value, template).not.toMatch(/https?:\/\//);
|
|
149
|
+
}
|
|
150
|
+
for (const reply of msg.replies) {
|
|
151
|
+
expect(reply.label, template).not.toMatch(/<[a-z!/]/i);
|
|
152
|
+
expect(reply.prompt ?? "", template).not.toMatch(/https?:\/\//);
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
it("every composer stays within the chip budget", () => {
|
|
158
|
+
for (const template of PROACTIVE_CHAT_TEMPLATES) {
|
|
159
|
+
const msg = composeFixture(template);
|
|
160
|
+
expect(msg.replies.length, template).toBeLessThanOrEqual(
|
|
161
|
+
PROACTIVE_CHAT_MAX_REPLIES,
|
|
162
|
+
);
|
|
163
|
+
expect(msg.title.length, template).toBeGreaterThan(0);
|
|
164
|
+
expect(msg.text.length, template).toBeGreaterThan(0);
|
|
165
|
+
}
|
|
166
|
+
});
|
|
167
|
+
});
|
|
@@ -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;
|