@company-semantics/contracts 93.3.0 → 94.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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@company-semantics/contracts",
3
- "version": "93.3.0",
3
+ "version": "94.0.0",
4
4
  "private": false,
5
5
  "repository": {
6
6
  "type": "git",
@@ -1,3 +1,3 @@
1
1
  // AUTO-GENERATED — do not edit. Run pnpm generate:spec-hash to regenerate.
2
- export const SPEC_HASH = '694bbd340597' as const;
3
- export const SPEC_HASH_FULL = '694bbd3405976ed940683994c167242174ad628748e5694fc6f138ccd4802cee' as const;
2
+ export const SPEC_HASH = '826e3d4d23ef' as const;
3
+ export const SPEC_HASH_FULL = '826e3d4d23ef986d25b22b32497686755ce2b9bebc0ad90efd7768b4e1e076de' as const;
@@ -5569,6 +5569,10 @@ export interface components {
5569
5569
  requestId: string;
5570
5570
  questionId: string;
5571
5571
  };
5572
+ presentationGroupId: string | null;
5573
+ /** @enum {string} */
5574
+ turnKind: "conversation" | "workflow_launch" | "workflow_settlement" | "input_request_launch" | "proactive" | "refusal";
5575
+ myFeedback: ("up" | "down") | null;
5572
5576
  }[];
5573
5577
  };
5574
5578
  SuccessResponse: {
@@ -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,
@@ -0,0 +1,143 @@
1
+ /**
2
+ * Chat message action eligibility (chat-action-system).
3
+ *
4
+ * Which MESSAGE actions (copy, feedback) and CONVERSATION actions (regenerate,
5
+ * edit) a chat turn offers is decided by ONE pure function over wire facts:
6
+ * the turn's role, its TurnKind and its position. The app renders the action
7
+ * row from `messageActionsFor`; the backend enforces the SAME function on every
8
+ * regenerate, edit and feedback request. Hiding a button is never the security
9
+ * boundary.
10
+ *
11
+ * Execution actions (Cancel, Continue, Retry operation, Run again) are NOT
12
+ * here: they act on durable work, not on a message, and reach a person as
13
+ * affordances from the closed catalog (affordance-system).
14
+ *
15
+ * INVARIANTS:
16
+ * - PURE and TOTAL: no I/O, no clock, no defaults. Every input yields a list.
17
+ * - An unsettled turn offers NOTHING — the row appears once, when it settles.
18
+ * - Regenerate is never offered on a workflow_launch, workflow_settlement,
19
+ * input_request_launch, proactive or refusal turn: it would ask the model for
20
+ * new prose without re-running the work.
21
+ * - Regenerate and edit are offered only on the latest turn of the active
22
+ * branch; edit only to the reader who authored it.
23
+ * - An input the schema does not recognize (an unknown kind or role) offers
24
+ * NOTHING. Unknown is never permissive.
25
+ *
26
+ * LOAD ORDER: imports only zod and `./turn-kind` (a leaf); never `./schemas`.
27
+ */
28
+
29
+ import { z } from "zod";
30
+ import { TurnKindSchema, type TurnKind } from "./turn-kind";
31
+
32
+ /**
33
+ * The message and conversation actions a turn's action row can carry. CLOSED
34
+ * and append-only. The order is the order the row draws them in, and the
35
+ * order `messageActionsFor` returns them in.
36
+ */
37
+ export const MESSAGE_ACTION_IDS = [
38
+ /** Copy the turn's readable prose. Side-effect-free. */
39
+ "copy",
40
+ /** Rate the turn up or down. Measures a message; never approves anything. */
41
+ "feedback",
42
+ /** A new answer to the same request; supersedes the old one. Never re-runs work. */
43
+ "regenerate",
44
+ /** Rewrite the reader's own prompt; supersedes the branch after it. */
45
+ "edit",
46
+ ] as const;
47
+
48
+ export const MessageActionIdSchema = z.enum(MESSAGE_ACTION_IDS);
49
+ export type MessageActionId = z.infer<typeof MessageActionIdSchema>;
50
+
51
+ /**
52
+ * The wire facts eligibility is decided from — all of them known to the app
53
+ * when it draws a turn and to the backend when it receives a request.
54
+ */
55
+ export const MessageActionContextSchema = z.object({
56
+ role: z.enum(["user", "assistant"]),
57
+ turnKind: TurnKindSchema,
58
+ /**
59
+ * The turn is done: not streaming, and no launched workflow behind it is in
60
+ * flight or awaiting settlement delivery.
61
+ */
62
+ settled: z.boolean(),
63
+ /** The turn is the latest of its role on the active branch. */
64
+ isLatestOnActiveBranch: z.boolean(),
65
+ /** The reader is the turn's author (a user turn they wrote). */
66
+ authoredByReader: z.boolean(),
67
+ });
68
+ export type MessageActionContext = z.infer<typeof MessageActionContextSchema>;
69
+
70
+ /** `always` is offered on every settled turn; `latest` only on the latest. */
71
+ type ActionRule = {
72
+ always: readonly MessageActionId[];
73
+ latest: readonly MessageActionId[];
74
+ };
75
+
76
+ /**
77
+ * The eligibility table. `satisfies Record<TurnKind, …>` makes a new TurnKind a
78
+ * compile error here until its eligibility is decided. Only a conversation
79
+ * turn is a request/answer pair, so only it offers regenerate or edit; a user
80
+ * row of any other kind (none exists today) is copy-only.
81
+ */
82
+ const TURN_ACTIONS = {
83
+ conversation: {
84
+ user: { always: ["copy"], latest: ["edit"] },
85
+ assistant: { always: ["copy", "feedback"], latest: ["regenerate"] },
86
+ },
87
+ workflow_launch: {
88
+ user: { always: ["copy"], latest: [] },
89
+ assistant: { always: ["copy", "feedback"], latest: [] },
90
+ },
91
+ workflow_settlement: {
92
+ user: { always: ["copy"], latest: [] },
93
+ assistant: { always: ["copy", "feedback"], latest: [] },
94
+ },
95
+ input_request_launch: {
96
+ user: { always: ["copy"], latest: [] },
97
+ assistant: { always: ["copy", "feedback"], latest: [] },
98
+ },
99
+ proactive: {
100
+ user: { always: ["copy"], latest: [] },
101
+ assistant: { always: ["copy"], latest: [] },
102
+ },
103
+ refusal: {
104
+ user: { always: ["copy"], latest: [] },
105
+ assistant: { always: ["copy"], latest: [] },
106
+ },
107
+ } as const satisfies Record<
108
+ TurnKind,
109
+ Record<MessageActionContext["role"], ActionRule>
110
+ >;
111
+
112
+ /**
113
+ * The actions a turn offers, in MESSAGE_ACTION_IDS order.
114
+ *
115
+ * - Input that does not parse (unknown kind, unknown role) → [].
116
+ * - Not settled → [].
117
+ * - User turn → its `always`, plus `latest` iff latest on the active branch
118
+ * AND authored by the reader.
119
+ * - Assistant turn → its `always`, plus `latest` iff latest on the active
120
+ * branch.
121
+ */
122
+ export function messageActionsFor(
123
+ ctx: MessageActionContext,
124
+ ): readonly MessageActionId[] {
125
+ const parsed = MessageActionContextSchema.safeParse(ctx);
126
+ if (!parsed.success || !parsed.data.settled) return [];
127
+ const { role, turnKind, isLatestOnActiveBranch, authoredByReader } =
128
+ parsed.data;
129
+ const rule: ActionRule = TURN_ACTIONS[turnKind][role];
130
+ const latest =
131
+ role === "user"
132
+ ? isLatestOnActiveBranch && authoredByReader
133
+ : isLatestOnActiveBranch;
134
+ return latest ? [...rule.always, ...rule.latest] : [...rule.always];
135
+ }
136
+
137
+ /** Whether `action` is among `messageActionsFor(ctx)` — the backend's check. */
138
+ export function isMessageActionAllowed(
139
+ ctx: MessageActionContext,
140
+ action: MessageActionId,
141
+ ): boolean {
142
+ return messageActionsFor(ctx).includes(action);
143
+ }
@@ -25,6 +25,7 @@ import {
25
25
  proactiveChatFields,
26
26
  TitleSourceSchema,
27
27
  } from "./enums";
28
+ import { TurnKindSchema } from "./turn-kind";
28
29
 
29
30
  // The chat vocabulary and the SSE event contract, re-exported so this module
30
31
  // stays the single import site for every chat schema.
@@ -285,6 +286,35 @@ export const ChatMessageSchema = z.object({
285
286
  *
286
287
  */
287
288
  discussionScope: ChatDiscussionScopeSchema.optional(),
289
+
290
+ /**
291
+ * The PRESENTATION GROUP this turn belongs to: every row of one launched
292
+ * piece of work draws as ONE response, with one action row
293
+ * (chat-action-system). REQUIRED, and null means the turn stands alone —
294
+ * every row has an answer, so there is no absence to make optional.
295
+ *
296
+ * A group is a SEPARATE CONCEPT FROM THE WORKFLOW it may name
297
+ * (INV-LAUNCHTURN-2): it is a presentation fact, never a workflow or run id,
298
+ * and a client must not join on it as one. The settlement row and the review
299
+ * hand-off INHERIT the anchor's group, so a launch, its settlement and its
300
+ * hand-off render together. Superseded rows are never on this wire — the
301
+ * transcript carries the active branch only.
302
+ */
303
+ presentationGroupId: z.string().uuid().nullable(),
304
+
305
+ /**
306
+ * What KIND of turn this row is — derived by the server from what persisted
307
+ * it, never inferred by a client from content. REQUIRED: every row is exactly
308
+ * one kind. Not a principal kind; `author` is unchanged by it.
309
+ */
310
+ turnKind: TurnKindSchema,
311
+
312
+ /**
313
+ * The READER's current rating of this turn — the person this response was
314
+ * projected for, never an aggregate of other participants. REQUIRED so a
315
+ * reload restores the rating; null on user turns and on unrated turns.
316
+ */
317
+ myFeedback: z.enum(["up", "down"]).nullable(),
288
318
  });
289
319
 
290
320
  // =============================================================================
@@ -0,0 +1,40 @@
1
+ /**
2
+ * Live-stream message metadata (chat-action-system).
3
+ *
4
+ * The `messageMetadata` the backend attaches to the AI SDK `start` chunk of a
5
+ * streamed assistant turn. It exists so a LIVE message is already the
6
+ * persisted message: the app adopts `messageId` as the live message's id, so
7
+ * the live message's id IS the persisted row id (doctrine rule 10), and it
8
+ * carries its presentation group and turn kind before any reload. Without
9
+ * it, a client could only draw a live turn as a loose message and regroup it
10
+ * after the next fetch — the flicker the presentation group exists to end.
11
+ *
12
+ * Every field mirrors the same-named field on `ChatMessage`, with the same
13
+ * meaning: a client that reads the live metadata and later reloads the
14
+ * transcript sees the same id, group, kind and timestamp.
15
+ *
16
+ * LOAD ORDER: imports only zod, `../api/primitives` and the `./turn-kind`
17
+ * leaf. It must NEVER import `./schemas`.
18
+ */
19
+
20
+ import { z } from "zod";
21
+ import { IsoDateTime } from "../api/primitives";
22
+ import { TurnKindSchema } from "./turn-kind";
23
+
24
+ export const ChatStreamMessageMetadataSchema = z.object({
25
+ /** The persisted row id of this message — the live message adopts it. */
26
+ messageId: z.string().uuid(),
27
+ /**
28
+ * The presentation group this turn joins; null when it stands alone.
29
+ * REQUIRED, exactly as on `ChatMessage` — every turn has an answer.
30
+ */
31
+ presentationGroupId: z.string().uuid().nullable(),
32
+ /** What kind of turn this is, derived by the server. REQUIRED. */
33
+ turnKind: TurnKindSchema,
34
+ /** The persisted row's creation time, so the live turn orders as it will on reload. */
35
+ createdAt: IsoDateTime,
36
+ });
37
+
38
+ export type ChatStreamMessageMetadata = z.infer<
39
+ typeof ChatStreamMessageMetadataSchema
40
+ >;
@@ -0,0 +1,43 @@
1
+ /**
2
+ * Chat turn kind (chat-action-system).
3
+ *
4
+ * Every chat message row is exactly one KIND of turn. The kind is a wire
5
+ * fact the server derives from what persisted the row — never something a
6
+ * client infers from content — so a client can decide how to draw a turn
7
+ * and which message actions it offers from the wire alone.
8
+ *
9
+ * A turn kind is NOT a principal kind: it never changes who authored the
10
+ * row (`author` stays a PrincipalRef), and it is not the presentation group
11
+ * — a group is a separate concept from the workflow it may name.
12
+ *
13
+ * LOAD ORDER: this module is a LEAF. It imports only zod and must NEVER
14
+ * import `./schemas` (or any module that does) — `./schemas`,
15
+ * `./stream-metadata` and `./message-actions` all import this file, and the
16
+ * reverse edge would reach its declarations in the temporal dead zone.
17
+ */
18
+
19
+ import { z } from "zod";
20
+
21
+ /**
22
+ * The kinds of chat turn. CLOSED and append-only: a new kind is a new
23
+ * eligibility decision, and every `Record<TurnKind, …>` table must fail to
24
+ * compile until that decision is made. Each kind is defined by the
25
+ * persisted fact that produces it.
26
+ */
27
+ export const TURN_KINDS = [
28
+ /** A user turn, or a model-authored answer to one — no launch behind it. */
29
+ "conversation",
30
+ /** A launch-only anchor whose launch kind names a workflow. */
31
+ "workflow_launch",
32
+ /** A settlement row: its launch identity carries the `workflow-settled:` prefix. */
33
+ "workflow_settlement",
34
+ /** A launch-only anchor whose launch kind names an input request. */
35
+ "input_request_launch",
36
+ /** An opener written by the ProactiveChatWriter, not in reply to a turn. */
37
+ "proactive",
38
+ /** The row recording a launch the server refused. */
39
+ "refusal",
40
+ ] as const;
41
+
42
+ export const TurnKindSchema = z.enum(TURN_KINDS);
43
+ export type TurnKind = z.infer<typeof TurnKindSchema>;