@company-semantics/contracts 62.11.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 +13 -0
- 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
|
@@ -10,6 +10,7 @@
|
|
|
10
10
|
import { describe, expect, it } from "vitest";
|
|
11
11
|
|
|
12
12
|
import {
|
|
13
|
+
AGENT_OUTCOME_REASONS,
|
|
13
14
|
COMMENT_SUBJECT_TYPES,
|
|
14
15
|
COMMENT_THREAD_STATUSES,
|
|
15
16
|
CommentMentionSchema,
|
|
@@ -28,7 +29,7 @@ const DOC_ID = "44444444-4444-4444-8444-444444444444";
|
|
|
28
29
|
|
|
29
30
|
function makeMention(over: Record<string, unknown> = {}) {
|
|
30
31
|
return {
|
|
31
|
-
userId: USER_ID,
|
|
32
|
+
target: { kind: "user", userId: USER_ID },
|
|
32
33
|
startOffset: 0,
|
|
33
34
|
endOffset: 9,
|
|
34
35
|
displayName: "Sam Chen",
|
|
@@ -40,7 +41,7 @@ function makeComment(over: Record<string, unknown> = {}) {
|
|
|
40
41
|
return {
|
|
41
42
|
id: COMMENT_ID,
|
|
42
43
|
threadId: THREAD_ID,
|
|
43
|
-
|
|
44
|
+
author: { kind: "user", userId: USER_ID },
|
|
44
45
|
body: "Should this say Q3 or Q4?",
|
|
45
46
|
editedAt: null,
|
|
46
47
|
deletedAt: null,
|
|
@@ -66,6 +67,8 @@ function makeThread(over: Record<string, unknown> = {}) {
|
|
|
66
67
|
suffix: " by March",
|
|
67
68
|
},
|
|
68
69
|
status: "open",
|
|
70
|
+
origin: { kind: "comment" },
|
|
71
|
+
agentOutcomes: [],
|
|
69
72
|
createdByUserId: USER_ID,
|
|
70
73
|
resolvedByUserId: null,
|
|
71
74
|
resolvedAt: null,
|
|
@@ -149,9 +152,42 @@ describe("CommentSchema and the redaction invariant", () => {
|
|
|
149
152
|
});
|
|
150
153
|
|
|
151
154
|
it("parses an unattributed comment — a tombstoned author is not an error", () => {
|
|
155
|
+
expect(CommentSchema.safeParse(makeComment({ author: null })).success).toBe(
|
|
156
|
+
true,
|
|
157
|
+
);
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
it("parses an agent-authored comment, and the agent ref carries no id", () => {
|
|
161
|
+
// The whole point of the ref: the agent has no users row, so a comment it
|
|
162
|
+
// wrote could not previously be attributed at all. `{ kind: 'agent' }` is
|
|
163
|
+
// the ONLY agent author — a userId beside it is refused rather than
|
|
164
|
+
// stripped, because a ref that parsed cleanly while carrying an id would
|
|
165
|
+
// hand the app a second, contradictory identity for a singleton.
|
|
152
166
|
expect(
|
|
153
|
-
CommentSchema.safeParse(makeComment({
|
|
167
|
+
CommentSchema.safeParse(makeComment({ author: { kind: "agent" } }))
|
|
168
|
+
.success,
|
|
154
169
|
).toBe(true);
|
|
170
|
+
expect(
|
|
171
|
+
CommentSchema.safeParse(
|
|
172
|
+
makeComment({ author: { kind: "agent", userId: USER_ID } }),
|
|
173
|
+
).success,
|
|
174
|
+
).toBe(false);
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
it("carries the author ref and no legacy author id beside it", () => {
|
|
178
|
+
// A legacy id kept beside the ref is how one consumer quietly keeps a
|
|
179
|
+
// second opinion about who wrote a comment. Asserting the key set makes
|
|
180
|
+
// adding it back a red test rather than a silently tolerated extra.
|
|
181
|
+
expect(Object.keys(CommentSchema.shape).sort()).toEqual([
|
|
182
|
+
"author",
|
|
183
|
+
"body",
|
|
184
|
+
"createdAt",
|
|
185
|
+
"deletedAt",
|
|
186
|
+
"editedAt",
|
|
187
|
+
"id",
|
|
188
|
+
"mentions",
|
|
189
|
+
"threadId",
|
|
190
|
+
]);
|
|
155
191
|
});
|
|
156
192
|
|
|
157
193
|
it("requires the mentions array — absent is not the same as empty", () => {
|
|
@@ -208,6 +244,20 @@ describe("CommentMentionSchema offsets", () => {
|
|
|
208
244
|
CommentMentionSchema.safeParse(makeMention({ endOffset: 2.5 })).success,
|
|
209
245
|
).toBe(false);
|
|
210
246
|
});
|
|
247
|
+
|
|
248
|
+
it("names the agent as a target, and rejects a bare user id", () => {
|
|
249
|
+
// The mark is a PRINCIPAL now, not a string. A bare id could never name the
|
|
250
|
+
// agent, which is why every surface used to re-derive it from the body
|
|
251
|
+
// characters under the range — the exact inversion this contract forbids.
|
|
252
|
+
expect(
|
|
253
|
+
CommentMentionSchema.safeParse(
|
|
254
|
+
makeMention({ target: { kind: "agent" }, displayName: "c_S" }),
|
|
255
|
+
).success,
|
|
256
|
+
).toBe(true);
|
|
257
|
+
expect(
|
|
258
|
+
CommentMentionSchema.safeParse(makeMention({ target: USER_ID })).success,
|
|
259
|
+
).toBe(false);
|
|
260
|
+
});
|
|
211
261
|
});
|
|
212
262
|
|
|
213
263
|
describe("CommentThreadSchema", () => {
|
|
@@ -358,6 +408,106 @@ describe("CommentThreadSummarySchema kind and suggestion", () => {
|
|
|
358
408
|
});
|
|
359
409
|
});
|
|
360
410
|
|
|
411
|
+
describe("CommentThreadSummarySchema origin and agentOutcomes", () => {
|
|
412
|
+
it("a thread summary requires origin and agentOutcomes", () => {
|
|
413
|
+
// REQUIRED, like `kind` and `capabilities`. Optional is how one consumer
|
|
414
|
+
// keeps a local derivation of what started a thread or whether the agent
|
|
415
|
+
// answered — and both of those are precisely what this vocabulary exists to
|
|
416
|
+
// stop being guessed. The backend projects `{kind:'comment'}` and `[]` onto
|
|
417
|
+
// every thread that predates them, so absence is malformed, not legacy.
|
|
418
|
+
const { origin: _noOrigin, ...withoutOrigin } = makeThread();
|
|
419
|
+
expect(CommentThreadSummarySchema.safeParse(withoutOrigin).success).toBe(
|
|
420
|
+
false,
|
|
421
|
+
);
|
|
422
|
+
const { agentOutcomes: _noOutcomes, ...withoutOutcomes } = makeThread();
|
|
423
|
+
expect(CommentThreadSummarySchema.safeParse(withoutOutcomes).success).toBe(
|
|
424
|
+
false,
|
|
425
|
+
);
|
|
426
|
+
});
|
|
427
|
+
|
|
428
|
+
it("parses a thread born from a mention in the document body", () => {
|
|
429
|
+
// The second origin arm, published ahead of the doc-body work so it needs
|
|
430
|
+
// no further release. `introducedAt` is when the mention appeared in the
|
|
431
|
+
// BODY and is deliberately not `createdAt`: the thread is materialised when
|
|
432
|
+
// the mention is noticed, which can be after replies already answered it.
|
|
433
|
+
const parsed = CommentThreadSummarySchema.parse(
|
|
434
|
+
makeThread({
|
|
435
|
+
origin: {
|
|
436
|
+
kind: "doc_mention",
|
|
437
|
+
byUser: { kind: "user", userId: USER_ID },
|
|
438
|
+
introducedAt: "2026-07-30T08:00:00.000Z",
|
|
439
|
+
},
|
|
440
|
+
}),
|
|
441
|
+
);
|
|
442
|
+
expect(parsed.origin.kind).toBe("doc_mention");
|
|
443
|
+
// A tombstoned introducer is not an error, same rule as a comment author.
|
|
444
|
+
expect(
|
|
445
|
+
CommentThreadSummarySchema.safeParse(
|
|
446
|
+
makeThread({
|
|
447
|
+
origin: {
|
|
448
|
+
kind: "doc_mention",
|
|
449
|
+
byUser: null,
|
|
450
|
+
introducedAt: "2026-07-30T08:00:00.000Z",
|
|
451
|
+
},
|
|
452
|
+
}),
|
|
453
|
+
).success,
|
|
454
|
+
).toBe(true);
|
|
455
|
+
});
|
|
456
|
+
|
|
457
|
+
it("rejects a doc_mention origin missing introducedAt", () => {
|
|
458
|
+
// The discriminant alone must not carry the arm: a `doc_mention` without a
|
|
459
|
+
// time is a genesis row a client can only place by falling back to
|
|
460
|
+
// `createdAt`, which is the wrong number.
|
|
461
|
+
expect(
|
|
462
|
+
CommentThreadSummarySchema.safeParse(
|
|
463
|
+
makeThread({ origin: { kind: "doc_mention", byUser: null } }),
|
|
464
|
+
).success,
|
|
465
|
+
).toBe(false);
|
|
466
|
+
});
|
|
467
|
+
|
|
468
|
+
it("carries a terminal unanswered outcome with a reason and no reply", () => {
|
|
469
|
+
// The marker that makes a trigger terminate VISIBLY. Without it a mention
|
|
470
|
+
// that will never be answered is indistinguishable from one still being
|
|
471
|
+
// worked, and the reader waits forever. `reason` is vocabulary only — the
|
|
472
|
+
// app owns the sentence, so wording changes need no release here.
|
|
473
|
+
expect([...AGENT_OUTCOME_REASONS]).toContain("delegate_unknown");
|
|
474
|
+
const parsed = CommentThreadSummarySchema.parse(
|
|
475
|
+
makeThread({
|
|
476
|
+
agentOutcomes: [
|
|
477
|
+
{
|
|
478
|
+
triggerId: "66666666-6666-4666-8666-666666666666",
|
|
479
|
+
status: "unanswered",
|
|
480
|
+
reason: "delegate_unknown",
|
|
481
|
+
replyCommentId: null,
|
|
482
|
+
},
|
|
483
|
+
],
|
|
484
|
+
}),
|
|
485
|
+
);
|
|
486
|
+
expect(parsed.agentOutcomes[0].status).toBe("unanswered");
|
|
487
|
+
});
|
|
488
|
+
|
|
489
|
+
it("rejects an outcome status or reason outside the published vocabulary", () => {
|
|
490
|
+
// Both enums are closed. A free string would let the backend invent a
|
|
491
|
+
// fourth state the app renders as nothing at all — silence again.
|
|
492
|
+
const outcome = {
|
|
493
|
+
triggerId: "66666666-6666-4666-8666-666666666666",
|
|
494
|
+
status: "pending",
|
|
495
|
+
reason: null,
|
|
496
|
+
replyCommentId: null,
|
|
497
|
+
};
|
|
498
|
+
expect(
|
|
499
|
+
CommentThreadSummarySchema.safeParse(
|
|
500
|
+
makeThread({ agentOutcomes: [{ ...outcome, status: "working" }] }),
|
|
501
|
+
).success,
|
|
502
|
+
).toBe(false);
|
|
503
|
+
expect(
|
|
504
|
+
CommentThreadSummarySchema.safeParse(
|
|
505
|
+
makeThread({ agentOutcomes: [{ ...outcome, reason: "gave_up" }] }),
|
|
506
|
+
).success,
|
|
507
|
+
).toBe(false);
|
|
508
|
+
});
|
|
509
|
+
});
|
|
510
|
+
|
|
361
511
|
describe("CommentThreadListResponseSchema", () => {
|
|
362
512
|
it("parses an empty subject", () => {
|
|
363
513
|
expect(
|
|
@@ -377,7 +527,25 @@ describe("MentionableResponseSchema", () => {
|
|
|
377
527
|
it("parses a candidate list", () => {
|
|
378
528
|
expect(
|
|
379
529
|
MentionableResponseSchema.safeParse({
|
|
380
|
-
items: [
|
|
530
|
+
items: [
|
|
531
|
+
{
|
|
532
|
+
principal: { kind: "user", userId: USER_ID },
|
|
533
|
+
displayName: "Sam Chen",
|
|
534
|
+
avatarUrl: null,
|
|
535
|
+
},
|
|
536
|
+
],
|
|
537
|
+
}).success,
|
|
538
|
+
).toBe(true);
|
|
539
|
+
});
|
|
540
|
+
|
|
541
|
+
it("offers the agent as a candidate — it has no users row to be keyed by", () => {
|
|
542
|
+
// A picker keyed on user ids could only ever offer people, which is why the
|
|
543
|
+
// agent had to be typed rather than searched for in the body text.
|
|
544
|
+
expect(
|
|
545
|
+
MentionableResponseSchema.safeParse({
|
|
546
|
+
items: [
|
|
547
|
+
{ principal: { kind: "agent" }, displayName: "c_S", avatarUrl: null },
|
|
548
|
+
],
|
|
381
549
|
}).success,
|
|
382
550
|
).toBe(true);
|
|
383
551
|
});
|
|
@@ -386,8 +554,9 @@ describe("MentionableResponseSchema", () => {
|
|
|
386
554
|
// The candidates ARE the document's readers. Returning the band each one
|
|
387
555
|
// holds would turn an autocomplete into a way to enumerate who can do what,
|
|
388
556
|
// so the field must stay absent from the shape rather than merely unset.
|
|
557
|
+
// The same assertion pins that `userId` did not survive beside `principal`.
|
|
389
558
|
expect(
|
|
390
559
|
Object.keys(MentionableResponseSchema.shape.items.element.shape).sort(),
|
|
391
|
-
).toEqual(["avatarUrl", "displayName", "
|
|
560
|
+
).toEqual(["avatarUrl", "displayName", "principal"]);
|
|
392
561
|
});
|
|
393
562
|
});
|
package/src/comments/index.ts
CHANGED
|
@@ -34,6 +34,8 @@ export {
|
|
|
34
34
|
export type { AnchorResolution, Occurrence } from "./resolve";
|
|
35
35
|
|
|
36
36
|
export {
|
|
37
|
+
AGENT_OUTCOME_REASONS,
|
|
38
|
+
AgentOutcomeSchema,
|
|
37
39
|
COMMENT_SUBJECT_TYPES,
|
|
38
40
|
COMMENT_THREAD_STATUSES,
|
|
39
41
|
CommentMentionSchema,
|
|
@@ -45,9 +47,11 @@ export {
|
|
|
45
47
|
CommentThreadSummarySchema,
|
|
46
48
|
MentionableCandidateSchema,
|
|
47
49
|
MentionableResponseSchema,
|
|
50
|
+
ThreadOriginSchema,
|
|
48
51
|
} from "./schemas";
|
|
49
52
|
|
|
50
53
|
export type {
|
|
54
|
+
AgentOutcome,
|
|
51
55
|
CommentMention,
|
|
52
56
|
CommentProjection,
|
|
53
57
|
CommentSubjectType,
|
|
@@ -57,6 +61,7 @@ export type {
|
|
|
57
61
|
CommentThreadSummary,
|
|
58
62
|
MentionableCandidate,
|
|
59
63
|
MentionableResponse,
|
|
64
|
+
ThreadOrigin,
|
|
60
65
|
} from "./schemas";
|
|
61
66
|
|
|
62
67
|
export {
|
package/src/comments/schemas.ts
CHANGED
|
@@ -9,15 +9,26 @@
|
|
|
9
9
|
*
|
|
10
10
|
* Zod-canonical: the schema is the source of truth, the type is inferred.
|
|
11
11
|
*
|
|
12
|
-
* MIRRORED,
|
|
12
|
+
* MIRRORED, WITH ONE DECLARED LEAD. Every field below matches the backend route
|
|
13
13
|
* boundary's response schemas (`src/api/http/routes/comments/comments.schemas.ts`
|
|
14
|
-
* and `.../company-md/mentionable.schemas.ts`) field-for-field
|
|
15
|
-
*
|
|
16
|
-
*
|
|
17
|
-
*
|
|
18
|
-
*
|
|
14
|
+
* and `.../company-md/mentionable.schemas.ts`) field-for-field — EXCEPT the
|
|
15
|
+
* principal vocabulary of ADR-CONTRACTS-160 (`target`, `author`, `principal`,
|
|
16
|
+
* `origin`, `agentOutcomes`), which lands HERE FIRST by the contracts-first
|
|
17
|
+
* protocol of ADR-CONT-029: the schema ships, the route registry adopts it,
|
|
18
|
+
* then `openapi/backend.yaml` and the generated `components["schemas"]` view in
|
|
19
|
+
* `../api` catch up. Until the backend side lands, `../api` still carries the
|
|
20
|
+
* superseded id-keyed fields, and NOTHING IN THIS REPO CAN SEE THE DIVERGENCE —
|
|
21
|
+
* the parity guard that would catch it compares the spec to the backend, never
|
|
22
|
+
* to this file. So this paragraph is the whole record of it, and it comes out
|
|
23
|
+
* when the spec agrees.
|
|
24
|
+
*
|
|
25
|
+
* Two descriptions of one wire shape that disagree is worse than one, so where
|
|
26
|
+
* the backend is looser than looks right — a principal's `userId` is
|
|
27
|
+
* `z.string()` and not a uuid, mention offsets are nullable — this mirrors the
|
|
28
|
+
* backend and says why.
|
|
19
29
|
*/
|
|
20
30
|
import { z } from "zod";
|
|
31
|
+
import { PrincipalRefSchema } from "../identity/agent";
|
|
21
32
|
import { CommentAnchorSchema, CommentAnchorTypeSchema } from "./anchor";
|
|
22
33
|
import { COMMENT_THREAD_KINDS, SuggestionSchema } from "./suggestion";
|
|
23
34
|
|
|
@@ -89,13 +100,16 @@ export type CommentSubjectType = z.infer<typeof CommentSubjectTypeSchema>;
|
|
|
89
100
|
* identity the reader can no longer resolve arrives as a neutral placeholder
|
|
90
101
|
* rather than failing the whole thread load.
|
|
91
102
|
*
|
|
92
|
-
* `
|
|
93
|
-
*
|
|
94
|
-
*
|
|
95
|
-
*
|
|
103
|
+
* `target` IS A PRINCIPAL, NOT A USER ID (ADR-CONTRACTS-160). The agent is a
|
|
104
|
+
* legal mention target and has no users row to point at, so a mention keyed on
|
|
105
|
+
* a bare id could not name it at all — which is how the mark ended up rendered
|
|
106
|
+
* from body characters everywhere it appeared. A user target's `userId` stays
|
|
107
|
+
* `z.string()` and not `.uuid()`, mirroring the backend boundary exactly:
|
|
108
|
+
* tightening it here would make this package reject responses the server
|
|
109
|
+
* considers valid, which is a client-side outage for no security gain.
|
|
96
110
|
*/
|
|
97
111
|
export const CommentMentionSchema = z.object({
|
|
98
|
-
|
|
112
|
+
target: PrincipalRefSchema,
|
|
99
113
|
startOffset: z.number().int().min(0).nullable(),
|
|
100
114
|
endOffset: z.number().int().min(0).nullable(),
|
|
101
115
|
displayName: z.string(),
|
|
@@ -126,8 +140,12 @@ export type CommentMention = z.infer<typeof CommentMentionSchema>;
|
|
|
126
140
|
* cannot check. `./__tests__/schemas.test.ts` pins that the redacted projection
|
|
127
141
|
* parses.
|
|
128
142
|
*
|
|
129
|
-
* `
|
|
130
|
-
* user record is gone
|
|
143
|
+
* `author` is a `PrincipalRef` and nullable, and null keeps EXACTLY ONE
|
|
144
|
+
* meaning: a USER author whose user record is gone, leaving the comment
|
|
145
|
+
* readable and unattributed. An agent author is `{ kind: "agent" }` and is
|
|
146
|
+
* NEVER null — the agent is a singleton with no record to lose
|
|
147
|
+
* (ADR-CONTRACTS-160), so a client that reads null as "the agent wrote this"
|
|
148
|
+
* attributes every tombstoned author's comment to the agent.
|
|
131
149
|
*
|
|
132
150
|
* The inferred type is `CommentProjection`, not `Comment`, for the reason
|
|
133
151
|
* `NotificationElement` is not `Element` (`../notifications/content`): `Comment`
|
|
@@ -138,7 +156,7 @@ export type CommentMention = z.infer<typeof CommentMentionSchema>;
|
|
|
138
156
|
export const CommentSchema = z.object({
|
|
139
157
|
id: z.string(),
|
|
140
158
|
threadId: z.string(),
|
|
141
|
-
|
|
159
|
+
author: PrincipalRefSchema.nullable(),
|
|
142
160
|
body: z.string().nullable(),
|
|
143
161
|
editedAt: z.string().nullable(),
|
|
144
162
|
deletedAt: z.string().nullable(),
|
|
@@ -151,6 +169,82 @@ export type CommentProjection = z.infer<typeof CommentSchema>;
|
|
|
151
169
|
// Threads
|
|
152
170
|
// =============================================================================
|
|
153
171
|
|
|
172
|
+
/**
|
|
173
|
+
* HOW A THREAD CAME TO EXIST — not WHO made it (`createdByUserId` answers that)
|
|
174
|
+
* but WHAT ACT did.
|
|
175
|
+
*
|
|
176
|
+
* Every thread in existence projects `{ kind: "comment" }`: somebody wrote a
|
|
177
|
+
* comment. The `doc_mention` arm is the thread that a mention in the DOCUMENT
|
|
178
|
+
* BODY brings into being, where nobody wrote a comment at all — so
|
|
179
|
+
* `createdByUserId` alone cannot tell the two apart, and a reader shown a
|
|
180
|
+
* body-born thread as an ordinary comment is being told someone said something
|
|
181
|
+
* they never said. Both arms publish NOW so the doc-body work emits the second
|
|
182
|
+
* one with no further release of this package.
|
|
183
|
+
*
|
|
184
|
+
* `byUser` is a `PrincipalRef` and nullable for the same tombstoning reason as
|
|
185
|
+
* `CommentSchema.author`. `introducedAt` is when the mention appeared in the
|
|
186
|
+
* BODY, which is NOT the thread's `createdAt`: the thread is materialised when
|
|
187
|
+
* the mention is noticed, which can be later, and ordering a genesis row by
|
|
188
|
+
* `createdAt` puts it after replies that answered it.
|
|
189
|
+
*
|
|
190
|
+
* NON-STRICT, like the thread shapes it sits inside and unlike
|
|
191
|
+
* `SuggestionSchema`: a suggestion is a negotiated payload that must grow by a
|
|
192
|
+
* new `v`, while this is a server projection that mirrors the backend's
|
|
193
|
+
* looseness rather than tightening past it.
|
|
194
|
+
*/
|
|
195
|
+
export const ThreadOriginSchema = z.discriminatedUnion("kind", [
|
|
196
|
+
z.object({ kind: z.literal("comment") }),
|
|
197
|
+
z.object({
|
|
198
|
+
kind: z.literal("doc_mention"),
|
|
199
|
+
byUser: PrincipalRefSchema.nullable(),
|
|
200
|
+
introducedAt: z.string(),
|
|
201
|
+
}),
|
|
202
|
+
]);
|
|
203
|
+
export type ThreadOrigin = z.infer<typeof ThreadOriginSchema>;
|
|
204
|
+
|
|
205
|
+
/** Why the agent will not reply. Vocabulary only — the app owns the sentence. */
|
|
206
|
+
export const AGENT_OUTCOME_REASONS = [
|
|
207
|
+
"replies_off",
|
|
208
|
+
"mentioner_unavailable",
|
|
209
|
+
"delegate_unknown",
|
|
210
|
+
"subject_unreadable",
|
|
211
|
+
"rate_capped",
|
|
212
|
+
"turn_failed",
|
|
213
|
+
] as const;
|
|
214
|
+
|
|
215
|
+
/**
|
|
216
|
+
* ONE DURABLE OUTCOME PER TRIGGER — what became of each mention of the agent on
|
|
217
|
+
* this thread. The array is empty on every thread that never named it.
|
|
218
|
+
*
|
|
219
|
+
* `pending` IS DERIVED FROM THE DURABLE TRIGGER ROW, never from worker state
|
|
220
|
+
* and never from an SSE stream. Both of those are the wrong source in opposite
|
|
221
|
+
* directions: a client inferring "still working" from a live stream shows
|
|
222
|
+
* nothing to a reader who connected after the turn began, and a client
|
|
223
|
+
* inferring it from the absence of a reply spins forever over a worker that
|
|
224
|
+
* died. The trigger row outlives both, which is why it is the only one.
|
|
225
|
+
*
|
|
226
|
+
* `unanswered` IS SYSTEM-OWNED AND TERMINAL: the agent will not reply and
|
|
227
|
+
* nothing further is queued. It exists so a mention cannot simply go quiet —
|
|
228
|
+
* silence reads as "still thinking" indefinitely, and the whole point of a
|
|
229
|
+
* durable outcome is that every trigger terminates visibly. `reason` says which
|
|
230
|
+
* way, and the APP owns the copy for each one: this package publishes the
|
|
231
|
+
* vocabulary, not the sentence, so wording changes need no release.
|
|
232
|
+
*
|
|
233
|
+
* `reason` is null EXACTLY when `status` is `pending` or `replied`, and
|
|
234
|
+
* `replyCommentId` is non-null EXACTLY when `status` is `replied`. Both rules
|
|
235
|
+
* are DOCUMENTED and writer-enforced rather than expressed as refinements, for
|
|
236
|
+
* the same reason as the redaction invariant above: a refinement would make
|
|
237
|
+
* this package refuse a response the server is willing to emit, and it cannot
|
|
238
|
+
* be said in the OpenAPI surface either.
|
|
239
|
+
*/
|
|
240
|
+
export const AgentOutcomeSchema = z.object({
|
|
241
|
+
triggerId: z.string(),
|
|
242
|
+
status: z.enum(["pending", "replied", "unanswered"]),
|
|
243
|
+
reason: z.enum(AGENT_OUTCOME_REASONS).nullable(),
|
|
244
|
+
replyCommentId: z.string().nullable(),
|
|
245
|
+
});
|
|
246
|
+
export type AgentOutcome = z.infer<typeof AgentOutcomeSchema>;
|
|
247
|
+
|
|
154
248
|
/**
|
|
155
249
|
* A thread WITHOUT its comments — the shape a resolve/reopen transition returns.
|
|
156
250
|
*
|
|
@@ -187,6 +281,13 @@ export type CommentProjection = z.infer<typeof CommentSchema>;
|
|
|
187
281
|
* thread status `resolved` with `suggestion.status` carrying which way it went
|
|
188
282
|
* (`accepted` | `rejected`), and `resolvedByUserId`/`resolvedAt` double as the
|
|
189
283
|
* decision actor and time — there is no second actor/timestamp pair.
|
|
284
|
+
*
|
|
285
|
+
* `origin` and `agentOutcomes` are BOTH REQUIRED, for the reason `kind` and
|
|
286
|
+
* `capabilities` are: an optional field is how one consumer quietly keeps a
|
|
287
|
+
* local derivation, and a second opinion about what started a thread or whether
|
|
288
|
+
* the agent answered is exactly the drift this vocabulary exists to delete. The
|
|
289
|
+
* backend projects `{ kind: "comment" }` and `[]` onto every thread that
|
|
290
|
+
* predates them, so absence is a malformed response and not a legacy row.
|
|
190
291
|
*/
|
|
191
292
|
export const CommentThreadSummarySchema = z.object({
|
|
192
293
|
id: z.string(),
|
|
@@ -197,6 +298,8 @@ export const CommentThreadSummarySchema = z.object({
|
|
|
197
298
|
anchorType: CommentAnchorTypeSchema,
|
|
198
299
|
anchor: CommentAnchorSchema,
|
|
199
300
|
status: CommentThreadStatusSchema,
|
|
301
|
+
origin: ThreadOriginSchema,
|
|
302
|
+
agentOutcomes: z.array(AgentOutcomeSchema),
|
|
200
303
|
createdByUserId: z.string().nullable(),
|
|
201
304
|
resolvedByUserId: z.string().nullable(),
|
|
202
305
|
resolvedAt: z.string().nullable(),
|
|
@@ -291,12 +394,15 @@ export type CommentThreadListResponse = z.infer<
|
|
|
291
394
|
* readers — so returning the band each one holds would turn an autocomplete into
|
|
292
395
|
* a way to enumerate who can do what.
|
|
293
396
|
*
|
|
294
|
-
* `
|
|
295
|
-
*
|
|
296
|
-
*
|
|
397
|
+
* `principal` is what the mention row will carry, and it is a `PrincipalRef`
|
|
398
|
+
* rather than a user id so that the AGENT is offerable here at all — it holds
|
|
399
|
+
* no grants of its own and has no users row, so a candidate list keyed on ids
|
|
400
|
+
* could only ever offer people (ADR-CONTRACTS-160). `displayName` is resolved
|
|
401
|
+
* fresh on every read and never frozen into a stored row, exactly as on
|
|
402
|
+
* `CommentMentionSchema`; for the agent it is `CS_AGENT.displayName`.
|
|
297
403
|
*/
|
|
298
404
|
export const MentionableCandidateSchema = z.object({
|
|
299
|
-
|
|
405
|
+
principal: PrincipalRefSchema,
|
|
300
406
|
displayName: z.string(),
|
|
301
407
|
avatarUrl: z.string().nullable(),
|
|
302
408
|
});
|
package/src/identity/README.md
CHANGED
|
@@ -20,6 +20,7 @@ TypeScript types and functions for user identity and display name resolution.
|
|
|
20
20
|
|
|
21
21
|
## Public API
|
|
22
22
|
|
|
23
|
+
- `AGENT_PRINCIPAL` — The one agent principal.
|
|
23
24
|
- `AccountCancelDeletionResponse` _(type)_
|
|
24
25
|
- `AccountCancelDeletionResponseSchema`
|
|
25
26
|
- `AccountConfirmDeletionResponse` _(type)_
|
|
@@ -42,6 +43,8 @@ TypeScript types and functions for user identity and display name resolution.
|
|
|
42
43
|
- `BannerDismissResponseSchema`
|
|
43
44
|
- `BannerDismissedListResponse` _(type)_
|
|
44
45
|
- `BannerDismissedListResponseSchema`
|
|
46
|
+
- `CS_AGENT` — The agent's identity, in the three forms a surface needs: the handle a mention is typed as, the name a chip…
|
|
47
|
+
- `CS_AGENT_HANDLE` — THE AGENT PRINCIPAL (control INVARIANTS.md, INV-AGENT-IDENTITY).
|
|
45
48
|
- `DeletionBlocker` _(type)_ — Conditions that block account deletion.
|
|
46
49
|
- `ISODateString` _(type)_ — ISO 8601 date-time string.
|
|
47
50
|
- `IdentityLink` _(type)_
|
|
@@ -65,6 +68,8 @@ TypeScript types and functions for user identity and display name resolution.
|
|
|
65
68
|
- `PersonSchema`
|
|
66
69
|
- `PositionRef` _(type)_
|
|
67
70
|
- `PositionRefSchema` — A reference to a POSITION in the org graph.
|
|
71
|
+
- `PrincipalRef` _(type)_
|
|
72
|
+
- `PrincipalRefSchema` — A principal: a person by id, or the one agent.
|
|
68
73
|
- `ProfileResponse` _(type)_
|
|
69
74
|
- `ProfileResponseSchema`
|
|
70
75
|
- `ReportingRelationshipType` _(type)_
|
|
@@ -77,8 +82,11 @@ TypeScript types and functions for user identity and display name resolution.
|
|
|
77
82
|
- `deriveFullName`
|
|
78
83
|
- `extractFirstWord` — Extract the first word from a full name.
|
|
79
84
|
- `generateInitials` — Generate initials from a full name.
|
|
85
|
+
- `isAgentPrincipal` — Narrows a ref to the agent arm.
|
|
86
|
+
- `principalKey` — Stable key for Set/Map use: 'agent' | 'user:id'.
|
|
80
87
|
- `resolveAvatar` — Resolve avatar from user identity.
|
|
81
88
|
- `resolveDisplayName` — Resolve the display name for assistant addressing.
|
|
89
|
+
- `userPrincipal` — A person as a principal.
|
|
82
90
|
|
|
83
91
|
<!-- END GENERATED: readme-public-api -->
|
|
84
92
|
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
import { describe, it, expect } from "vitest";
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
AGENT_PRINCIPAL,
|
|
5
|
+
CS_AGENT,
|
|
6
|
+
CS_AGENT_HANDLE,
|
|
7
|
+
PrincipalRefSchema,
|
|
8
|
+
isAgentPrincipal,
|
|
9
|
+
principalKey,
|
|
10
|
+
userPrincipal,
|
|
11
|
+
} from "../agent.js";
|
|
12
|
+
|
|
13
|
+
const USER_ID = "33333333-3333-4333-8333-333333333333";
|
|
14
|
+
|
|
15
|
+
describe("CS_AGENT", () => {
|
|
16
|
+
// The point of the constant: the renderers derive the glyph rather than
|
|
17
|
+
// retyping it, so this assertion is what keeps them honest.
|
|
18
|
+
it("the glyph is derived from the handle", () => {
|
|
19
|
+
expect(CS_AGENT.glyph).toBe(`[${CS_AGENT.handle}]`);
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
it("names the agent once — handle, displayName and the constant agree", () => {
|
|
23
|
+
expect(CS_AGENT.handle).toBe(CS_AGENT_HANDLE);
|
|
24
|
+
expect(CS_AGENT.displayName).toBe(CS_AGENT_HANDLE);
|
|
25
|
+
});
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
describe("PrincipalRefSchema", () => {
|
|
29
|
+
it("accepts the agent arm with no id", () => {
|
|
30
|
+
expect(PrincipalRefSchema.parse({ kind: "agent" })).toEqual({
|
|
31
|
+
kind: "agent",
|
|
32
|
+
});
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
it("accepts a user arm carrying a userId", () => {
|
|
36
|
+
expect(PrincipalRefSchema.parse({ kind: "user", userId: USER_ID })).toEqual(
|
|
37
|
+
{
|
|
38
|
+
kind: "user",
|
|
39
|
+
userId: USER_ID,
|
|
40
|
+
},
|
|
41
|
+
);
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
// Strict, not stripping: a stray userId on an agent ref means the caller
|
|
45
|
+
// believes the agent has a users row. Refuse rather than silently drop it.
|
|
46
|
+
it("an agent ref rejects a userId", () => {
|
|
47
|
+
expect(
|
|
48
|
+
PrincipalRefSchema.safeParse({ kind: "agent", userId: "x" }).success,
|
|
49
|
+
).toBe(false);
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
it("a user ref requires a userId", () => {
|
|
53
|
+
expect(PrincipalRefSchema.safeParse({ kind: "user" }).success).toBe(false);
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
it("rejects an unknown kind", () => {
|
|
57
|
+
expect(PrincipalRefSchema.safeParse({ kind: "system" }).success).toBe(
|
|
58
|
+
false,
|
|
59
|
+
);
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
// Mirrors the comments boundary rather than the uuid-tight rest of this
|
|
63
|
+
// domain: tightening here would reject ids the server considers valid.
|
|
64
|
+
it("does not require the userId to be a uuid", () => {
|
|
65
|
+
expect(
|
|
66
|
+
PrincipalRefSchema.safeParse({ kind: "user", userId: "u_1" }).success,
|
|
67
|
+
).toBe(true);
|
|
68
|
+
});
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
describe("principal helpers", () => {
|
|
72
|
+
it("AGENT_PRINCIPAL parses as the agent arm", () => {
|
|
73
|
+
expect(PrincipalRefSchema.parse(AGENT_PRINCIPAL)).toEqual({
|
|
74
|
+
kind: "agent",
|
|
75
|
+
});
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
it("userPrincipal builds a parseable user ref", () => {
|
|
79
|
+
expect(PrincipalRefSchema.parse(userPrincipal(USER_ID))).toEqual({
|
|
80
|
+
kind: "user",
|
|
81
|
+
userId: USER_ID,
|
|
82
|
+
});
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
it("isAgentPrincipal narrows the agent arm only", () => {
|
|
86
|
+
expect(isAgentPrincipal(AGENT_PRINCIPAL)).toBe(true);
|
|
87
|
+
expect(isAgentPrincipal(userPrincipal(USER_ID))).toBe(false);
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
it("principalKey is stable and cannot collide across arms", () => {
|
|
91
|
+
expect(principalKey(AGENT_PRINCIPAL)).toBe("agent");
|
|
92
|
+
expect(principalKey(userPrincipal(USER_ID))).toBe(`user:${USER_ID}`);
|
|
93
|
+
expect(new Set([principalKey(AGENT_PRINCIPAL), "agent"]).size).toBe(1);
|
|
94
|
+
});
|
|
95
|
+
});
|