@company-semantics/contracts 62.11.0 → 63.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@company-semantics/contracts",
3
- "version": "62.11.0",
3
+ "version": "63.1.0",
4
4
  "private": false,
5
5
  "repository": {
6
6
  "type": "git",
@@ -181,5 +181,5 @@
181
181
  "*.md": "markdownlint-cli2",
182
182
  "package.json": "node -e \"JSON.parse(require('fs').readFileSync('package.json'))\""
183
183
  },
184
- "securityRequirementsVersion": "2dcea336317c1136c8d94798b1cadc9c115febf574d041a173974a5f7959de0b"
184
+ "securityRequirementsVersion": "b5085eaa2fcf9fee09a3c8d2a9e15eab119800def73c98dc6ab6937d1c6e07b2"
185
185
  }
@@ -1,3 +1,3 @@
1
1
  // AUTO-GENERATED — do not edit. Run pnpm generate:spec-hash to regenerate.
2
- export const SPEC_HASH = '9829bca605e4' as const;
3
- export const SPEC_HASH_FULL = '9829bca605e4a464d73b067d1d063d2a68c5871b9fae570470a43987e481bb3a' as const;
2
+ export const SPEC_HASH = '387045558d18' as const;
3
+ export const SPEC_HASH_FULL = '387045558d1867722a744ddb05cf5ebb7260c54df0ff272b868bd4dd2ad2a973' as const;
@@ -4079,6 +4079,19 @@ export interface components {
4079
4079
  sequenceNumber: number;
4080
4080
  /** Format: date-time */
4081
4081
  createdAt: string;
4082
+ mentions: {
4083
+ target: {
4084
+ /** @constant */
4085
+ kind: "user";
4086
+ userId: string;
4087
+ } | {
4088
+ /** @constant */
4089
+ kind: "agent";
4090
+ };
4091
+ startOffset: number | null;
4092
+ endOffset: number | null;
4093
+ displayName: string;
4094
+ }[];
4082
4095
  }[];
4083
4096
  };
4084
4097
  SuccessResponse: {
@@ -4360,6 +4373,11 @@ export interface components {
4360
4373
  /** @enum {string} */
4361
4374
  origin?: "user" | "proactive";
4362
4375
  launch: {
4376
+ /** @constant */
4377
+ kind: "structure-review";
4378
+ /** Format: uuid */
4379
+ proposalId: string;
4380
+ } | {
4363
4381
  /** @constant */
4364
4382
  kind: "workflow";
4365
4383
  /** Format: uuid */
@@ -6948,12 +6966,19 @@ export interface components {
6948
6966
  insertedText?: string;
6949
6967
  };
6950
6968
  body?: string;
6951
- mentions?: {
6969
+ mentions?: ({
6970
+ /** @constant */
6971
+ kind: "user";
6952
6972
  /** Format: uuid */
6953
6973
  userId: string;
6954
6974
  startOffset: number;
6955
6975
  endOffset: number;
6956
- }[];
6976
+ } | {
6977
+ /** @constant */
6978
+ kind: "agent";
6979
+ startOffset: number;
6980
+ endOffset: number;
6981
+ })[];
6957
6982
  };
6958
6983
  /** @description The written reply, and whether it auto-reopened a resolved thread. */
6959
6984
  CommentReplyResponse: {
@@ -6962,12 +6987,19 @@ export interface components {
6962
6987
  };
6963
6988
  CommentReplyRequest: {
6964
6989
  body: string;
6965
- mentions?: {
6990
+ mentions?: ({
6991
+ /** @constant */
6992
+ kind: "user";
6966
6993
  /** Format: uuid */
6967
6994
  userId: string;
6968
6995
  startOffset: number;
6969
6996
  endOffset: number;
6970
- }[];
6997
+ } | {
6998
+ /** @constant */
6999
+ kind: "agent";
7000
+ startOffset: number;
7001
+ endOffset: number;
7002
+ })[];
6971
7003
  };
6972
7004
  /** @description The comment after the mutation (redacted projection after delete). */
6973
7005
  CommentResponse: {
@@ -6975,12 +7007,19 @@ export interface components {
6975
7007
  };
6976
7008
  EditCommentRequest: {
6977
7009
  body: string;
6978
- mentions?: {
7010
+ mentions?: ({
7011
+ /** @constant */
7012
+ kind: "user";
6979
7013
  /** Format: uuid */
6980
7014
  userId: string;
6981
7015
  startOffset: number;
6982
7016
  endOffset: number;
6983
- }[];
7017
+ } | {
7018
+ /** @constant */
7019
+ kind: "agent";
7020
+ startOffset: number;
7021
+ endOffset: number;
7022
+ })[];
6984
7023
  };
6985
7024
  /** @description The thread after a resolve/reopen transition. */
6986
7025
  CommentThreadSummaryResponse: {
@@ -7013,7 +7052,14 @@ export interface components {
7013
7052
  };
7014
7053
  /** @description A principal who can read the document and may therefore be mentioned. */
7015
7054
  CompanyMdMentionableCandidate: {
7016
- userId: string;
7055
+ principal: {
7056
+ /** @constant */
7057
+ kind: "user";
7058
+ userId: string;
7059
+ } | {
7060
+ /** @constant */
7061
+ kind: "agent";
7062
+ };
7017
7063
  displayName: string;
7018
7064
  avatarUrl: string | null;
7019
7065
  };
@@ -7094,6 +7140,29 @@ export interface components {
7094
7140
  /** @enum {string} */
7095
7141
  kind: "comment" | "suggestion";
7096
7142
  suggestion: components["schemas"]["Suggestion"] | null;
7143
+ origin: {
7144
+ /** @constant */
7145
+ kind: "comment";
7146
+ } | {
7147
+ /** @constant */
7148
+ kind: "doc_mention";
7149
+ byUser: ({
7150
+ /** @constant */
7151
+ kind: "user";
7152
+ userId: string;
7153
+ } | {
7154
+ /** @constant */
7155
+ kind: "agent";
7156
+ }) | null;
7157
+ introducedAt: string;
7158
+ };
7159
+ agentOutcomes: {
7160
+ triggerId: string;
7161
+ /** @enum {string} */
7162
+ status: "pending" | "replied" | "unanswered";
7163
+ reason: ("replies_off" | "mentioner_unavailable" | "delegate_unknown" | "subject_unreadable" | "rate_capped" | "turn_failed") | null;
7164
+ replyCommentId: string | null;
7165
+ }[];
7097
7166
  createdByUserId: string | null;
7098
7167
  resolvedByUserId: string | null;
7099
7168
  resolvedAt: string | null;
@@ -7142,16 +7211,30 @@ export interface components {
7142
7211
  Comment: {
7143
7212
  id: string;
7144
7213
  threadId: string;
7145
- authorUserId: string | null;
7214
+ author: ({
7215
+ /** @constant */
7216
+ kind: "user";
7217
+ userId: string;
7218
+ } | {
7219
+ /** @constant */
7220
+ kind: "agent";
7221
+ }) | null;
7146
7222
  body: string | null;
7147
7223
  editedAt: string | null;
7148
7224
  deletedAt: string | null;
7149
7225
  createdAt: string;
7150
7226
  mentions: components["schemas"]["CommentMention"][];
7151
7227
  };
7152
- /** @description One mention on a comment: the mentioned user id, its zero-based exclusive-end UTF-16 range (null until the offset columns exist), and the display name resolved at read time. */
7228
+ /** @description One mention on a comment: the principal mentioned, its zero-based exclusive-end UTF-16 range (null until the offset columns exist), and the display name resolved at read time. */
7153
7229
  CommentMention: {
7154
- userId: string;
7230
+ target: {
7231
+ /** @constant */
7232
+ kind: "user";
7233
+ userId: string;
7234
+ } | {
7235
+ /** @constant */
7236
+ kind: "agent";
7237
+ };
7155
7238
  startOffset: number | null;
7156
7239
  endOffset: number | null;
7157
7240
  displayName: string;
@@ -7178,6 +7261,29 @@ export interface components {
7178
7261
  /** @enum {string} */
7179
7262
  kind: "comment" | "suggestion";
7180
7263
  suggestion: components["schemas"]["Suggestion"] | null;
7264
+ origin: {
7265
+ /** @constant */
7266
+ kind: "comment";
7267
+ } | {
7268
+ /** @constant */
7269
+ kind: "doc_mention";
7270
+ byUser: ({
7271
+ /** @constant */
7272
+ kind: "user";
7273
+ userId: string;
7274
+ } | {
7275
+ /** @constant */
7276
+ kind: "agent";
7277
+ }) | null;
7278
+ introducedAt: string;
7279
+ };
7280
+ agentOutcomes: {
7281
+ triggerId: string;
7282
+ /** @enum {string} */
7283
+ status: "pending" | "replied" | "unanswered";
7284
+ reason: ("replies_off" | "mentioner_unavailable" | "delegate_unknown" | "subject_unreadable" | "rate_capped" | "turn_failed") | null;
7285
+ replyCommentId: string | null;
7286
+ }[];
7181
7287
  createdByUserId: string | null;
7182
7288
  resolvedByUserId: string | null;
7183
7289
  resolvedAt: string | null;
@@ -24,6 +24,13 @@ Shared types for chat persistence, sharing, real-time events, and runtime profil
24
24
  - Page context carries NO ORG IDENTITY, though the app's org routes have one in the URL. Org scope is derived from the session; a request-supplied org identifier would be an authority claim in a field the server otherwise reads for itself
25
25
  - `compactedThroughSequence` on the chat detail is ABSENT or NULL exactly when the conversation has never been compacted. It is never coerced to `0` — sequence numbers are zero-indexed, so `0` is a boundary a chat can really have, and a zero default would make "never compacted" indistinguishable from "compacted through sequence 0". It is optional for the same reason `proactiveKind` is a bare string: a client that does not know the field renders the transcript exactly as before (ADR-CONTRACTS-154)
26
26
  - `compactedThroughSequence` is a BOUNDARY MARKER, not a context gauge. It is the one sequence number the summary covers through — never a token count, never a percentage of the context window, and never the summary text itself. The reader's question it answers is "why did it forget the beginning", not "how full is the window" (ADR-CONTRACTS-154)
27
+ - `mentions` on a chat message is REQUIRED, and an empty array is the common value. Absent is not the same as empty — this shipped in a MAJOR precisely so no consumer keeps a second opinion about which one it holds. Contrast the proactive wire fields above, which are optional so consumer object literals survive a minor (ADR-CONTRACTS-160)
28
+ - A mention TARGET is a `PrincipalRef`, never a user id, so the agent — which has no users row to point at — is a legal mention target on the chat surface exactly as on a comment (ADR-CONTRACTS-160)
29
+ - Chat mention offsets are ZERO-BASED UTF-16 CODE UNITS into the exact `content` string with `endOffset` EXCLUSIVE, and no Unicode normalization happens anywhere on the path — the same convention as `CommentMention`, because a mention means the same thing on both surfaces. An astral character counts as TWO units
30
+ - Chat mention offsets are NULLABLE and null means "mentioned somewhere in this turn, highlight nothing", never an error. A user turn carries a real range; an assistant turn's mention is a typed part with no character range, and the nullability is what lets that land with no second schema
31
+ - Identity on a chat mention renders from the mention ROW, never from the content characters under the range. A stale range can only mis-highlight; it can never misattribute
32
+ - `SharedChatMessage` deliberately has NO mentions. The token-only share viewer is granted the CONTENT of a shared chat and nothing else; a mention row is identity, and identity is not in that grant
33
+ - `ChatMentionableResponse` carries `MentionableCandidate` items imported from `comments`, not a chat-flavoured copy. A candidate is a principal, a display name and an avatar on both surfaces — and on both, deliberately no email and no access level, because a picker that returned the band each candidate holds would be a way to enumerate who can do what
27
34
  - `CHAT_PAGE_VIEWS` and `CHAT_PAGE_SCOPES` are a projection of the app's `Route` union (`company-semantics-app/src/platform/route-parser.ts`), not a parallel vocabulary. A view the parser can emit but the schema rejects fails the send outright rather than degrading
28
35
 
29
36
  <!-- BEGIN GENERATED: readme-public-api — derived from code by `pnpm readme-api`. Do not edit. -->
@@ -53,7 +60,11 @@ Shared types for chat persistence, sharing, real-time events, and runtime profil
53
60
  - `ChatListFilters` _(type)_ — Filters for listing chats.
54
61
  - `ChatListResponse` _(type)_ — Response for GET /api/chats
55
62
  - `ChatListResponseSchema` — Response for GET /api/chats.
63
+ - `ChatMentionableResponse` _(type)_ — Response for the chat composer's mentionable-candidate endpoint.
64
+ - `ChatMentionableResponseSchema` — Who the chat composer may offer as a mention target.
56
65
  - `ChatMessage` _(type)_ — Individual message in a chat response (GET /api/chats/:id).
66
+ - `ChatMessageMention` _(type)_ — One mention on a chat turn — a `PrincipalRef` target, a nullable UTF-16 code-unit range into `content`, and a…
67
+ - `ChatMessageMentionSchema` — ONE mention on a chat turn — field-for-field `CommentMentionSchema` (`../comments/schemas`), and deliberately…
57
68
  - `ChatMessageSchema` — Individual message in a chat, as returned by GET /api/chats/:id.
58
69
  - `ChatOrigin` _(type)_ — How a chat was born: `user` or `proactive` (the system spoke first).
59
70
  - `ChatOriginSchema` — How a chat was born: by the user, or by the system speaking first (a proactive occurrence projected onto the…
@@ -117,6 +128,8 @@ Shared types for chat persistence, sharing, real-time events, and runtime profil
117
128
  **Internal domains:**
118
129
 
119
130
  - `api`
131
+ - `comments`
132
+ - `identity`
120
133
  - `proactive`
121
134
 
122
135
  **External packages:**
@@ -0,0 +1,203 @@
1
+ /**
2
+ * Mentions on a chat turn (ADR-CONTRACTS-160).
3
+ *
4
+ * INVARIANTS TESTED:
5
+ * - `mentions` is REQUIRED on a chat message. Absent is not the same as empty:
6
+ * an empty array says "this turn named nobody", a missing key says nothing at
7
+ * all, and the whole point of shipping it in a MAJOR is that no consumer gets
8
+ * to keep a second opinion about which one it is holding.
9
+ * - A mention TARGET is a `PrincipalRef`, so the agent — which has no users row
10
+ * to point at — is a legal target on the chat surface exactly as on a comment.
11
+ * A bare user id is not a target, and never was: that is the shape this
12
+ * vocabulary replaced.
13
+ * - OFFSETS ARE NULLABLE and null is not an error. A user turn carries a real
14
+ * range because the composer measured it; an assistant turn's mention (the
15
+ * later multi-party phase) is a typed part with no character range at all.
16
+ * The nullability is what lets that land with no second schema.
17
+ * - OFFSETS ARE UTF-16 CODE UNITS into the exact `content` string, `endOffset`
18
+ * exclusive. An astral character counts as TWO. Asserted against a real slice
19
+ * rather than stated, because a client measuring code POINTS produces ranges
20
+ * that look right until an emoji precedes the mention.
21
+ * - `SharedChatMessageSchema` did NOT gain mentions. The token-only share viewer
22
+ * renders content, not identity chips, and a mention row is identity a
23
+ * tokenholder was never granted.
24
+ * - `ChatMentionableResponseSchema` carries `MentionableCandidateSchema` items —
25
+ * the comments shape imported, not a chat-flavoured copy of it.
26
+ */
27
+ import { describe, expect, it } from "vitest";
28
+ import { AGENT_PRINCIPAL, CS_AGENT, userPrincipal } from "../../identity/agent";
29
+ import {
30
+ ChatMentionableResponseSchema,
31
+ ChatMessageMentionSchema,
32
+ ChatMessageSchema,
33
+ GetChatResponseSchema,
34
+ SharedChatMessageSchema,
35
+ } from "../schemas.js";
36
+
37
+ // ---------------------------------------------------------------------------
38
+ // Fixtures — one well-formed value per shape; every negative case spreads it
39
+ // and changes exactly ONE field, so a failure cannot mean something else.
40
+ // ---------------------------------------------------------------------------
41
+
42
+ const makeMention = (over: Record<string, unknown> = {}) => ({
43
+ target: userPrincipal("user_1"),
44
+ startOffset: 0,
45
+ endOffset: 5,
46
+ displayName: "Ada",
47
+ ...over,
48
+ });
49
+
50
+ const makeMessage = (over: Record<string, unknown> = {}) => ({
51
+ id: "msg_1",
52
+ role: "user" as const,
53
+ content: "Hello",
54
+ sequenceNumber: 0,
55
+ createdAt: "2026-09-02T00:00:00.000Z",
56
+ mentions: [],
57
+ ...over,
58
+ });
59
+
60
+ describe("ChatMessageSchema mentions", () => {
61
+ it("a chat message requires a mentions array", () => {
62
+ const { mentions: _omitted, ...withoutMentions } = makeMessage();
63
+
64
+ const r = ChatMessageSchema.safeParse(withoutMentions);
65
+ expect(r.success).toBe(false);
66
+ if (!r.success) expect(r.error.issues[0].path).toEqual(["mentions"]);
67
+ });
68
+
69
+ it("an empty mentions array is the common value, not an absence", () => {
70
+ const parsed = ChatMessageSchema.parse(makeMessage());
71
+ expect(parsed.mentions).toEqual([]);
72
+ });
73
+
74
+ it("carries the mention rows a turn named", () => {
75
+ const parsed = ChatMessageSchema.parse(
76
+ makeMessage({ mentions: [makeMention()] }),
77
+ );
78
+
79
+ expect(parsed.mentions).toHaveLength(1);
80
+ expect(parsed.mentions[0].target).toEqual({
81
+ kind: "user",
82
+ userId: "user_1",
83
+ });
84
+ });
85
+
86
+ it("rides the transcript in GET /api/chats/:id", () => {
87
+ const parsed = GetChatResponseSchema.parse({
88
+ chat: {
89
+ id: "chat_1",
90
+ title: "A conversation",
91
+ interactionId: "int_1",
92
+ createdAt: "2026-09-02T00:00:00.000Z",
93
+ updatedAt: "2026-09-02T01:00:00.000Z",
94
+ },
95
+ messages: [makeMessage({ mentions: [makeMention()] })],
96
+ });
97
+
98
+ expect(parsed.messages[0].mentions).toHaveLength(1);
99
+ });
100
+
101
+ it("the share viewer's message shape did not gain mentions", () => {
102
+ // A token holder is granted the CONTENT of a shared chat and nothing else.
103
+ // Mention rows are identity, and identity is not in the grant.
104
+ expect(Object.keys(SharedChatMessageSchema.shape)).not.toContain(
105
+ "mentions",
106
+ );
107
+ });
108
+ });
109
+
110
+ describe("ChatMessageMentionSchema", () => {
111
+ it("the agent is a legal mention target and needs no user record", () => {
112
+ const parsed = ChatMessageMentionSchema.parse(
113
+ makeMention({
114
+ target: AGENT_PRINCIPAL,
115
+ displayName: CS_AGENT.displayName,
116
+ }),
117
+ );
118
+
119
+ expect(parsed.target).toEqual({ kind: "agent" });
120
+ expect(parsed.displayName).toBe("c_S");
121
+ });
122
+
123
+ it("a bare user id is not a target", () => {
124
+ // The shape this vocabulary replaced. A string where a ref belongs must
125
+ // refuse, or the agent silently becomes unnameable again.
126
+ expect(
127
+ ChatMessageMentionSchema.safeParse(makeMention({ target: "user_1" }))
128
+ .success,
129
+ ).toBe(false);
130
+ });
131
+
132
+ it("null offsets mean highlight nothing, and are never an error", () => {
133
+ // The assistant-turn case: a typed part with no character range.
134
+ const parsed = ChatMessageMentionSchema.parse(
135
+ makeMention({ startOffset: null, endOffset: null }),
136
+ );
137
+
138
+ expect(parsed.startOffset).toBeNull();
139
+ expect(parsed.endOffset).toBeNull();
140
+ });
141
+
142
+ it("offsets are UTF-16 code units, with endOffset exclusive", () => {
143
+ // The emoji is TWO code units, so the mention starts at 6. A client
144
+ // measuring code POINTS (`[...content].indexOf`) would say 5 and slice one
145
+ // unit early — one unit per preceding astral character.
146
+ const content = "hi 🎉 @Ada";
147
+ const startOffset = content.indexOf("@Ada");
148
+ const mention = ChatMessageMentionSchema.parse(
149
+ makeMention({ startOffset, endOffset: startOffset + "@Ada".length }),
150
+ );
151
+
152
+ expect(startOffset).toBe(6);
153
+ expect([...content].indexOf("@")).toBe(5);
154
+ expect(content.slice(mention.startOffset!, mention.endOffset!)).toBe(
155
+ "@Ada",
156
+ );
157
+ });
158
+
159
+ it("rejects a negative startOffset", () => {
160
+ expect(
161
+ ChatMessageMentionSchema.safeParse(makeMention({ startOffset: -1 }))
162
+ .success,
163
+ ).toBe(false);
164
+ });
165
+ });
166
+
167
+ describe("ChatMentionableResponseSchema", () => {
168
+ it("offers the agent alongside people", () => {
169
+ const parsed = ChatMentionableResponseSchema.parse({
170
+ items: [
171
+ {
172
+ principal: AGENT_PRINCIPAL,
173
+ displayName: CS_AGENT.displayName,
174
+ avatarUrl: null,
175
+ },
176
+ {
177
+ principal: userPrincipal("user_1"),
178
+ displayName: "Ada",
179
+ avatarUrl: null,
180
+ },
181
+ ],
182
+ });
183
+
184
+ expect(parsed.items).toHaveLength(2);
185
+ expect(parsed.items[0].principal).toEqual({ kind: "agent" });
186
+ });
187
+
188
+ it("an empty candidate list parses", () => {
189
+ expect(ChatMentionableResponseSchema.parse({ items: [] }).items).toEqual(
190
+ [],
191
+ );
192
+ });
193
+
194
+ it("carries the comments candidate shape, not a chat copy of it", () => {
195
+ // No email and no access level: this is a picker, not an
196
+ // access-inspection surface.
197
+ const r = ChatMentionableResponseSchema.safeParse({
198
+ items: [{ userId: "user_1", displayName: "Ada", avatarUrl: null }],
199
+ });
200
+
201
+ expect(r.success).toBe(false);
202
+ });
203
+ });
package/src/chat/index.ts CHANGED
@@ -50,6 +50,10 @@ export type {
50
50
  ListSharesResponse,
51
51
  UpdateShareResponse,
52
52
  ChatMessage,
53
+ // Mentions on a chat turn (ADR-CONTRACTS-160): a PrincipalRef target, so the
54
+ // agent is a legal mention target on the chat surface too.
55
+ ChatMessageMention,
56
+ ChatMentionableResponse,
53
57
  ChatDetail,
54
58
  GetChatResponse,
55
59
  // Page context (request-side): where the reader is, as identifiers
@@ -91,6 +95,8 @@ export {
91
95
  ListSharesResponseSchema,
92
96
  UpdateShareResponseSchema,
93
97
  ChatMessageSchema,
98
+ ChatMessageMentionSchema,
99
+ ChatMentionableResponseSchema,
94
100
  ChatDetailSchema,
95
101
  GetChatResponseSchema,
96
102
  // Page context (request-side) — the schema plus the closed vocabularies it
@@ -1,5 +1,7 @@
1
1
  import { z } from "zod";
2
2
  import { IsoDateTime } from "../api/primitives";
3
+ import { MentionableCandidateSchema } from "../comments/schemas";
4
+ import { PrincipalRefSchema } from "../identity/agent";
3
5
 
4
6
  // =============================================================================
5
7
  // Enums
@@ -232,7 +234,65 @@ export const ChatSseEventSchema = z.discriminatedUnion("type", [
232
234
  // Message Types
233
235
  // =============================================================================
234
236
 
235
- /** Individual message in a chat, as returned by GET /api/chats/:id. */
237
+ /**
238
+ * ONE mention on a chat turn — field-for-field `CommentMentionSchema`
239
+ * (`../comments/schemas`), and deliberately the same shape rather than a
240
+ * chat-flavoured variant: a mention means the same thing on both surfaces, and
241
+ * two descriptions of it would drift.
242
+ *
243
+ * The comments conventions carry over WHOLE and are restated here because a
244
+ * reader of this file will not necessarily open that one:
245
+ *
246
+ * - OFFSETS ARE ZERO-BASED UTF-16 CODE UNITS INTO THE EXACT `content` STRING,
247
+ * `endOffset` EXCLUSIVE — plain JavaScript string coordinates, the same ones a
248
+ * `textarea`'s `selectionStart` reports. An astral character counts as TWO
249
+ * units, so a client measuring in code POINTS slides off the mention by one
250
+ * per preceding emoji.
251
+ * - NO UNICODE NORMALIZATION happens anywhere on this path. Normalising the
252
+ * content before measuring changes its length and desynchronises every offset
253
+ * after the first composed character.
254
+ * - IDENTITY RENDERS FROM THIS ROW, NEVER FROM THE CONTENT UNDER THE RANGE. A
255
+ * stale range can therefore only mis-HIGHLIGHT; it can never misattribute.
256
+ *
257
+ * OFFSETS ARE NULLABLE, and null means "mentioned somewhere in this turn,
258
+ * highlight nothing" — never an error. A USER turn always carries a real range,
259
+ * because the composer measured it. An ASSISTANT turn's mention arrives as a
260
+ * typed part with no character range at all (the later multi-party phase), and
261
+ * the nullability is what lets that land without a second schema.
262
+ *
263
+ * `target` is a `PrincipalRef`, so the agent — which has no users row — is a
264
+ * legal mention target here exactly as it is on a comment (ADR-CONTRACTS-160).
265
+ * `displayName` is resolved AT READ TIME and never frozen onto the row: a rename
266
+ * re-renders with no backfill.
267
+ */
268
+ export const ChatMessageMentionSchema = z.object({
269
+ target: PrincipalRefSchema,
270
+ startOffset: z.number().int().min(0).nullable(),
271
+ endOffset: z.number().int().min(0).nullable(),
272
+ displayName: z.string(),
273
+ });
274
+
275
+ /**
276
+ * Individual message in a chat, as returned by GET /api/chats/:id.
277
+ *
278
+ * `mentions` is REQUIRED, not optional, and an empty array is the common value.
279
+ * This is the `CommentSchema` side of the choice rather than the
280
+ * `proactiveChatFields` side directly above: those are optional so consumer
281
+ * object literals keep compiling across a minor, whereas this ships in a MAJOR
282
+ * where the whole point is that no consumer keeps a second opinion about
283
+ * whether a turn has mentions. The server always projects the array; a missing
284
+ * one is a backend that has not adopted this release, and refusing it is the
285
+ * intended failure (ADR-CONTRACTS-160).
286
+ *
287
+ * MIRRORED, WITH ONE DECLARED LEAD. `mentions` lands HERE FIRST by the
288
+ * contracts-first protocol of ADR-CONT-029: the schema ships, the backend route
289
+ * registry adopts it, then `openapi/backend.yaml` and the generated
290
+ * `components["schemas"]` view in `../api` catch up. Until then `../api` still
291
+ * describes a chat message without mentions, and NOTHING IN THIS REPO CAN SEE
292
+ * THE DIVERGENCE — the parity guard that would catch it compares the spec to the
293
+ * backend, never to this file. This paragraph is the whole record of it, and it
294
+ * comes out when the spec agrees.
295
+ */
236
296
  export const ChatMessageSchema = z.object({
237
297
  id: z.string(),
238
298
  role: z.enum(["user", "assistant"]),
@@ -240,6 +300,7 @@ export const ChatMessageSchema = z.object({
240
300
  parts: z.array(z.unknown()).optional(),
241
301
  sequenceNumber: z.number().int(),
242
302
  createdAt: IsoDateTime,
303
+ mentions: z.array(ChatMessageMentionSchema),
243
304
  });
244
305
 
245
306
  // =============================================================================
@@ -357,6 +418,27 @@ export const GetChatResponseSchema = z.object({
357
418
  messages: z.array(ChatMessageSchema),
358
419
  });
359
420
 
421
+ /**
422
+ * Who the chat composer may offer as a mention target.
423
+ *
424
+ * REUSES `MentionableCandidateSchema` from `../comments/schemas` rather than
425
+ * declaring a chat-shaped twin. A candidate is a principal, a display name and
426
+ * an avatar on both surfaces, and the reasons that shape carries no email and no
427
+ * access level hold here identically: it is a picker, not an
428
+ * access-inspection surface, so returning the band each candidate holds would
429
+ * turn an autocomplete into a way to enumerate who can do what.
430
+ *
431
+ * The ENVELOPE is separate from `MentionableResponseSchema` only because the two
432
+ * endpoints are separate and may diverge in what rides beside `items` — the item
433
+ * shape itself is one definition, imported, never copied.
434
+ *
435
+ * WHO IS IN THE SET is the server's decision and a client cannot widen it: the
436
+ * candidates are the chat's participants plus the agent, derived server-side.
437
+ */
438
+ export const ChatMentionableResponseSchema = z.object({
439
+ items: z.array(MentionableCandidateSchema),
440
+ });
441
+
360
442
  // =============================================================================
361
443
  // Page Context (request-side: where the reader is, as identifiers)
362
444
  // =============================================================================
package/src/chat/types.ts CHANGED
@@ -37,6 +37,8 @@ import {
37
37
  ListSharesResponseSchema,
38
38
  UpdateShareResponseSchema,
39
39
  ChatMessageSchema,
40
+ ChatMessageMentionSchema,
41
+ ChatMentionableResponseSchema,
40
42
  ChatDetailSchema,
41
43
  GetChatResponseSchema,
42
44
  ChatPageContextSchema,
@@ -291,6 +293,17 @@ export type UpdateShareResponse = z.infer<typeof UpdateShareResponseSchema>;
291
293
  /** Individual message in a chat response (GET /api/chats/:id). */
292
294
  export type ChatMessage = z.infer<typeof ChatMessageSchema>;
293
295
 
296
+ /**
297
+ * One mention on a chat turn — a `PrincipalRef` target, a nullable UTF-16
298
+ * code-unit range into `content`, and a read-time display name.
299
+ */
300
+ export type ChatMessageMention = z.infer<typeof ChatMessageMentionSchema>;
301
+
302
+ /** Response for the chat composer's mentionable-candidate endpoint. */
303
+ export type ChatMentionableResponse = z.infer<
304
+ typeof ChatMentionableResponseSchema
305
+ >;
306
+
294
307
  /** Chat detail object in GET /api/chats/:id — includes interactionId for append-on-edit flows. */
295
308
  export type ChatDetail = z.infer<typeof ChatDetailSchema>;
296
309