@company-semantics/contracts 81.0.0 → 83.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 +19 -0
- package/src/chat/README.md +33 -1
- package/src/chat/__tests__/compaction-boundary.test.ts +1 -0
- package/src/chat/__tests__/continuity.test.ts +402 -0
- package/src/chat/__tests__/message-mentions.test.ts +1 -0
- package/src/chat/__tests__/multi-party.test.ts +1 -0
- package/src/chat/continuity.ts +207 -0
- package/src/chat/index.ts +26 -0
- package/src/chat/schemas.ts +7 -0
- package/src/index.ts +77 -1
- package/src/user-notifications/README.md +99 -12
- package/src/user-notifications/__tests__/README.md +19 -0
- package/src/user-notifications/__tests__/compile-fixtures.ts +54 -0
- package/src/user-notifications/__tests__/condition-keys.test.ts +126 -0
- package/src/user-notifications/__tests__/feed-lifecycle.test.ts +260 -0
- package/src/user-notifications/__tests__/lifecycle.test.ts +114 -0
- package/src/user-notifications/__tests__/vocabulary.test.ts +6 -1
- package/src/user-notifications/condition-keys.ts +64 -0
- package/src/user-notifications/feed-lifecycle.ts +76 -0
- package/src/user-notifications/index.ts +49 -2
- package/src/user-notifications/lifecycle-registry.ts +123 -0
- package/src/user-notifications/lifecycle.ts +75 -0
- package/src/user-notifications/schemas.ts +27 -6
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 = '9286d0cf0c7c' as const;
|
|
3
|
+
export const SPEC_HASH_FULL = '9286d0cf0c7c1457a11e830402e9e860ee80e5b0b263b79d4ec15308d7ecb271' as const;
|
package/src/api/generated.ts
CHANGED
|
@@ -4889,6 +4889,21 @@ export interface components {
|
|
|
4889
4889
|
/** Format: date-time */
|
|
4890
4890
|
addedAt: string;
|
|
4891
4891
|
}[];
|
|
4892
|
+
continuity: {
|
|
4893
|
+
subjectId: string;
|
|
4894
|
+
/** @enum {string} */
|
|
4895
|
+
subjectKind: "source-sync-candidate-review" | "source-sync-synthesis-review" | "org-structure-review";
|
|
4896
|
+
subjectTitle: string;
|
|
4897
|
+
generation: number;
|
|
4898
|
+
isActiveGeneration: boolean;
|
|
4899
|
+
activeChatId: string | null;
|
|
4900
|
+
continuationOf: {
|
|
4901
|
+
chatId: string;
|
|
4902
|
+
/** Format: date-time */
|
|
4903
|
+
createdAt: string;
|
|
4904
|
+
} | null;
|
|
4905
|
+
continuationReason: "user-started-fresh" | null;
|
|
4906
|
+
} | null;
|
|
4892
4907
|
};
|
|
4893
4908
|
messages: {
|
|
4894
4909
|
id: string;
|
|
@@ -7614,9 +7629,13 @@ export interface components {
|
|
|
7614
7629
|
href: string | null;
|
|
7615
7630
|
createdAt: string;
|
|
7616
7631
|
readAt: string | null;
|
|
7632
|
+
activityAt: string;
|
|
7633
|
+
occurrenceCount: number;
|
|
7634
|
+
resolvedAt: string | null;
|
|
7617
7635
|
};
|
|
7618
7636
|
})[];
|
|
7619
7637
|
nextCursor: string | null;
|
|
7638
|
+
viewCount: number;
|
|
7620
7639
|
unreadCount: number;
|
|
7621
7640
|
truncated: boolean;
|
|
7622
7641
|
};
|
package/src/chat/README.md
CHANGED
|
@@ -38,24 +38,44 @@ Shared types for chat persistence, sharing, real-time events, and runtime profil
|
|
|
38
38
|
- The summary carries a participant COUNT, never the roster. A 50-row sidebar page must not carry 50 rosters, and a chat's membership is not disclosed by the chat merely appearing in a list. `ChatDetail.participants` is where the names are, and it is optional — a missing roster is simply not drawn, whereas a guessed role is drawn wrong
|
|
39
39
|
- A `ChatParticipant` is a USER, never a `PrincipalRef` — the deliberate asymmetry with a mention TARGET, which is a ref precisely so the agent can be named. The agent takes part in every chat by construction and has no participant row: it is mentionable, not a member, and cannot be added, removed or left
|
|
40
40
|
- `ChatParticipantRole` has EXACTLY two members. A third would be an access band, and this is participation rather than an ACL: a participant sees the whole conversation, `owner` names the one person who additionally deletes, renames, pins and shares it, and a chat you can read half of is not a thing this surface models
|
|
41
|
-
- The chat schemas are THREE files with a one-way load order: `enums.ts` (the closed vocabularies plus the `proactiveChatFields` / `multiPartyChatFields` groups) imports nothing from the domain, `event-schemas.ts` imports only from it, and `schemas.ts` imports from both and RE-EXPORTS them so `index.ts`, `types.ts` and the tests keep one import site. An import from either of the first two back into `schemas.ts` is a load cycle: the re-export edge is evaluated before `schemas.ts`'s body, so the shared declarations are reached in the temporal dead zone and every consumer throws a `ReferenceError` at import time
|
|
41
|
+
- The chat schemas are THREE files with a one-way load order: `enums.ts` (the closed vocabularies plus the `proactiveChatFields` / `multiPartyChatFields` groups) imports nothing from the domain, `event-schemas.ts` imports only from it, and `schemas.ts` imports from both and RE-EXPORTS them so `index.ts`, `types.ts` and the tests keep one import site. An import from either of the first two back into `schemas.ts` is a load cycle: the re-export edge is evaluated before `schemas.ts`'s body, so the shared declarations are reached in the temporal dead zone and every consumer throws a `ReferenceError` at import time. `continuity.ts` sits outside that set on the same terms: `schemas.ts` imports it, it never imports `schemas.ts`, and `index.ts` re-exports it directly (its types are declared beside its schemas, as `runtime-profile.ts` does)
|
|
42
42
|
- `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
|
|
43
43
|
|
|
44
|
+
## Continuity
|
|
45
|
+
|
|
46
|
+
A chat can belong to a SUBJECT — the durable case a hand-off to chat is about — and a subject is worked in numbered GENERATIONS, each a chat linked back to its predecessor (`src/chat/continuity.ts`, ADR slug `chat-continuity-wire-vocabulary`).
|
|
47
|
+
|
|
48
|
+
- `ChatDetail.continuity` is REQUIRED and NULLABLE: null means the chat belongs to no subject; a missing key is a backend that has not adopted the release
|
|
49
|
+
- Three identities, never conflated: `subjectId` (the case), `generation` (1-based position in the lineage), and the chat id carrying it. `activeChatId` is where the case continues now — this chat's own id when it is active, null when the subject has no active generation
|
|
50
|
+
- `continuationOf` and `continuationReason` are null together: generation 1, a generation started after its predecessor was deleted, and a predecessor row that no longer exists all send null, never a dangling link
|
|
51
|
+
- Compaction NEVER changes subject or generation identity. Compacting a chat (`compactedThroughSequence`) summarizes the older turns of the SAME chat — it keeps the same `subjectId`, the same `generation` and the same chat id, and it is not a continuation. A new generation starts only through a `continuationReason`, and compaction is deliberately not one
|
|
52
|
+
- `CONVERSATION_SUBJECT_KINDS` and `CHAT_CONTINUATION_REASONS` are CLOSED. A reason is added only when a real trigger exists; the launch-kind → subject-kind mapping and subject titles are server policy and do not live here
|
|
53
|
+
- Launch resolution is READ-ONLY and never creates a chat. `LAUNCH_DISPOSITIONS` is CLOSED; `created-and-launch` carries a null `chatId` AND a null `generation` (its `subjectId` is still reported when the subject row exists), and both `resume-*` dispositions carry `subjectId`, `chatId` and `generation`. `ResolveLaunchResponseSchema` refuses every other combination
|
|
54
|
+
- `StartFreshChatResponse` is the NEW active generation and always links its predecessor (`continuationOf.chatId`)
|
|
55
|
+
- A launch posted into a chat that is not — or can no longer become — the subject's active generation is a 409 whose body is `LaunchTargetSuperseded` (`code: "launch_target_superseded"`); its `activeChatId` names where to go, and null means start a new chat
|
|
56
|
+
- Request bodies for resolve-launch and start-fresh are deliberately NOT here: the resolve body embeds the backend-local launch schema, and start-fresh has no body
|
|
57
|
+
|
|
44
58
|
<!-- BEGIN GENERATED: readme-public-api — derived from code by `pnpm readme-api`. Do not edit. -->
|
|
45
59
|
|
|
46
60
|
## Public API
|
|
47
61
|
|
|
48
62
|
- `BaseEvent` _(type)_ — Base envelope for all SSE events.
|
|
49
63
|
- `BaseEventSchema`
|
|
64
|
+
- `CHAT_CONTINUATION_REASONS` — Why a generation continues its predecessor.
|
|
50
65
|
- `CHAT_DISCUSSION_REQUEST_TYPES` — The kinds of durable input request a turn can be scoped to.
|
|
51
66
|
- `CHAT_PAGE_ENTITY_TYPES` — What kind of thing the reader has open, when they have one open.
|
|
52
67
|
- `CHAT_PAGE_SCOPES` — The route parser's four URL scopes: unscoped, `/me/*`, `/team/*`, `/@:org/*`.
|
|
53
68
|
- `CHAT_PAGE_VIEWS` — The app route parser's seventeen `type` values, projected onto the wire.
|
|
54
69
|
- `CHAT_RUNTIME_PROFILES` — Ordered list of available profiles for UI rendering.
|
|
70
|
+
- `CONVERSATION_SUBJECT_KINDS` — The kinds of subject a chat can continue.
|
|
55
71
|
- `ChatByInteractionResponse` _(type)_ — Response for GET /api/chats/by-interaction/:interactionId
|
|
56
72
|
- `ChatByInteractionResponseSchema` — Response for GET /api/chats/by-interaction/:interactionId
|
|
57
73
|
- `ChatChangedField` _(type)_ — Fields that can change on a chat, used in changed array.
|
|
58
74
|
- `ChatChangedFieldSchema`
|
|
75
|
+
- `ChatContinuationReason` _(type)_
|
|
76
|
+
- `ChatContinuationReasonSchema`
|
|
77
|
+
- `ChatContinuity` _(type)_
|
|
78
|
+
- `ChatContinuitySchema` — Where a chat sits in its subject's lineage — carried on the chat detail.
|
|
59
79
|
- `ChatCreatedEvent` _(type)_ — Emitted exactly once after a chat is successfully persisted.
|
|
60
80
|
- `ChatCreatedEventSchema` — Emitted exactly once after a chat is persisted; a pushed proactive chat is announced over this event too.
|
|
61
81
|
- `ChatDeletedEvent` _(type)_ — Emitted when a chat is deleted.
|
|
@@ -103,6 +123,8 @@ Shared types for chat persistence, sharing, real-time events, and runtime profil
|
|
|
103
123
|
- `ChatUpdatedEventSchema`
|
|
104
124
|
- `ChatVisibility` _(type)_ — Chat share visibility. - private: Token exists but access requires authentication as chat owner - public…
|
|
105
125
|
- `ChatVisibilitySchema`
|
|
126
|
+
- `ConversationSubjectKind` _(type)_
|
|
127
|
+
- `ConversationSubjectKindSchema`
|
|
106
128
|
- `CreateChatResponse` _(type)_ — Response for POST /api/chats
|
|
107
129
|
- `CreateChatResponseSchema` — Response for POST /api/chats
|
|
108
130
|
- `CreateShareRequest` _(type)_ — Request to create a new share for a chat.
|
|
@@ -117,15 +139,25 @@ Shared types for chat persistence, sharing, real-time events, and runtime profil
|
|
|
117
139
|
- `InvalidateChatListEventSchema`
|
|
118
140
|
- `InvalidationReason` _(type)_ — Reason for cache invalidation.
|
|
119
141
|
- `InvalidationReasonSchema`
|
|
142
|
+
- `LAUNCH_DISPOSITIONS` — What the read-only launch resolution found for a hand-off.
|
|
143
|
+
- `LAUNCH_TARGET_SUPERSEDED_CODE` — The 409 code for a launch posted into a chat that is not the subject's active generation.
|
|
144
|
+
- `LaunchDisposition` _(type)_
|
|
145
|
+
- `LaunchDispositionSchema`
|
|
146
|
+
- `LaunchTargetSuperseded` _(type)_
|
|
147
|
+
- `LaunchTargetSupersededSchema` — The 409 body the authoritative launch write returns when the posted chat is not — or can no longer become —…
|
|
120
148
|
- `ListSharesResponse` _(type)_ — Response for GET /api/chats/:chatId/shares
|
|
121
149
|
- `ListSharesResponseSchema` — Response for GET /api/chats/:chatId/shares
|
|
122
150
|
- `ProactiveChatResolution` _(type)_ — Resolving an occurrence id to the CURRENT reader's chat — one of the four bounded states `ready` (with…
|
|
123
151
|
- `ProactiveChatResolutionSchema` — Resolving an occurrence id to THIS reader's chat (INV-PROACTIVE-LINK).
|
|
124
152
|
- `RecognizedProactiveKind` _(type)_ — A wire `proactiveKind` after recognition: a kind this contracts version knows, or the raw string it did not —…
|
|
153
|
+
- `ResolveLaunchResponse` _(type)_
|
|
154
|
+
- `ResolveLaunchResponseSchema` — Response of the read-only launch resolution.
|
|
125
155
|
- `SharedChatMessage` _(type)_ — Individual message in a shared chat view.
|
|
126
156
|
- `SharedChatMessageSchema`
|
|
127
157
|
- `SharedChatView` _(type)_ — Shared chat view returned to viewers.
|
|
128
158
|
- `SharedChatViewSchema`
|
|
159
|
+
- `StartFreshChatResponse` _(type)_
|
|
160
|
+
- `StartFreshChatResponseSchema` — Response of the start-fresh write: the NEW active generation of the subject, linked back to the chat it…
|
|
129
161
|
- `TitleGenerationRequest` _(type)_ — Request to generate a title for a chat.
|
|
130
162
|
- `TitleGenerationResponse` _(type)_ — Response from title generation.
|
|
131
163
|
- `TitleGenerationResponseSchema`
|
|
@@ -0,0 +1,402 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Chat continuity wire vocabulary (chat-continuity-wire-vocabulary).
|
|
3
|
+
*
|
|
4
|
+
* INVARIANTS TESTED:
|
|
5
|
+
* - The subject-kind and continuation-reason vocabularies are CLOSED and
|
|
6
|
+
* pinned member-for-member; the only continuation reason is
|
|
7
|
+
* `user-started-fresh`.
|
|
8
|
+
* - `generation` is a positive integer — generation 1 is the first.
|
|
9
|
+
* - `continuationOf` and `continuationReason` are null TOGETHER: a reason
|
|
10
|
+
* without a predecessor, or a predecessor without a reason, is refused.
|
|
11
|
+
* - `activeChatId` is nullable (the subject has no active generation).
|
|
12
|
+
* - `ChatDetail.continuity` is REQUIRED and NULLABLE: null parses (a chat
|
|
13
|
+
* with no subject), a missing key does not.
|
|
14
|
+
* - The launch dispositions are CLOSED. `created-and-launch` carries a null
|
|
15
|
+
* chatId AND generation (resolve never creates a chat); both `resume-*`
|
|
16
|
+
* dispositions carry subjectId, chatId and generation — the refinement
|
|
17
|
+
* rejects either direction of violation.
|
|
18
|
+
* - The start-fresh response always links its predecessor; the 409 body's
|
|
19
|
+
* code is the literal `launch_target_superseded`, activeChatId nullable.
|
|
20
|
+
* - Every export is reachable from both the chat barrel and the root barrel.
|
|
21
|
+
*/
|
|
22
|
+
import { describe, expect, it } from "vitest";
|
|
23
|
+
import * as root from "../../index";
|
|
24
|
+
import {
|
|
25
|
+
CHAT_CONTINUATION_REASONS,
|
|
26
|
+
ChatContinuationReasonSchema,
|
|
27
|
+
ChatContinuitySchema,
|
|
28
|
+
CONVERSATION_SUBJECT_KINDS,
|
|
29
|
+
ConversationSubjectKindSchema,
|
|
30
|
+
LAUNCH_DISPOSITIONS,
|
|
31
|
+
LAUNCH_TARGET_SUPERSEDED_CODE,
|
|
32
|
+
LaunchDispositionSchema,
|
|
33
|
+
LaunchTargetSupersededSchema,
|
|
34
|
+
ResolveLaunchResponseSchema,
|
|
35
|
+
StartFreshChatResponseSchema,
|
|
36
|
+
} from "../continuity";
|
|
37
|
+
import * as chat from "../index";
|
|
38
|
+
import { ChatDetailSchema, GetChatResponseSchema } from "../schemas";
|
|
39
|
+
|
|
40
|
+
// ---------------------------------------------------------------------------
|
|
41
|
+
// Fixtures
|
|
42
|
+
// ---------------------------------------------------------------------------
|
|
43
|
+
|
|
44
|
+
/** Generation 1 of a subject, and the active one. */
|
|
45
|
+
const FIRST_GENERATION = {
|
|
46
|
+
subjectId: "subj_1",
|
|
47
|
+
subjectKind: "source-sync-candidate-review",
|
|
48
|
+
subjectTitle: "Review candidate files",
|
|
49
|
+
generation: 1,
|
|
50
|
+
isActiveGeneration: true,
|
|
51
|
+
activeChatId: "chat_1",
|
|
52
|
+
continuationOf: null,
|
|
53
|
+
continuationReason: null,
|
|
54
|
+
} as const;
|
|
55
|
+
|
|
56
|
+
/** Generation 2, continuing chat_1 because the reader started fresh. */
|
|
57
|
+
const SECOND_GENERATION = {
|
|
58
|
+
...FIRST_GENERATION,
|
|
59
|
+
generation: 2,
|
|
60
|
+
activeChatId: "chat_2",
|
|
61
|
+
continuationOf: { chatId: "chat_1", createdAt: "2026-09-01T00:00:00.000Z" },
|
|
62
|
+
continuationReason: "user-started-fresh",
|
|
63
|
+
} as const;
|
|
64
|
+
|
|
65
|
+
/** A chat detail with every REQUIRED field except `continuity`. */
|
|
66
|
+
const BASE_DETAIL = {
|
|
67
|
+
id: "chat_1",
|
|
68
|
+
title: "Candidate review",
|
|
69
|
+
interactionId: "int_1",
|
|
70
|
+
createdAt: "2026-09-01T00:00:00.000Z",
|
|
71
|
+
updatedAt: "2026-09-01T01:00:00.000Z",
|
|
72
|
+
};
|
|
73
|
+
|
|
74
|
+
describe("continuity vocabularies", () => {
|
|
75
|
+
it("pins the closed subject kinds", () => {
|
|
76
|
+
expect([...CONVERSATION_SUBJECT_KINDS]).toEqual([
|
|
77
|
+
"source-sync-candidate-review",
|
|
78
|
+
"source-sync-synthesis-review",
|
|
79
|
+
"org-structure-review",
|
|
80
|
+
]);
|
|
81
|
+
expect(new Set(CONVERSATION_SUBJECT_KINDS).size).toBe(
|
|
82
|
+
CONVERSATION_SUBJECT_KINDS.length,
|
|
83
|
+
);
|
|
84
|
+
expect(ConversationSubjectKindSchema.safeParse("chat").success).toBe(false);
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
it("has exactly one continuation reason: user-started-fresh", () => {
|
|
88
|
+
expect([...CHAT_CONTINUATION_REASONS]).toEqual(["user-started-fresh"]);
|
|
89
|
+
expect(ChatContinuationReasonSchema.safeParse("inactivity").success).toBe(
|
|
90
|
+
false,
|
|
91
|
+
);
|
|
92
|
+
expect(
|
|
93
|
+
ChatContinuationReasonSchema.safeParse("semantic-divergence").success,
|
|
94
|
+
).toBe(false);
|
|
95
|
+
});
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
describe("ChatContinuitySchema", () => {
|
|
99
|
+
it("a first generation has no continuation", () => {
|
|
100
|
+
const parsed = ChatContinuitySchema.parse(FIRST_GENERATION);
|
|
101
|
+
expect(parsed).toEqual(FIRST_GENERATION);
|
|
102
|
+
expect(parsed.generation).toBe(1);
|
|
103
|
+
expect(parsed.continuationOf).toBeNull();
|
|
104
|
+
expect(parsed.continuationReason).toBeNull();
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
it("parses a continuing generation with its predecessor and reason", () => {
|
|
108
|
+
expect(ChatContinuitySchema.parse(SECOND_GENERATION)).toEqual(
|
|
109
|
+
SECOND_GENERATION,
|
|
110
|
+
);
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
it("parses a superseded generation pointing at the active chat", () => {
|
|
114
|
+
const parsed = ChatContinuitySchema.parse({
|
|
115
|
+
...FIRST_GENERATION,
|
|
116
|
+
isActiveGeneration: false,
|
|
117
|
+
activeChatId: "chat_2",
|
|
118
|
+
});
|
|
119
|
+
expect(parsed.activeChatId).toBe("chat_2");
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
it("accepts a null activeChatId when the subject has no active generation", () => {
|
|
123
|
+
const parsed = ChatContinuitySchema.parse({
|
|
124
|
+
...FIRST_GENERATION,
|
|
125
|
+
isActiveGeneration: false,
|
|
126
|
+
activeChatId: null,
|
|
127
|
+
});
|
|
128
|
+
expect(parsed.activeChatId).toBeNull();
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
it("refuses a reason without a predecessor", () => {
|
|
132
|
+
expect(
|
|
133
|
+
ChatContinuitySchema.safeParse({
|
|
134
|
+
...FIRST_GENERATION,
|
|
135
|
+
continuationReason: "user-started-fresh",
|
|
136
|
+
}).success,
|
|
137
|
+
).toBe(false);
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
it("refuses a predecessor without a reason", () => {
|
|
141
|
+
expect(
|
|
142
|
+
ChatContinuitySchema.safeParse({
|
|
143
|
+
...SECOND_GENERATION,
|
|
144
|
+
continuationReason: null,
|
|
145
|
+
}).success,
|
|
146
|
+
).toBe(false);
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
it("requires a positive integer generation", () => {
|
|
150
|
+
for (const generation of [0, -1, 1.5]) {
|
|
151
|
+
expect(
|
|
152
|
+
ChatContinuitySchema.safeParse({ ...FIRST_GENERATION, generation })
|
|
153
|
+
.success,
|
|
154
|
+
).toBe(false);
|
|
155
|
+
}
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
it("requires an ISO timestamp on the predecessor", () => {
|
|
159
|
+
expect(
|
|
160
|
+
ChatContinuitySchema.safeParse({
|
|
161
|
+
...SECOND_GENERATION,
|
|
162
|
+
continuationOf: { chatId: "chat_1", createdAt: "yesterday" },
|
|
163
|
+
}).success,
|
|
164
|
+
).toBe(false);
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
it("an unknown subject kind is rejected", () => {
|
|
168
|
+
expect(
|
|
169
|
+
ChatContinuitySchema.safeParse({
|
|
170
|
+
...FIRST_GENERATION,
|
|
171
|
+
subjectKind: "org-structure/review",
|
|
172
|
+
}).success,
|
|
173
|
+
).toBe(false);
|
|
174
|
+
});
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
describe("ChatDetail.continuity", () => {
|
|
178
|
+
it("is null for a chat that belongs to no subject", () => {
|
|
179
|
+
const parsed = ChatDetailSchema.parse({ ...BASE_DETAIL, continuity: null });
|
|
180
|
+
expect(parsed.continuity).toBeNull();
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
it("carries the lineage for a chat that belongs to a subject", () => {
|
|
184
|
+
const parsed = ChatDetailSchema.parse({
|
|
185
|
+
...BASE_DETAIL,
|
|
186
|
+
continuity: SECOND_GENERATION,
|
|
187
|
+
});
|
|
188
|
+
expect(parsed.continuity?.generation).toBe(2);
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
it("a chat detail without continuity fails to parse", () => {
|
|
192
|
+
expect(ChatDetailSchema.safeParse(BASE_DETAIL).success).toBe(false);
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
it("rides the chat detail in GET /api/chats/:id", () => {
|
|
196
|
+
const parsed = GetChatResponseSchema.parse({
|
|
197
|
+
chat: { ...BASE_DETAIL, continuity: FIRST_GENERATION },
|
|
198
|
+
messages: [],
|
|
199
|
+
});
|
|
200
|
+
expect(parsed.chat.continuity?.subjectId).toBe("subj_1");
|
|
201
|
+
});
|
|
202
|
+
});
|
|
203
|
+
|
|
204
|
+
describe("barrel exports", () => {
|
|
205
|
+
it("every continuity value is reachable from both barrels", () => {
|
|
206
|
+
for (const barrel of [chat, root]) {
|
|
207
|
+
expect(barrel.CONVERSATION_SUBJECT_KINDS).toBe(
|
|
208
|
+
CONVERSATION_SUBJECT_KINDS,
|
|
209
|
+
);
|
|
210
|
+
expect(barrel.ConversationSubjectKindSchema).toBe(
|
|
211
|
+
ConversationSubjectKindSchema,
|
|
212
|
+
);
|
|
213
|
+
expect(barrel.CHAT_CONTINUATION_REASONS).toBe(CHAT_CONTINUATION_REASONS);
|
|
214
|
+
expect(barrel.ChatContinuationReasonSchema).toBe(
|
|
215
|
+
ChatContinuationReasonSchema,
|
|
216
|
+
);
|
|
217
|
+
expect(barrel.ChatContinuitySchema).toBe(ChatContinuitySchema);
|
|
218
|
+
expect(barrel.LAUNCH_DISPOSITIONS).toBe(LAUNCH_DISPOSITIONS);
|
|
219
|
+
expect(barrel.LaunchDispositionSchema).toBe(LaunchDispositionSchema);
|
|
220
|
+
expect(barrel.ResolveLaunchResponseSchema).toBe(
|
|
221
|
+
ResolveLaunchResponseSchema,
|
|
222
|
+
);
|
|
223
|
+
expect(barrel.StartFreshChatResponseSchema).toBe(
|
|
224
|
+
StartFreshChatResponseSchema,
|
|
225
|
+
);
|
|
226
|
+
expect(barrel.LAUNCH_TARGET_SUPERSEDED_CODE).toBe(
|
|
227
|
+
LAUNCH_TARGET_SUPERSEDED_CODE,
|
|
228
|
+
);
|
|
229
|
+
expect(barrel.LaunchTargetSupersededSchema).toBe(
|
|
230
|
+
LaunchTargetSupersededSchema,
|
|
231
|
+
);
|
|
232
|
+
}
|
|
233
|
+
});
|
|
234
|
+
});
|
|
235
|
+
|
|
236
|
+
describe("launch dispositions", () => {
|
|
237
|
+
it("pins the closed disposition vocabulary", () => {
|
|
238
|
+
expect([...LAUNCH_DISPOSITIONS]).toEqual([
|
|
239
|
+
"resume-existing-anchor",
|
|
240
|
+
"resume-and-launch",
|
|
241
|
+
"created-and-launch",
|
|
242
|
+
]);
|
|
243
|
+
expect(new Set(LAUNCH_DISPOSITIONS).size).toBe(LAUNCH_DISPOSITIONS.length);
|
|
244
|
+
expect(LaunchDispositionSchema.safeParse("created").success).toBe(false);
|
|
245
|
+
});
|
|
246
|
+
});
|
|
247
|
+
|
|
248
|
+
describe("ResolveLaunchResponseSchema", () => {
|
|
249
|
+
const RESUME = {
|
|
250
|
+
subjectId: "subj_1",
|
|
251
|
+
chatId: "chat_1",
|
|
252
|
+
generation: 1,
|
|
253
|
+
} as const;
|
|
254
|
+
|
|
255
|
+
it("parses both resume dispositions with subject, chat and generation", () => {
|
|
256
|
+
for (const disposition of [
|
|
257
|
+
"resume-existing-anchor",
|
|
258
|
+
"resume-and-launch",
|
|
259
|
+
] as const) {
|
|
260
|
+
expect(
|
|
261
|
+
ResolveLaunchResponseSchema.parse({ ...RESUME, disposition }),
|
|
262
|
+
).toEqual({ ...RESUME, disposition });
|
|
263
|
+
}
|
|
264
|
+
});
|
|
265
|
+
|
|
266
|
+
it("parses created-and-launch with no chat and no generation", () => {
|
|
267
|
+
const parsed = ResolveLaunchResponseSchema.parse({
|
|
268
|
+
subjectId: null,
|
|
269
|
+
chatId: null,
|
|
270
|
+
generation: null,
|
|
271
|
+
disposition: "created-and-launch",
|
|
272
|
+
});
|
|
273
|
+
expect(parsed.chatId).toBeNull();
|
|
274
|
+
});
|
|
275
|
+
|
|
276
|
+
it("created-and-launch still reports an existing subject with no active generation", () => {
|
|
277
|
+
const parsed = ResolveLaunchResponseSchema.parse({
|
|
278
|
+
subjectId: "subj_1",
|
|
279
|
+
chatId: null,
|
|
280
|
+
generation: null,
|
|
281
|
+
disposition: "created-and-launch",
|
|
282
|
+
});
|
|
283
|
+
expect(parsed.subjectId).toBe("subj_1");
|
|
284
|
+
});
|
|
285
|
+
|
|
286
|
+
// Resolve never creates a chat, so a created-and-launch cannot name one.
|
|
287
|
+
it("created-and-launch with a chat id is rejected", () => {
|
|
288
|
+
expect(
|
|
289
|
+
ResolveLaunchResponseSchema.safeParse({
|
|
290
|
+
subjectId: "subj_1",
|
|
291
|
+
chatId: "chat_1",
|
|
292
|
+
generation: null,
|
|
293
|
+
disposition: "created-and-launch",
|
|
294
|
+
}).success,
|
|
295
|
+
).toBe(false);
|
|
296
|
+
});
|
|
297
|
+
|
|
298
|
+
it("rejects created-and-launch carrying a generation", () => {
|
|
299
|
+
expect(
|
|
300
|
+
ResolveLaunchResponseSchema.safeParse({
|
|
301
|
+
subjectId: "subj_1",
|
|
302
|
+
chatId: null,
|
|
303
|
+
generation: 1,
|
|
304
|
+
disposition: "created-and-launch",
|
|
305
|
+
}).success,
|
|
306
|
+
).toBe(false);
|
|
307
|
+
});
|
|
308
|
+
|
|
309
|
+
it("resume-and-launch without a chat id is rejected", () => {
|
|
310
|
+
const result = ResolveLaunchResponseSchema.safeParse({
|
|
311
|
+
...RESUME,
|
|
312
|
+
chatId: null,
|
|
313
|
+
disposition: "resume-and-launch",
|
|
314
|
+
});
|
|
315
|
+
expect(result.success).toBe(false);
|
|
316
|
+
expect(result.error?.issues[0]?.path).toEqual(["chatId"]);
|
|
317
|
+
});
|
|
318
|
+
|
|
319
|
+
it("rejects a resume disposition missing chatId, subjectId or generation", () => {
|
|
320
|
+
for (const disposition of [
|
|
321
|
+
"resume-existing-anchor",
|
|
322
|
+
"resume-and-launch",
|
|
323
|
+
] as const) {
|
|
324
|
+
for (const field of ["chatId", "subjectId", "generation"] as const) {
|
|
325
|
+
const result = ResolveLaunchResponseSchema.safeParse({
|
|
326
|
+
...RESUME,
|
|
327
|
+
disposition,
|
|
328
|
+
[field]: null,
|
|
329
|
+
});
|
|
330
|
+
expect(result.success).toBe(false);
|
|
331
|
+
expect(result.error?.issues[0]?.path).toEqual([field]);
|
|
332
|
+
}
|
|
333
|
+
}
|
|
334
|
+
});
|
|
335
|
+
|
|
336
|
+
it("requires a positive integer generation", () => {
|
|
337
|
+
expect(
|
|
338
|
+
ResolveLaunchResponseSchema.safeParse({
|
|
339
|
+
...RESUME,
|
|
340
|
+
generation: 0,
|
|
341
|
+
disposition: "resume-and-launch",
|
|
342
|
+
}).success,
|
|
343
|
+
).toBe(false);
|
|
344
|
+
});
|
|
345
|
+
});
|
|
346
|
+
|
|
347
|
+
describe("StartFreshChatResponseSchema", () => {
|
|
348
|
+
const FRESH = {
|
|
349
|
+
chatId: "chat_2",
|
|
350
|
+
interactionId: "int_2",
|
|
351
|
+
subjectId: "subj_1",
|
|
352
|
+
generation: 2,
|
|
353
|
+
continuationOf: { chatId: "chat_1" },
|
|
354
|
+
};
|
|
355
|
+
|
|
356
|
+
it("parses the new active generation linked to its predecessor", () => {
|
|
357
|
+
expect(StartFreshChatResponseSchema.parse(FRESH)).toEqual(FRESH);
|
|
358
|
+
});
|
|
359
|
+
|
|
360
|
+
it("requires the predecessor link", () => {
|
|
361
|
+
expect(
|
|
362
|
+
StartFreshChatResponseSchema.safeParse({ ...FRESH, continuationOf: null })
|
|
363
|
+
.success,
|
|
364
|
+
).toBe(false);
|
|
365
|
+
});
|
|
366
|
+
|
|
367
|
+
it("requires a positive integer generation", () => {
|
|
368
|
+
expect(
|
|
369
|
+
StartFreshChatResponseSchema.safeParse({ ...FRESH, generation: 0 })
|
|
370
|
+
.success,
|
|
371
|
+
).toBe(false);
|
|
372
|
+
});
|
|
373
|
+
});
|
|
374
|
+
|
|
375
|
+
describe("LaunchTargetSupersededSchema", () => {
|
|
376
|
+
it("pins the 409 code", () => {
|
|
377
|
+
expect(LAUNCH_TARGET_SUPERSEDED_CODE).toBe("launch_target_superseded");
|
|
378
|
+
});
|
|
379
|
+
|
|
380
|
+
it("a superseded target names the active chat", () => {
|
|
381
|
+
const body = { code: "launch_target_superseded", activeChatId: "chat_2" };
|
|
382
|
+
expect(LaunchTargetSupersededSchema.parse(body)).toEqual(body);
|
|
383
|
+
});
|
|
384
|
+
|
|
385
|
+
it("a null activeChatId means start a new chat", () => {
|
|
386
|
+
expect(
|
|
387
|
+
LaunchTargetSupersededSchema.parse({
|
|
388
|
+
code: LAUNCH_TARGET_SUPERSEDED_CODE,
|
|
389
|
+
activeChatId: null,
|
|
390
|
+
}).activeChatId,
|
|
391
|
+
).toBeNull();
|
|
392
|
+
});
|
|
393
|
+
|
|
394
|
+
it("rejects any other code", () => {
|
|
395
|
+
expect(
|
|
396
|
+
LaunchTargetSupersededSchema.safeParse({
|
|
397
|
+
code: "conflict",
|
|
398
|
+
activeChatId: null,
|
|
399
|
+
}).success,
|
|
400
|
+
).toBe(false);
|
|
401
|
+
});
|
|
402
|
+
});
|