@company-semantics/contracts 93.3.0 → 95.0.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.
@@ -37,8 +37,13 @@ import type {
37
37
  ModelAllowlistSchema,
38
38
  OrgBillingOverviewSchema,
39
39
  PortalSessionResponseSchema,
40
+ PriceBookVersionListSchema,
41
+ PriceBookVersionSummarySchema,
40
42
  PriceListModelSchema,
43
+ PriceListQuerySchema,
44
+ PriceListRatesSchema,
41
45
  PriceListSchema,
46
+ PriceListTierSchema,
42
47
  ProviderKeyListSchema,
43
48
  ProviderKeyStatusSchema,
44
49
  SpendingControlsSchema,
@@ -151,11 +156,28 @@ export type ProviderKeyStatus = z.infer<typeof ProviderKeyStatusSchema>;
151
156
  /** Every provider the org could configure a key for. */
152
157
  export type ProviderKeyList = z.infer<typeof ProviderKeyListSchema>;
153
158
 
154
- /** One priced model. Every rate is micro-USD per million tokens. */
159
+ /** One rate set, micro-USD per million tokens; null = no separate rate. */
160
+ export type PriceListRates = z.infer<typeof PriceListRatesSchema>;
161
+
162
+ /** One tier of one processing mode: reference and hosted rates. */
163
+ export type PriceListTier = z.infer<typeof PriceListTierSchema>;
164
+
165
+ /** One priced model's card: provenance, aliases and tiers per mode. */
155
166
  export type PriceListModel = z.infer<typeof PriceListModelSchema>;
156
167
 
157
168
  /** The published price book, versioned. */
158
169
  export type PriceList = z.infer<typeof PriceListSchema>;
159
170
 
171
+ /** The price-list query: an optional version label, else the active one. */
172
+ export type PriceListQuery = z.infer<typeof PriceListQuerySchema>;
173
+
174
+ /** One published price-book version. */
175
+ export type PriceBookVersionSummary = z.infer<
176
+ typeof PriceBookVersionSummarySchema
177
+ >;
178
+
179
+ /** The price book's version history, newest first. */
180
+ export type PriceBookVersionList = z.infer<typeof PriceBookVersionListSchema>;
181
+
160
182
  /** A house-funded org's grant. */
161
183
  export type HouseFunding = z.infer<typeof HouseFundingSchema>;
@@ -332,6 +332,43 @@ export const BILLING_PROVIDERS = [
332
332
  export type BillingProvider = (typeof BILLING_PROVIDERS)[number];
333
333
  export const BillingProviderSchema = z.enum(BILLING_PROVIDERS);
334
334
 
335
+ /**
336
+ * How a provider processes a request, which selects the rate set it is billed
337
+ * at. Each mode is an EXPLICIT rate set published by the provider, never a
338
+ * multiplier we derive: OpenAI's gpt-4o "Fast" is 1.7x Standard, gpt-6-luna's is
339
+ * 2x, and Anthropic's Batch is 0.5x. "fast" is what OpenAI called "priority"
340
+ * until 2026-07-30 and what Anthropic calls fast mode.
341
+ */
342
+ export const PROCESSING_MODES = [
343
+ /** The provider's default processing. Every priced model has it. */
344
+ "standard",
345
+ /** Asynchronous batch processing. */
346
+ "batch",
347
+ /** OpenAI Flex: lower price, slower and best-effort capacity. */
348
+ "flex",
349
+ /** Premium latency (OpenAI Fast, formerly Priority; Anthropic fast mode). */
350
+ "fast",
351
+ /** OpenAI Ultrafast. */
352
+ "ultrafast",
353
+ ] as const;
354
+ export type ProcessingMode = (typeof PROCESSING_MODES)[number];
355
+ export const ProcessingModeSchema = z.enum(PROCESSING_MODES);
356
+
357
+ /**
358
+ * What a provider counts to decide which prompt-length tier a request pays.
359
+ * Anthropic (Claude Haiku 5.5, >100K): every input token, INCLUDING cache reads
360
+ * and cache writes. A model with more than one tier must name its measure; an
361
+ * ambiguous provider rule is never guessed (the long tier stays unpublished).
362
+ */
363
+ export const PROMPT_TIER_MEASURES = [
364
+ /** input + cache read + cache write. */
365
+ "input_incl_cache",
366
+ /** Fresh (uncached) input only. */
367
+ "input_excl_cache",
368
+ ] as const;
369
+ export type PromptTierMeasure = (typeof PROMPT_TIER_MEASURES)[number];
370
+ export const PromptTierMeasureSchema = z.enum(PROMPT_TIER_MEASURES);
371
+
335
372
  /* -------------------------------------------------------------------------- */
336
373
  /* Derived tables */
337
374
  /* -------------------------------------------------------------------------- */
@@ -55,6 +55,20 @@ A chat can belong to a SUBJECT — the durable case a hand-off to chat is about
55
55
  - A launch posted into a chat that is not — or can no longer become — the subject's active generation is a 409 whose body is `LaunchTargetSuperseded` (`code: "launch_target_superseded"`); its `activeChatId` names where to go, and null means start a new chat
56
56
  - Request bodies for resolve-launch and start-fresh are deliberately NOT here: the resolve body embeds the backend-local launch schema, and start-fresh has no body
57
57
 
58
+ ## Turn on the wire
59
+
60
+ One turn is one PRESENTATION GROUP whose action row appears once, when the turn settles (control doctrine `chat-action-system`; ADR slug `chat-turn-identity-and-message-actions`).
61
+
62
+ - `ChatMessage.presentationGroupId`, `turnKind` and `myFeedback` are REQUIRED: null for no group, a kind for every row, null for no rating. They shipped in a MAJOR so no consumer holds a second opinion about a missing key
63
+ - `TURN_KINDS` (`turn-kind.ts`) is CLOSED and append-only. The server derives the kind from what persisted the row; a client never infers it from content. A turn kind is not a principal kind and never changes `author`
64
+ - `myFeedback` is the READER's own rating, per-reader like `myRole`
65
+ - `ChatStreamMessageMetadataSchema` (`stream-metadata.ts`) rides the AI SDK `start` chunk. The live message adopts `messageId`, so a streaming turn already IS the persisted row, already grouped; every field means what the same-named `ChatMessage` field means
66
+ - `messageActionsFor` (`message-actions.ts`) is the ONE eligibility function: the app renders the action row from it and the backend enforces the same function (`isMessageActionAllowed`) on every regenerate, edit and feedback request. Hiding a button is never the boundary
67
+ - `messageActionsFor` is PURE and TOTAL. An unsettled turn offers nothing; input that does not parse offers nothing — unknown is never permissive. Regenerate is offered only on the latest settled `conversation` answer, edit only on the reader's own latest `conversation` prompt
68
+ - Its per-kind table is `satisfies Record<TurnKind, …>`, so a new kind fails to compile until its eligibility is decided
69
+ - Execution actions (cancel, continue, retry, run again) are NOT message actions and are not here — they reach a person as affordances
70
+ - Load order: `turn-kind.ts` is a LEAF (zod only). `schemas.ts`, `stream-metadata.ts` and `message-actions.ts` import it; the latter two never import `schemas.ts`, and `index.ts` re-exports all three directly
71
+
58
72
  <!-- BEGIN GENERATED: readme-public-api — derived from code by `pnpm readme-api`. Do not edit. -->
59
73
 
60
74
  ## Public API
@@ -114,6 +128,8 @@ A chat can belong to a SUBJECT — the durable case a hand-off to chat is about
114
128
  - `ChatRuntimeProfileInfo` _(type)_ — Runtime profile metadata for UI rendering.
115
129
  - `ChatSseEvent` _(type)_ — SSE event types for chat updates.
116
130
  - `ChatSseEventSchema`
131
+ - `ChatStreamMessageMetadata` _(type)_
132
+ - `ChatStreamMessageMetadataSchema`
117
133
  - `ChatSuccessResponse` _(type)_ — Generic mutation success confirmation.
118
134
  - `ChatSuccessResponseSchema` — Generic success confirmation for mutations that return no entity data.
119
135
  - `ChatSummary` _(type)_ — Lightweight chat summary for list views.
@@ -141,6 +157,11 @@ A chat can belong to a SUBJECT — the durable case a hand-off to chat is about
141
157
  - `LaunchDispositionSchema`
142
158
  - `LaunchTargetSuperseded` _(type)_
143
159
  - `LaunchTargetSupersededSchema` — The 409 body the authoritative launch write returns when the posted chat is not — or can no longer become —…
160
+ - `MESSAGE_ACTION_IDS` — The message and conversation actions a turn's action row can carry.
161
+ - `MessageActionContext` _(type)_
162
+ - `MessageActionContextSchema` — The wire facts eligibility is decided from — all of them known to the app when it draws a turn and to the…
163
+ - `MessageActionId` _(type)_
164
+ - `MessageActionIdSchema`
144
165
  - `ProactiveChatResolution` _(type)_ — Resolving an occurrence id to the CURRENT reader's chat — one of the four bounded states `ready` (with…
145
166
  - `ProactiveChatResolutionSchema` — Resolving an occurrence id to THIS reader's chat (INV-PROACTIVE-LINK).
146
167
  - `RecognizedProactiveKind` _(type)_ — A wire `proactiveKind` after recognition: a kind this contracts version knows, or the raw string it did not —…
@@ -148,12 +169,17 @@ A chat can belong to a SUBJECT — the durable case a hand-off to chat is about
148
169
  - `ResolveLaunchResponseSchema` — Response of the read-only launch resolution.
149
170
  - `StartFreshChatResponse` _(type)_
150
171
  - `StartFreshChatResponseSchema` — Response of the start-fresh write: the NEW active generation of the subject, linked back to the chat it…
172
+ - `TURN_KINDS` — The kinds of chat turn.
151
173
  - `TitleGenerationRequest` _(type)_ — Request to generate a title for a chat.
152
174
  - `TitleGenerationResponse` _(type)_ — Response from title generation.
153
175
  - `TitleGenerationResponseSchema`
154
176
  - `TitleSource` _(type)_ — Title source - tracks how the title was set. - auto: Generated automatically by LLM - manual: Set by user…
155
177
  - `TitleSourceSchema`
178
+ - `TurnKind` _(type)_
179
+ - `TurnKindSchema`
156
180
  - `UpdateChatParticipantRequest` _(type)_ — Request for PATCH /api/chats/:chatId/participants/:userId — the owner moving someone between Read only and…
181
+ - `isMessageActionAllowed` — Whether `action` is among `messageActionsFor(ctx)` — the backend's check.
182
+ - `messageActionsFor` — The actions a turn offers, in MESSAGEACTIONIDS order. - Input that does not parse (unknown kind, unknown…
157
183
  - `recognizeProactiveKind` — Recognize a wire `proactiveKind`.
158
184
 
159
185
  <!-- END GENERATED: readme-public-api -->
@@ -0,0 +1,61 @@
1
+ /**
2
+ * The turn-on-the-wire vocabulary is reachable from the `./chat` entry
3
+ * (chat-turn-identity-and-message-actions).
4
+ *
5
+ * The backend and the app import these from `@company-semantics/contracts/chat`,
6
+ * which maps to `src/chat/index.ts`. A symbol that exists in its module but is
7
+ * missing from the barrel is invisible to every consumer.
8
+ *
9
+ * INVARIANTS TESTED:
10
+ * - TURN_KINDS, ChatStreamMessageMetadataSchema, MESSAGE_ACTION_IDS,
11
+ * MessageActionContextSchema, messageActionsFor and isMessageActionAllowed
12
+ * are exported from the chat barrel
13
+ * - messageActionsFor called through the barrel is the same function the
14
+ * backend enforces: a settled, latest conversation answer offers regenerate
15
+ */
16
+ import { describe, expect, it } from "vitest";
17
+ import {
18
+ ChatStreamMessageMetadataSchema,
19
+ isMessageActionAllowed,
20
+ MESSAGE_ACTION_IDS,
21
+ MessageActionContextSchema,
22
+ messageActionsFor,
23
+ TURN_KINDS,
24
+ TurnKindSchema,
25
+ } from "../index.js";
26
+
27
+ describe("chat barrel exports the turn on the wire", () => {
28
+ it("exports the closed vocabularies", () => {
29
+ expect(TURN_KINDS).toContain("conversation");
30
+ expect(TurnKindSchema.options).toEqual([...TURN_KINDS]);
31
+ expect(MESSAGE_ACTION_IDS).toEqual([
32
+ "copy",
33
+ "feedback",
34
+ "regenerate",
35
+ "edit",
36
+ ]);
37
+ });
38
+
39
+ it("exports the live-stream metadata schema", () => {
40
+ const parsed = ChatStreamMessageMetadataSchema.safeParse({
41
+ messageId: "11111111-1111-4111-8111-111111111111",
42
+ presentationGroupId: null,
43
+ turnKind: "conversation",
44
+ createdAt: "2026-10-09T12:00:00.000Z",
45
+ });
46
+ expect(parsed.success).toBe(true);
47
+ });
48
+
49
+ it("calls messageActionsFor through the barrel", () => {
50
+ const ctx = MessageActionContextSchema.parse({
51
+ role: "assistant",
52
+ turnKind: "conversation",
53
+ settled: true,
54
+ isLatestOnActiveBranch: true,
55
+ authoredByReader: false,
56
+ });
57
+ expect(messageActionsFor(ctx)).toEqual(["copy", "feedback", "regenerate"]);
58
+ expect(isMessageActionAllowed(ctx, "regenerate")).toBe(true);
59
+ expect(isMessageActionAllowed(ctx, "edit")).toBe(false);
60
+ });
61
+ });
@@ -0,0 +1,208 @@
1
+ /**
2
+ * messageActionsFor — the ONE eligibility decision the app renders from and
3
+ * the backend enforces (chat-action-system).
4
+ *
5
+ * INVARIANTS TESTED:
6
+ * - One test per row of the doctrine matrix.
7
+ * - An unsettled turn offers nothing.
8
+ * - Regenerate is never offered on a launch, settlement or input-request turn.
9
+ * - Total over TURN_KINDS; an unknown kind or role is never permissive.
10
+ * - isMessageActionAllowed agrees with messageActionsFor.
11
+ */
12
+ import { describe, expect, it } from "vitest";
13
+ import {
14
+ MESSAGE_ACTION_IDS,
15
+ MessageActionContextSchema,
16
+ isMessageActionAllowed,
17
+ messageActionsFor,
18
+ type MessageActionContext,
19
+ } from "../message-actions.js";
20
+ import { TURN_KINDS } from "../turn-kind.js";
21
+
22
+ function makeCtx(
23
+ over: Partial<MessageActionContext> = {},
24
+ ): MessageActionContext {
25
+ return {
26
+ role: "assistant",
27
+ turnKind: "conversation",
28
+ settled: true,
29
+ isLatestOnActiveBranch: true,
30
+ authoredByReader: false,
31
+ ...over,
32
+ };
33
+ }
34
+
35
+ describe("MESSAGE_ACTION_IDS", () => {
36
+ it("names the four message and conversation actions, in order", () => {
37
+ expect(MESSAGE_ACTION_IDS).toEqual([
38
+ "copy",
39
+ "feedback",
40
+ "regenerate",
41
+ "edit",
42
+ ]);
43
+ });
44
+ });
45
+
46
+ describe("MessageActionContextSchema", () => {
47
+ it("accepts a valid context and refuses an unknown turn kind", () => {
48
+ expect(MessageActionContextSchema.safeParse(makeCtx()).success).toBe(true);
49
+ expect(
50
+ MessageActionContextSchema.safeParse({
51
+ ...makeCtx(),
52
+ turnKind: "tool_call",
53
+ }).success,
54
+ ).toBe(false);
55
+ });
56
+ });
57
+
58
+ describe("messageActionsFor", () => {
59
+ it("an unsettled turn offers nothing", () => {
60
+ for (const turnKind of TURN_KINDS) {
61
+ for (const role of ["user", "assistant"] as const) {
62
+ expect(
63
+ messageActionsFor(
64
+ makeCtx({ role, turnKind, settled: false, authoredByReader: true }),
65
+ ),
66
+ ).toEqual([]);
67
+ }
68
+ }
69
+ });
70
+
71
+ it("a user turn that is latest and the reader's own offers copy and edit", () => {
72
+ expect(
73
+ messageActionsFor(makeCtx({ role: "user", authoredByReader: true })),
74
+ ).toEqual(["copy", "edit"]);
75
+ });
76
+
77
+ it("a user turn that is not latest, or not the reader's own, offers copy only", () => {
78
+ expect(
79
+ messageActionsFor(
80
+ makeCtx({
81
+ role: "user",
82
+ authoredByReader: true,
83
+ isLatestOnActiveBranch: false,
84
+ }),
85
+ ),
86
+ ).toEqual(["copy"]);
87
+ expect(
88
+ messageActionsFor(makeCtx({ role: "user", authoredByReader: false })),
89
+ ).toEqual(["copy"]);
90
+ });
91
+
92
+ it("the latest assistant conversation turn offers copy, feedback and regenerate", () => {
93
+ expect(messageActionsFor(makeCtx())).toEqual([
94
+ "copy",
95
+ "feedback",
96
+ "regenerate",
97
+ ]);
98
+ });
99
+
100
+ it("an older assistant conversation turn offers copy and feedback", () => {
101
+ expect(
102
+ messageActionsFor(makeCtx({ isLatestOnActiveBranch: false })),
103
+ ).toEqual(["copy", "feedback"]);
104
+ });
105
+
106
+ it("never offers regenerate on a workflow_launch, workflow_settlement or input_request_launch turn", () => {
107
+ for (const turnKind of [
108
+ "workflow_launch",
109
+ "workflow_settlement",
110
+ "input_request_launch",
111
+ ] as const) {
112
+ for (const isLatestOnActiveBranch of [true, false]) {
113
+ expect(
114
+ messageActionsFor(makeCtx({ turnKind, isLatestOnActiveBranch })),
115
+ ).toEqual(["copy", "feedback"]);
116
+ }
117
+ }
118
+ });
119
+
120
+ it("a proactive or refusal turn offers copy only", () => {
121
+ for (const turnKind of ["proactive", "refusal"] as const) {
122
+ expect(messageActionsFor(makeCtx({ turnKind }))).toEqual(["copy"]);
123
+ }
124
+ });
125
+
126
+ it("never offers edit on an assistant turn, nor regenerate on a user turn", () => {
127
+ for (const turnKind of TURN_KINDS) {
128
+ expect(
129
+ messageActionsFor(makeCtx({ turnKind, authoredByReader: true })),
130
+ ).not.toContain("edit");
131
+ expect(
132
+ messageActionsFor(
133
+ makeCtx({ role: "user", turnKind, authoredByReader: true }),
134
+ ),
135
+ ).not.toContain("regenerate");
136
+ }
137
+ });
138
+
139
+ it("is total: every turn kind and role yields a list of known actions", () => {
140
+ // `satisfies Record<TurnKind, …>` proves totality at compile time; this
141
+ // loops the vocabulary at runtime.
142
+ for (const turnKind of TURN_KINDS) {
143
+ for (const role of ["user", "assistant"] as const) {
144
+ const actions = messageActionsFor(makeCtx({ role, turnKind }));
145
+ expect(actions).toContain("copy");
146
+ for (const action of actions)
147
+ expect(MESSAGE_ACTION_IDS).toContain(action);
148
+ }
149
+ }
150
+ });
151
+
152
+ it("an unknown turn kind or role offers nothing", () => {
153
+ const unknownKind = {
154
+ ...makeCtx(),
155
+ turnKind: "tool_call",
156
+ } as unknown as MessageActionContext;
157
+ const unknownRole = {
158
+ ...makeCtx(),
159
+ role: "system",
160
+ } as unknown as MessageActionContext;
161
+ const inherited = {
162
+ ...makeCtx(),
163
+ turnKind: "toString",
164
+ } as unknown as MessageActionContext;
165
+ expect(messageActionsFor(unknownKind)).toEqual([]);
166
+ expect(messageActionsFor(unknownRole)).toEqual([]);
167
+ expect(messageActionsFor(inherited)).toEqual([]);
168
+ });
169
+
170
+ it("is pure: the same input yields the same result and the result is a fresh list", () => {
171
+ const first = messageActionsFor(makeCtx());
172
+ const second = messageActionsFor(makeCtx());
173
+ expect(first).toEqual(second);
174
+ expect(first).not.toBe(second);
175
+ });
176
+ });
177
+
178
+ describe("isMessageActionAllowed", () => {
179
+ it("agrees with messageActionsFor for every kind, role, position and action", () => {
180
+ for (const turnKind of TURN_KINDS) {
181
+ for (const role of ["user", "assistant"] as const) {
182
+ for (const isLatestOnActiveBranch of [true, false]) {
183
+ const ctx = makeCtx({
184
+ role,
185
+ turnKind,
186
+ isLatestOnActiveBranch,
187
+ authoredByReader: true,
188
+ });
189
+ const offered = messageActionsFor(ctx);
190
+ for (const action of MESSAGE_ACTION_IDS) {
191
+ expect(isMessageActionAllowed(ctx, action)).toBe(
192
+ offered.includes(action),
193
+ );
194
+ }
195
+ }
196
+ }
197
+ }
198
+ });
199
+
200
+ it("refuses regenerate on a settled workflow turn the server is asked to regenerate", () => {
201
+ expect(
202
+ isMessageActionAllowed(
203
+ makeCtx({ turnKind: "workflow_settlement" }),
204
+ "regenerate",
205
+ ),
206
+ ).toBe(false);
207
+ });
208
+ });
@@ -54,6 +54,10 @@ const makeMessage = (over: Record<string, unknown> = {}) => ({
54
54
  // required-ness itself is pinned in ./multi-party.test.ts; here it is just
55
55
  // part of a well-formed message.
56
56
  author: userPrincipal("user_1"),
57
+ // REQUIRED turn identity — pinned in ./turn-wire.test.ts.
58
+ presentationGroupId: null,
59
+ turnKind: "conversation" as const,
60
+ myFeedback: null,
57
61
  ...over,
58
62
  });
59
63
 
@@ -53,6 +53,9 @@ const makeMessage = (over: Record<string, unknown> = {}) => ({
53
53
  createdAt: ISO,
54
54
  mentions: [],
55
55
  author: userPrincipal("user_1"),
56
+ presentationGroupId: null,
57
+ turnKind: "conversation" as const,
58
+ myFeedback: null,
56
59
  ...over,
57
60
  });
58
61
 
@@ -0,0 +1,73 @@
1
+ /**
2
+ * ChatStreamMessageMetadataSchema — the AI SDK `start` chunk metadata
3
+ * (chat-action-system).
4
+ *
5
+ * INVARIANTS TESTED:
6
+ * - a valid metadata value round-trips unchanged
7
+ * - a null presentation group parses (the turn stands alone)
8
+ * - a non-uuid messageId is refused
9
+ * - each field is required; an unknown turnKind is refused
10
+ */
11
+ import { describe, expect, it } from "vitest";
12
+ import {
13
+ ChatStreamMessageMetadataSchema,
14
+ type ChatStreamMessageMetadata,
15
+ } from "../stream-metadata.js";
16
+
17
+ function makeMetadata(
18
+ over: Partial<Record<keyof ChatStreamMessageMetadata, unknown>> = {},
19
+ ): Record<string, unknown> {
20
+ return {
21
+ messageId: "6f1c2a3b-4d5e-4f60-8a7b-9c0d1e2f3a4b",
22
+ presentationGroupId: "0e9d8c7b-6a5f-4e3d-9c2b-1a0f9e8d7c6b",
23
+ turnKind: "workflow_launch",
24
+ createdAt: "2026-10-08T12:00:00.000Z",
25
+ ...over,
26
+ };
27
+ }
28
+
29
+ describe("ChatStreamMessageMetadataSchema", () => {
30
+ it("round-trips a valid metadata value", () => {
31
+ const value = makeMetadata();
32
+ expect(ChatStreamMessageMetadataSchema.parse(value)).toEqual(value);
33
+ });
34
+
35
+ it("accepts a null presentationGroupId", () => {
36
+ const value = makeMetadata({
37
+ presentationGroupId: null,
38
+ turnKind: "conversation",
39
+ });
40
+ expect(ChatStreamMessageMetadataSchema.parse(value)).toEqual(value);
41
+ });
42
+
43
+ it("refuses a non-uuid messageId", () => {
44
+ expect(
45
+ ChatStreamMessageMetadataSchema.safeParse(
46
+ makeMetadata({ messageId: "msg-1" }),
47
+ ).success,
48
+ ).toBe(false);
49
+ });
50
+
51
+ it("refuses a value missing any field", () => {
52
+ for (const key of [
53
+ "messageId",
54
+ "presentationGroupId",
55
+ "turnKind",
56
+ "createdAt",
57
+ ] as const) {
58
+ const value = makeMetadata();
59
+ delete value[key];
60
+ expect(ChatStreamMessageMetadataSchema.safeParse(value).success).toBe(
61
+ false,
62
+ );
63
+ }
64
+ });
65
+
66
+ it("refuses an unknown turnKind", () => {
67
+ expect(
68
+ ChatStreamMessageMetadataSchema.safeParse(
69
+ makeMetadata({ turnKind: "execution" }),
70
+ ).success,
71
+ ).toBe(false);
72
+ });
73
+ });
@@ -0,0 +1,34 @@
1
+ /**
2
+ * TURN_KINDS — the closed vocabulary of chat turn kinds (chat-action-system).
3
+ *
4
+ * INVARIANTS TESTED:
5
+ * - the union is exactly the six kinds, in declaration order
6
+ * - TurnKindSchema accepts every member and refuses anything else
7
+ */
8
+ import { describe, expect, it } from "vitest";
9
+ import { TURN_KINDS, TurnKindSchema } from "../turn-kind.js";
10
+
11
+ describe("TURN_KINDS", () => {
12
+ it("names the six turn kinds, in order", () => {
13
+ expect(TURN_KINDS).toEqual([
14
+ "conversation",
15
+ "workflow_launch",
16
+ "workflow_settlement",
17
+ "input_request_launch",
18
+ "proactive",
19
+ "refusal",
20
+ ]);
21
+ });
22
+
23
+ it("TurnKindSchema accepts every member", () => {
24
+ for (const kind of TURN_KINDS) {
25
+ expect(TurnKindSchema.parse(kind)).toBe(kind);
26
+ }
27
+ });
28
+
29
+ it("TurnKindSchema refuses an unknown kind", () => {
30
+ expect(TurnKindSchema.safeParse("execution").success).toBe(false);
31
+ expect(TurnKindSchema.safeParse("Conversation").success).toBe(false);
32
+ expect(TurnKindSchema.safeParse(null).success).toBe(false);
33
+ });
34
+ });
@@ -0,0 +1,93 @@
1
+ /**
2
+ * The chat turn on the wire (chat-action-system).
3
+ *
4
+ * INVARIANTS TESTED:
5
+ * - `presentationGroupId`, `turnKind` and `myFeedback` are REQUIRED on every
6
+ * chat message. Every row has an answer — null for no group, a kind for every
7
+ * row, null for no rating — so a missing field is a backend that has not
8
+ * adopted this release, and refusing it is the intended failure.
9
+ * - A null group and a null rating parse: the turn stands alone / is unrated.
10
+ * - The group is a uuid; anything else is refused.
11
+ * - `turnKind` is CLOSED: an unknown kind is refused, never passed through.
12
+ * - `myFeedback` is exactly `up`, `down` or null.
13
+ */
14
+ import { describe, expect, it } from "vitest";
15
+ import { userPrincipal } from "../../identity/agent";
16
+ import { ChatMessageSchema } from "../schemas.js";
17
+ import { TURN_KINDS } from "../turn-kind.js";
18
+
19
+ // One well-formed message; every negative case changes exactly ONE field.
20
+ const makeMessage = (over: Record<string, unknown> = {}) => ({
21
+ id: "msg_1",
22
+ role: "assistant" as const,
23
+ content: "Done.",
24
+ sequenceNumber: 1,
25
+ createdAt: "2026-10-08T00:00:00.000Z",
26
+ mentions: [],
27
+ author: { kind: "agent" as const },
28
+ presentationGroupId: "3f2b8c1e-6d4a-4f7b-9a2e-1c5d8e9f0a1b",
29
+ turnKind: "workflow_settlement" as const,
30
+ myFeedback: "up" as const,
31
+ ...over,
32
+ });
33
+
34
+ const without = (key: string) => {
35
+ const message: Record<string, unknown> = makeMessage();
36
+ delete message[key];
37
+ return message;
38
+ };
39
+
40
+ describe("ChatMessageSchema turn identity", () => {
41
+ it("accepts a well-formed grouped, rated turn", () => {
42
+ expect(ChatMessageSchema.safeParse(makeMessage()).success).toBe(true);
43
+ });
44
+
45
+ it("refuses a message without presentationGroupId, turnKind or myFeedback", () => {
46
+ for (const key of ["presentationGroupId", "turnKind", "myFeedback"]) {
47
+ expect(ChatMessageSchema.safeParse(without(key)).success).toBe(false);
48
+ }
49
+ });
50
+
51
+ it("parses a null group and a null rating", () => {
52
+ const parsed = ChatMessageSchema.parse(
53
+ makeMessage({
54
+ role: "user",
55
+ author: userPrincipal("user_1"),
56
+ presentationGroupId: null,
57
+ turnKind: "conversation",
58
+ myFeedback: null,
59
+ }),
60
+ );
61
+ expect(parsed.presentationGroupId).toBeNull();
62
+ expect(parsed.myFeedback).toBeNull();
63
+ });
64
+
65
+ it("accepts every turn kind", () => {
66
+ for (const turnKind of TURN_KINDS) {
67
+ expect(
68
+ ChatMessageSchema.safeParse(makeMessage({ turnKind })).success,
69
+ ).toBe(true);
70
+ }
71
+ });
72
+
73
+ it("refuses an unknown turnKind", () => {
74
+ expect(
75
+ ChatMessageSchema.safeParse(makeMessage({ turnKind: "workflow" }))
76
+ .success,
77
+ ).toBe(false);
78
+ });
79
+
80
+ it("refuses a non-uuid presentationGroupId", () => {
81
+ expect(
82
+ ChatMessageSchema.safeParse(makeMessage({ presentationGroupId: "run_1" }))
83
+ .success,
84
+ ).toBe(false);
85
+ });
86
+
87
+ it("refuses a rating other than up, down or null", () => {
88
+ expect(
89
+ ChatMessageSchema.safeParse(makeMessage({ myFeedback: "neutral" }))
90
+ .success,
91
+ ).toBe(false);
92
+ });
93
+ });
package/src/chat/index.ts CHANGED
@@ -138,6 +138,23 @@ export {
138
138
  LaunchTargetSupersededSchema,
139
139
  } from "./continuity";
140
140
 
141
+ // The turn on the wire (chat-turn-identity-and-message-actions): the closed
142
+ // turn-kind vocabulary, the live-stream message metadata for the AI SDK start
143
+ // chunk, and the one pure eligibility function the app renders from and the
144
+ // backend enforces (doctrine: chat-action-system)
145
+ export type { TurnKind } from "./turn-kind";
146
+ export { TURN_KINDS, TurnKindSchema } from "./turn-kind";
147
+ export type { ChatStreamMessageMetadata } from "./stream-metadata";
148
+ export { ChatStreamMessageMetadataSchema } from "./stream-metadata";
149
+ export type { MessageActionId, MessageActionContext } from "./message-actions";
150
+ export {
151
+ MESSAGE_ACTION_IDS,
152
+ MessageActionIdSchema,
153
+ MessageActionContextSchema,
154
+ messageActionsFor,
155
+ isMessageActionAllowed,
156
+ } from "./message-actions";
157
+
141
158
  // Runtime profile types and constants
142
159
  export type {
143
160
  ChatRuntimeProfile,