@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 +2 -2
- package/src/api/generated-spec-hash.ts +2 -2
- package/src/api/generated.ts +116 -10
- package/src/chat/README.md +13 -0
- package/src/chat/__tests__/message-mentions.test.ts +203 -0
- package/src/chat/index.ts +6 -0
- package/src/chat/schemas.ts +83 -1
- package/src/chat/types.ts +13 -0
- package/src/comments/README.md +53 -5
- package/src/comments/__tests__/README.md +7 -0
- package/src/comments/__tests__/schemas.test.ts +174 -5
- package/src/comments/index.ts +5 -0
- package/src/comments/schemas.ts +124 -18
- package/src/identity/README.md +8 -0
- package/src/identity/__tests__/agent.test.ts +95 -0
- package/src/identity/agent.ts +79 -0
- package/src/identity/index.ts +14 -0
- package/src/index.ts +29 -0
- package/src/notifications/renderers/ascii/README.md +6 -0
- package/src/notifications/renderers/ascii/geometry.ts +11 -2
- package/src/notifications/renderers/email/chat.ts +4 -3
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@company-semantics/contracts",
|
|
3
|
-
"version": "
|
|
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": "
|
|
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 = '
|
|
3
|
-
export const SPEC_HASH_FULL = '
|
|
2
|
+
export const SPEC_HASH = '387045558d18' as const;
|
|
3
|
+
export const SPEC_HASH_FULL = '387045558d1867722a744ddb05cf5ebb7260c54df0ff272b868bd4dd2ad2a973' as const;
|
package/src/api/generated.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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;
|
package/src/chat/README.md
CHANGED
|
@@ -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
|
package/src/chat/schemas.ts
CHANGED
|
@@ -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
|
-
/**
|
|
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
|
|