@company-semantics/contracts 62.10.0 → 63.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 +1 -1
- package/src/api/generated-spec-hash.ts +2 -2
- package/src/api/generated.ts +20 -5
- 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,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 = 'e90aa233fbf5' as const;
|
|
3
|
+
export const SPEC_HASH_FULL = 'e90aa233fbf5c821f7a67d53e8da52152166bf1eefb029e1999fde02e8e66a4f' as const;
|
package/src/api/generated.ts
CHANGED
|
@@ -2714,8 +2714,8 @@ export interface paths {
|
|
|
2714
2714
|
get?: never;
|
|
2715
2715
|
put?: never;
|
|
2716
2716
|
/**
|
|
2717
|
-
* Upload an org-chart artifact (PDF
|
|
2718
|
-
* @description Accepts
|
|
2717
|
+
* Upload an org-chart artifact (text, office documents, PDF, images, diagrams) for ingestion
|
|
2718
|
+
* @description Accepts one multipart file admitted by the org-chart ingestion surface (contracts INGESTION_SURFACES: text, document, image and diagram families; 10 MiB ceiling). With `?dryRun=true` the operation only reaches the validated state and writes no live rows. Returns 202 with the operationId so the caller can poll.
|
|
2719
2719
|
*/
|
|
2720
2720
|
post: operations["importOrgChart"];
|
|
2721
2721
|
delete?: never;
|
|
@@ -2826,7 +2826,7 @@ export interface paths {
|
|
|
2826
2826
|
path?: never;
|
|
2827
2827
|
cookie?: never;
|
|
2828
2828
|
};
|
|
2829
|
-
/** Get extracted text content from a Google Drive file */
|
|
2829
|
+
/** Get extracted text content from a Google Drive file (fetched in a registry format and parsed like an upload) */
|
|
2830
2830
|
get: operations["getDriveFileContent"];
|
|
2831
2831
|
put?: never;
|
|
2832
2832
|
post?: never;
|
|
@@ -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: {
|
|
@@ -4762,6 +4775,8 @@ export interface components {
|
|
|
4762
4775
|
destinationDocId: string;
|
|
4763
4776
|
title: string;
|
|
4764
4777
|
rawText: string;
|
|
4778
|
+
/** Format: uuid */
|
|
4779
|
+
sourceOperationId?: string;
|
|
4765
4780
|
chatId: string;
|
|
4766
4781
|
};
|
|
4767
4782
|
RoleCatalogResponse: {
|
|
@@ -11686,14 +11701,14 @@ export interface operations {
|
|
|
11686
11701
|
};
|
|
11687
11702
|
content?: never;
|
|
11688
11703
|
};
|
|
11689
|
-
/** @description Upload exceeds the 10
|
|
11704
|
+
/** @description Upload exceeds the surface ceiling (10 MiB) or the format cap */
|
|
11690
11705
|
413: {
|
|
11691
11706
|
headers: {
|
|
11692
11707
|
[name: string]: unknown;
|
|
11693
11708
|
};
|
|
11694
11709
|
content?: never;
|
|
11695
11710
|
};
|
|
11696
|
-
/** @description
|
|
11711
|
+
/** @description Bytes match no registered format, or a format the org-chart surface does not admit */
|
|
11697
11712
|
415: {
|
|
11698
11713
|
headers: {
|
|
11699
11714
|
[name: string]: unknown;
|
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
|
|
package/src/comments/README.md
CHANGED
|
@@ -181,8 +181,39 @@ it stays inside the vocabulary-guard's rules — request bodies still do not.
|
|
|
181
181
|
Offsets are nullable — treat null as "highlight nothing", not as an error.
|
|
182
182
|
Mentions written since ADR-BE-522 carry a real range; older rows carry null
|
|
183
183
|
permanently, since recovering one would mean searching the body for a name.
|
|
184
|
+
- **EVERY ACTOR HERE IS A `PrincipalRef`, NOT A USER ID** (ADR-CONTRACTS-160). A
|
|
185
|
+
comment's `author`, a mention's `target` and a picker candidate's `principal`
|
|
186
|
+
are all `{kind:'user', userId} | {kind:'agent'}`, because the agent is a legal
|
|
187
|
+
author and a legal mention target and has no users row to be keyed by — a
|
|
188
|
+
surface keyed on ids could only name people, which is why the mark used to be
|
|
189
|
+
re-derived from the body characters under the range, the exact
|
|
190
|
+
identity-from-body inversion this domain exists to prevent. `author` is
|
|
191
|
+
nullable for ONE reason only, a USER author whose record is gone; an agent
|
|
192
|
+
author is never null, so reading null as "the agent wrote this" attributes
|
|
193
|
+
every anonymous comment to it. No legacy id survives beside a ref — pre-launch,
|
|
194
|
+
an optional or duplicated field is how one consumer quietly keeps a second
|
|
195
|
+
opinion.
|
|
196
|
+
- **`origin` says what ACT made a thread; `createdByUserId` says who.** They are
|
|
197
|
+
not the same question, and only `origin` distinguishes a thread somebody wrote
|
|
198
|
+
from one a mention in the DOCUMENT BODY brought into being — a body-born thread
|
|
199
|
+
shown as an ordinary comment tells a reader that someone said something they
|
|
200
|
+
never said. `introducedAt` on the `doc_mention` arm is when the mention
|
|
201
|
+
appeared in the body and is deliberately NOT the thread's `createdAt`: the
|
|
202
|
+
thread is materialised when the mention is noticed, which can be after replies
|
|
203
|
+
have already answered it.
|
|
204
|
+
- **`pending` COMES FROM THE DURABLE TRIGGER ROW, and `unanswered` is
|
|
205
|
+
system-owned.** Every mention of the agent leaves exactly one `AgentOutcome`,
|
|
206
|
+
because a mention that simply goes quiet reads as "still thinking" forever.
|
|
207
|
+
Deriving `pending` from worker state or an SSE stream is wrong in both
|
|
208
|
+
directions — a reader who connects after the turn began sees nothing, and a
|
|
209
|
+
reader watching for a reply that a dead worker will never send spins
|
|
210
|
+
indefinitely. `reason` is published as VOCABULARY only; the app owns the
|
|
211
|
+
sentence for each one, so wording changes need no release here. As with the
|
|
212
|
+
redaction rule, "`reason` non-null iff terminal-unanswered" and
|
|
213
|
+
"`replyCommentId` non-null iff `replied`" are documented and writer-enforced
|
|
214
|
+
rather than expressed as Zod refinements.
|
|
184
215
|
- **These shapes are MIRRORED from the backend route boundary, not authored
|
|
185
|
-
here.** Every bound matches
|
|
216
|
+
here — with ONE declared lead.** Every bound matches
|
|
186
217
|
`company-semantics-backend/src/api/http/routes/comments/comments.schemas.ts`,
|
|
187
218
|
and therefore matches the generated `components["schemas"]` view in `../api`.
|
|
188
219
|
The accept/reject corpus in `__tests__/fixtures/comment-anchors.json` is
|
|
@@ -192,7 +223,14 @@ it stays inside the vocabulary-guard's rules — request bodies still do not.
|
|
|
192
223
|
test run, so the corpus cannot be edited here quietly. A change made at the
|
|
193
224
|
boundary is NOT caught from this repo — nothing in a single-repo CI run can
|
|
194
225
|
see that file, and this package must not depend on the backend to look. See
|
|
195
|
-
ADR-CONTRACTS-116 for the split and for where the upstream gate belongs.
|
|
226
|
+
ADR-CONTRACTS-116 for the split and for where the upstream gate belongs. The
|
|
227
|
+
lead is the principal vocabulary above: `target`, `author`, `principal`,
|
|
228
|
+
`origin` and `agentOutcomes` land HERE FIRST by the contracts-first protocol of
|
|
229
|
+
ADR-CONT-029, so until the backend adopts them `../api` still carries the
|
|
230
|
+
superseded id-keyed fields. Nothing mechanical notices — the parity guard
|
|
231
|
+
compares the spec to the backend, never to this directory — so the divergence
|
|
232
|
+
is recorded in prose here and in `./schemas.ts`, and both notes come out when
|
|
233
|
+
the spec agrees.
|
|
196
234
|
- **Request bodies are NOT here** (ADR-CONT-029), and one of them must never
|
|
197
235
|
be: the mention set on a write is validated against a candidate list the
|
|
198
236
|
server derives from `effective_acl_grants`, so nothing a client sends may
|
|
@@ -209,12 +247,15 @@ it stays inside the vocabulary-guard's rules — request bodies still do not.
|
|
|
209
247
|
| `CommentThreadStatusSchema` | Zod mirror of `COMMENT_THREAD_STATUSES` |
|
|
210
248
|
| `COMMENT_SUBJECT_TYPES` | What a thread can hang off — closed, sized to what admits comments |
|
|
211
249
|
| `CommentSubjectTypeSchema` | Zod mirror of `COMMENT_SUBJECT_TYPES` |
|
|
212
|
-
| `CommentMentionSchema` | One mention:
|
|
250
|
+
| `CommentMentionSchema` | One mention: `PrincipalRef` target, nullable UTF-16 range, display name |
|
|
213
251
|
| `CommentSchema` | One comment; `body` null iff soft-deleted (infers `CommentProjection`) |
|
|
252
|
+
| `ThreadOriginSchema` | `{comment}` \| `{doc_mention, byUser, introducedAt}` — what ACT made a thread |
|
|
253
|
+
| `AGENT_OUTCOME_REASONS` | Why the agent will not reply — vocabulary only; the app owns the sentence |
|
|
254
|
+
| `AgentOutcomeSchema` | One durable outcome per trigger; `pending` comes from the trigger row |
|
|
214
255
|
| `CommentThreadSummarySchema` | A thread without its comments — the resolve/reopen response |
|
|
215
256
|
| `CommentThreadSchema` | A thread with all its comments, oldest-first, tombstones included |
|
|
216
257
|
| `CommentThreadListResponseSchema` | `GET /api/comments` |
|
|
217
|
-
| `MentionableCandidateSchema` | One offerable mention target — no email, no access level
|
|
258
|
+
| `MentionableCandidateSchema` | One offerable mention target — a `PrincipalRef`; no email, no access level |
|
|
218
259
|
| `MentionableResponseSchema` | `GET /api/company-md/docs/{id}/mentionable` |
|
|
219
260
|
| `COMMENT_THREAD_KINDS` | `comment` \| `suggestion` — a suggestion IS a comment thread |
|
|
220
261
|
| `SUGGESTION_OPS` | `insert` \| `delete` \| `replace` — what a suggestion proposes |
|
|
@@ -262,7 +303,14 @@ it stays inside the vocabulary-guard's rules — request bodies still do not.
|
|
|
262
303
|
projection from it without a cycle. `./receipt` is a leaf for the same
|
|
263
304
|
reason — zod only, no reach for the anchor or the ladder — so anything in this
|
|
264
305
|
directory can import it later without one.
|
|
265
|
-
-
|
|
306
|
+
- `../identity/agent` — `PrincipalRefSchema`, imported at RUNTIME by
|
|
307
|
+
`./schemas`. This directory once imported nothing outside itself; it now
|
|
308
|
+
imports exactly one thing, and deliberately. A locally-declared user-or-agent
|
|
309
|
+
union would be a second description of who the agent is, in the one package
|
|
310
|
+
whose job is to have exactly one — and `../identity/agent` is a zod-only leaf,
|
|
311
|
+
so the dependency adds no reach. The arrow runs comments → identity and never
|
|
312
|
+
back.
|
|
313
|
+
- Nothing else outside this directory is imported: the
|
|
266
314
|
comment vocabulary binds to the existing `commenter` band of
|
|
267
315
|
`../permissions`'s `AccessLevel` and introduces no access level of its own, so
|
|
268
316
|
it needs no import to say so.
|
|
@@ -79,6 +79,13 @@ deleteLength: 0, insert: "" }` is a valid mutation that applies without error
|
|
|
79
79
|
- **Negative tests mutate ONE field of a well-formed factory result.** A
|
|
80
80
|
hand-built broken object can pass for the wrong reason: it fails because of
|
|
81
81
|
the field nobody was testing.
|
|
82
|
+
- **A field's ABSENCE is pinned by asserting the whole key set, not by hoping.**
|
|
83
|
+
`Object.keys(...).sort()` on `CommentSchema` and on the candidate element is
|
|
84
|
+
what makes re-adding a legacy id field beside its `PrincipalRef` a red test.
|
|
85
|
+
A parse-based test cannot do this job: these shapes
|
|
86
|
+
are non-strict, so an extra key is accepted silently, and a legacy id kept
|
|
87
|
+
beside the ref is exactly how one consumer would quietly keep a second opinion
|
|
88
|
+
about who wrote a comment.
|
|
82
89
|
|
|
83
90
|
## Public API
|
|
84
91
|
|