@company-semantics/contracts 97.0.0 → 99.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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@company-semantics/contracts",
3
- "version": "97.0.0",
3
+ "version": "99.0.0",
4
4
  "private": false,
5
5
  "repository": {
6
6
  "type": "git",
@@ -1,3 +1,3 @@
1
1
  // AUTO-GENERATED — do not edit. Run pnpm generate:spec-hash to regenerate.
2
- export const SPEC_HASH = 'f571d3d78c72' as const;
3
- export const SPEC_HASH_FULL = 'f571d3d78c720d7ad11592b2d490a723c48c539d162c41a6dd77f67bb6ad922f' as const;
2
+ export const SPEC_HASH = '935ee995d53d' as const;
3
+ export const SPEC_HASH_FULL = '935ee995d53d56538f0566bb3c1372041d5cb1def41f3b28db02716ad4d8c591' as const;
@@ -421,6 +421,23 @@ export interface paths {
421
421
  patch?: never;
422
422
  trace?: never;
423
423
  };
424
+ "/api/chats/{id}/branch": {
425
+ parameters: {
426
+ query?: never;
427
+ header?: never;
428
+ path?: never;
429
+ cookie?: never;
430
+ };
431
+ get?: never;
432
+ put?: never;
433
+ /** Make one version of a turn the active branch */
434
+ post: operations["selectChatBranch"];
435
+ delete?: never;
436
+ options?: never;
437
+ head?: never;
438
+ patch?: never;
439
+ trace?: never;
440
+ };
424
441
  "/api/chats/{id}/pin": {
425
442
  parameters: {
426
443
  query?: never;
@@ -1712,7 +1729,7 @@ export interface paths {
1712
1729
  };
1713
1730
  /**
1714
1731
  * Read one analysis workflow’s generic lifecycle envelope
1715
- * @description The domain-agnostic projection a client polls: identity, registered type, activity-free lifecycle state, start and completion times, the opaque domain progress and result payloads (null until the domain adapter supplies them), a closed failure class on a failed workflow, the frozen typed outcome of the current execution epoch once it settles (null until then), the execution affordances offered now (the contracts offer table over state, the type’s execution support and the frozen outcome, filtered by the viewer’s policy), the workflow it was relaunched from, the current execution epoch’s start and completion times, and the legacy capabilities (canStop, canContinue) kept until their consumers move. Org-scoped: a workflow in another workspace answers 404.
1732
+ * @description The domain-agnostic projection a client polls: identity, registered type, activity-free lifecycle state, start and completion times, the opaque domain progress and result payloads (null until the domain adapter supplies them), a closed failure class on a failed workflow, the frozen typed outcome of the current execution epoch once it settles (null until then), the execution affordances offered now (the contracts offer table over state, the type’s execution support and the frozen outcome, filtered by the viewer’s policy), the workflow it was relaunched from, and the current execution epoch’s start and completion times. Org-scoped: a workflow in another workspace answers 404.
1716
1733
  */
1717
1734
  get: operations["getAnalysisWorkflow"];
1718
1735
  put?: never;
@@ -5652,7 +5669,16 @@ export interface components {
5652
5669
  presentationGroupId: string | null;
5653
5670
  /** @enum {string} */
5654
5671
  turnKind: "conversation" | "workflow_launch" | "workflow_settlement" | "input_request_launch" | "proactive" | "refusal";
5655
- myFeedback: ("up" | "down") | null;
5672
+ alternatives: {
5673
+ position: number;
5674
+ total: number;
5675
+ ids: string[];
5676
+ } | null;
5677
+ myFeedback: {
5678
+ /** @enum {string} */
5679
+ rating: "up" | "down";
5680
+ reason: ("incorrect" | "didnt_follow_instructions" | "not_helpful" | "missing_context" | "tool_or_workflow_issue" | "other") | null;
5681
+ } | null;
5656
5682
  }[];
5657
5683
  };
5658
5684
  SuccessResponse: {
@@ -5710,6 +5736,10 @@ export interface components {
5710
5736
  title: string;
5711
5737
  isAutoGenerated: boolean;
5712
5738
  };
5739
+ ChatBranchSelectRequest: {
5740
+ /** Format: uuid */
5741
+ messageId: string;
5742
+ };
5713
5743
  ChatSseEvent: {
5714
5744
  /** @constant */
5715
5745
  v: 1;
@@ -5978,14 +6008,16 @@ export interface components {
5978
6008
  ChatMessageFeedbackResponse: {
5979
6009
  /** @enum {string} */
5980
6010
  rating: "up" | "down";
5981
- reason: string | null;
6011
+ reason: ("incorrect" | "didnt_follow_instructions" | "not_helpful" | "missing_context" | "tool_or_workflow_issue" | "other") | null;
5982
6012
  /** Format: date-time */
5983
6013
  updatedAt: string;
5984
6014
  };
5985
6015
  PutChatMessageFeedbackRequest: {
5986
6016
  /** @enum {string} */
5987
6017
  rating: "up" | "down";
5988
- reason?: string;
6018
+ /** @enum {string} */
6019
+ reason?: "incorrect" | "didnt_follow_instructions" | "not_helpful" | "missing_context" | "tool_or_workflow_issue" | "other";
6020
+ comment?: string;
5989
6021
  };
5990
6022
  WorkspaceAccessResponse: {
5991
6023
  hasAccess: boolean;
@@ -7756,10 +7788,6 @@ export interface components {
7756
7788
  })[];
7757
7789
  }) | null;
7758
7790
  error?: string;
7759
- capabilities: {
7760
- canStop: boolean;
7761
- canContinue: boolean;
7762
- };
7763
7791
  executionAffordances: ({
7764
7792
  /** @constant */
7765
7793
  id: "workflow.stop";
@@ -12458,6 +12486,32 @@ export interface operations {
12458
12486
  };
12459
12487
  };
12460
12488
  };
12489
+ selectChatBranch: {
12490
+ parameters: {
12491
+ query?: never;
12492
+ header?: never;
12493
+ path: {
12494
+ id: string;
12495
+ };
12496
+ cookie?: never;
12497
+ };
12498
+ requestBody: {
12499
+ content: {
12500
+ "application/json": components["schemas"]["ChatBranchSelectRequest"];
12501
+ };
12502
+ };
12503
+ responses: {
12504
+ /** @description The chat with its new active branch */
12505
+ 200: {
12506
+ headers: {
12507
+ [name: string]: unknown;
12508
+ };
12509
+ content: {
12510
+ "application/json": components["schemas"]["GetChatResponse"];
12511
+ };
12512
+ };
12513
+ };
12514
+ };
12461
12515
  pinChat: {
12462
12516
  parameters: {
12463
12517
  query?: never;
@@ -59,16 +59,30 @@ A chat can belong to a SUBJECT — the durable case a hand-off to chat is about
59
59
 
60
60
  One turn is one PRESENTATION GROUP whose action row appears once, when the turn settles (control doctrine `chat-action-system`; ADR slug `chat-turn-identity-and-message-actions`).
61
61
 
62
- - `ChatMessage.presentationGroupId`, `turnKind` and `myFeedback` are REQUIRED: null for no group, a kind for every row, null for no rating. They shipped in a MAJOR so no consumer holds a second opinion about a missing key
62
+ - `ChatMessage.presentationGroupId`, `turnKind` and `myFeedback` are REQUIRED: null for no group, a kind for every row, null for no rating (otherwise `{ rating, reason }` — see "Versions and feedback"). They shipped in a MAJOR so no consumer holds a second opinion about a missing key
63
63
  - `TURN_KINDS` (`turn-kind.ts`) is CLOSED and append-only. The server derives the kind from what persisted the row; a client never infers it from content. A turn kind is not a principal kind and never changes `author`
64
64
  - `myFeedback` is the READER's own rating, per-reader like `myRole`
65
65
  - `ChatStreamMessageMetadataSchema` (`stream-metadata.ts`) rides the AI SDK `start` chunk. The live message adopts `messageId`, so a streaming turn already IS the persisted row, already grouped; every field means what the same-named `ChatMessage` field means
66
66
  - `messageActionsFor` (`message-actions.ts`) is the ONE eligibility function: the app renders the action row from it and the backend enforces the same function (`isMessageActionAllowed`) on every regenerate, edit and feedback request. Hiding a button is never the boundary
67
- - `messageActionsFor` is PURE and TOTAL. An unsettled turn offers nothing; input that does not parse offers nothing — unknown is never permissive. Regenerate is offered only on the latest settled `conversation` answer, edit only on the reader's own latest `conversation` prompt
67
+ - `messageActionsFor` is PURE and TOTAL. An unsettled turn offers nothing; input that does not parse offers nothing — unknown is never permissive. Regenerate is offered on ANY settled `conversation` answer and edit on ANY settled `conversation` prompt the reader authored — not only the latest: both add a sibling version (a branch) and never delete. `isLatestOnActiveBranch` stays on the context but gates nothing. Launch, settlement, input-request, proactive and refusal turns never offer regenerate
68
68
  - Its per-kind table is `satisfies Record<TurnKind, …>`, so a new kind fails to compile until its eligibility is decided
69
69
  - Execution actions (cancel, continue, retry, run again) are NOT message actions and are not here — they reach a person as affordances
70
70
  - Load order: `turn-kind.ts` is a LEAF (zod only). `schemas.ts`, `stream-metadata.ts` and `message-actions.ts` import it; the latter two never import `schemas.ts`, and `index.ts` re-exports all three directly
71
71
 
72
+ ## Versions and feedback
73
+
74
+ Regenerating an answer or editing a prompt ADDS A SIBLING VERSION of the turn — a branch in a message tree with an active leaf — and never deletes anything; a reader's rating of a turn carries a CLOSED reason (ADR slug `response-versions-and-feedback-reasons`).
75
+
76
+ - `ChatMessage.alternatives` is REQUIRED and NULLABLE: null means the turn has no siblings. Otherwise `{ position, total, ids }` — `position` is 1-based, `ids` lists every sibling (this one included) ORDERED BY CREATION, and the schema refuses `ids.length !== total` or `position > total`, so a client never reconciles fields that disagree
77
+ - The transcript carries the ACTIVE branch only. Other versions are reachable by id, never inlined
78
+ - `ChatBranchSelectRequestSchema` (`{ messageId }`) is the body that makes one sibling the active branch. Naming an id is a lookup, not authority — the server re-checks it is a version of a turn in this chat
79
+ - `FEEDBACK_RATINGS` is `up` / `down`. `FEEDBACK_REASONS` (`feedback.ts`) is CLOSED and append-only: a reason is a category the product can count, and free text belongs in `comment` (at most `FEEDBACK_COMMENT_MAX` = 2000 characters), never in a reason
80
+ - `PutChatMessageFeedbackRequestSchema` is the body of `putChatMessageFeedback`, named after the generated operation's component so the backend can replace its route-local schema of the same name. A reason or a comment rides only a `down` rating; the schema refuses either on `up`. It is a deliberate exception to "request schemas live in backend": the closed reason list is shared vocabulary the app's picker and the backend's check must agree on
81
+ - `ChatMessage.myFeedback` is `{ rating, reason } | null` — the READER's own rating; `reason` is null on an up rating and on a down rating given without one. The comment is write-only and never projected back onto the transcript
82
+ - Feedback MEASURES a turn. It is NOT APPROVAL of anything the turn proposed, and never authorizes, confirms or executes
83
+ - Load order: `feedback.ts` is a LEAF (zod only). `schemas.ts` imports it, it never imports `schemas.ts`, and `index.ts` re-exports it directly
84
+ - `openapi/backend.yaml` deliberately lags (contracts-first): its `ChatMessage` has no `alternatives`, its `myFeedback` is a bare rating, and its feedback `reason` is free text. It catches up when the backend regenerates its spec
85
+
72
86
  <!-- BEGIN GENERATED: readme-public-api — derived from code by `pnpm readme-api`. Do not edit. -->
73
87
 
74
88
  ## Public API
@@ -83,6 +97,8 @@ One turn is one PRESENTATION GROUP whose action row appears once, when the turn
83
97
  - `CHAT_PAGE_VIEWS` — The app route parser's fifteen `type` values, projected onto the wire.
84
98
  - `CHAT_RUNTIME_PROFILES` — Ordered list of available profiles for UI rendering.
85
99
  - `CONVERSATION_SUBJECT_KINDS` — The kinds of subject a chat can continue.
100
+ - `ChatBranchSelectRequest` _(type)_ — Request body that makes one sibling version the active branch.
101
+ - `ChatBranchSelectRequestSchema` — Request body that makes one version the active branch (response-versions-and-feedback-reasons).
86
102
  - `ChatByInteractionResponse` _(type)_ — Response for GET /api/chats/by-interaction/:interactionId
87
103
  - `ChatByInteractionResponseSchema` — Response for GET /api/chats/by-interaction/:interactionId
88
104
  - `ChatChangedField` _(type)_ — Fields that can change on a chat, used in changed array.
@@ -111,8 +127,12 @@ One turn is one PRESENTATION GROUP whose action row appears once, when the turn
111
127
  - `ChatMentionableResponse` _(type)_ — Response for the chat composer's mentionable-candidate endpoint.
112
128
  - `ChatMentionableResponseSchema` — Who the chat composer may offer as a mention target.
113
129
  - `ChatMessage` _(type)_ — Individual message in a chat response (GET /api/chats/:id).
130
+ - `ChatMessageAlternatives` _(type)_ — The sibling versions of one turn — 1-based `position`, `total`, and every sibling id ordered by creation…
131
+ - `ChatMessageAlternativesSchema` — The VERSIONS of one turn (response-versions-and-feedback-reasons, ).
114
132
  - `ChatMessageMention` _(type)_ — One mention on a chat turn — a `PrincipalRef` target, a nullable UTF-16 code-unit range into `content`, and a…
115
133
  - `ChatMessageMentionSchema` — ONE mention on a chat turn — field-for-field `CommentMentionSchema` (`../comments/schemas`), and deliberately…
134
+ - `ChatMessageMyFeedback` _(type)_
135
+ - `ChatMessageMyFeedbackSchema` — The reader's own rating as `ChatMessage.myFeedback` carries it.
116
136
  - `ChatMessageSchema`
117
137
  - `ChatOrigin` _(type)_ — How a chat was born: `user` or `proactive` (the system spoke first).
118
138
  - `ChatOriginSchema` — How a chat was born: by the user, or by the system speaking first (a proactive occurrence projected onto the…
@@ -143,6 +163,13 @@ One turn is one PRESENTATION GROUP whose action row appears once, when the turn
143
163
  - `CreateChatResponse` _(type)_ — Response for POST /api/chats
144
164
  - `CreateChatResponseSchema` — Response for POST /api/chats
145
165
  - `DEFAULT_CHAT_RUNTIME_PROFILE` — Default profile when none is specified in the request.
166
+ - `FEEDBACK_COMMENT_MAX` — Longest free-text comment a down rating may carry.
167
+ - `FEEDBACK_RATINGS` — The two ratings a reader can give a turn.
168
+ - `FEEDBACK_REASONS` — Why a reader rated a turn down.
169
+ - `FeedbackRating` _(type)_
170
+ - `FeedbackRatingSchema`
171
+ - `FeedbackReason` _(type)_
172
+ - `FeedbackReasonSchema`
146
173
  - `GetChatResponse` _(type)_ — Response for GET /api/chats/:id
147
174
  - `GetChatResponseSchema` — Response for GET /api/chats/:id
148
175
  - `InvalidateChatEvent` _(type)_ — Single-entity staleness signal.
@@ -164,6 +191,8 @@ One turn is one PRESENTATION GROUP whose action row appears once, when the turn
164
191
  - `MessageActionIdSchema`
165
192
  - `ProactiveChatResolution` _(type)_ — Resolving an occurrence id to the CURRENT reader's chat — one of the four bounded states `ready` (with…
166
193
  - `ProactiveChatResolutionSchema` — Resolving an occurrence id to THIS reader's chat (INV-PROACTIVE-LINK).
194
+ - `PutChatMessageFeedbackRequest` _(type)_
195
+ - `PutChatMessageFeedbackRequestSchema` — Body of PUT /api/chats/{id}/messages/{messageId}/feedback (operationId putChatMessageFeedback).
167
196
  - `RecognizedProactiveKind` _(type)_ — A wire `proactiveKind` after recognition: a kind this contracts version knows, or the raw string it did not —…
168
197
  - `ResolveLaunchResponse` _(type)_
169
198
  - `ResolveLaunchResponseSchema` — Response of the read-only launch resolution.
@@ -0,0 +1,121 @@
1
+ /**
2
+ * The sibling versions of one turn (response-versions-and-feedback-reasons,
3
+ * ADR-CONTRACTS-228).
4
+ *
5
+ * INVARIANTS TESTED:
6
+ * - `ChatMessage.alternatives` is null (no siblings) or a version position
7
+ * within the ordered sibling ids.
8
+ * - `ids` are uuids, `ids.length === total` and `1 <= position <= total`.
9
+ * - The branch-select request names one sibling by uuid `messageId`.
10
+ * - Both schemas and their types are reachable from the `./chat` barrel.
11
+ */
12
+ import { describe, expect, it } from "vitest";
13
+ import {
14
+ ChatBranchSelectRequestSchema,
15
+ ChatMessageAlternativesSchema,
16
+ ChatMessageSchema,
17
+ } from "../schemas.js";
18
+ import type {
19
+ ChatBranchSelectRequest,
20
+ ChatMessageAlternatives,
21
+ } from "../index.js";
22
+ import * as barrel from "../index.js";
23
+
24
+ const A = "11111111-1111-4111-8111-111111111111";
25
+ const B = "22222222-2222-4222-8222-222222222222";
26
+
27
+ const makeMessage = (alternatives: unknown) => ({
28
+ id: "msg_1",
29
+ role: "assistant" as const,
30
+ content: "Done.",
31
+ sequenceNumber: 1,
32
+ createdAt: "2026-10-08T00:00:00.000Z",
33
+ mentions: [],
34
+ author: { kind: "agent" as const },
35
+ presentationGroupId: null,
36
+ turnKind: "conversation" as const,
37
+ alternatives,
38
+ myFeedback: null,
39
+ });
40
+
41
+ describe("ChatMessageAlternativesSchema", () => {
42
+ it("parses null alternatives on a message: the turn has no siblings", () => {
43
+ expect(ChatMessageSchema.parse(makeMessage(null)).alternatives).toBeNull();
44
+ });
45
+
46
+ it("accepts a version position within ordered sibling ids", () => {
47
+ const alternatives: ChatMessageAlternatives = {
48
+ position: 2,
49
+ total: 2,
50
+ ids: [A, B],
51
+ };
52
+ expect(
53
+ ChatMessageSchema.parse(makeMessage(alternatives)).alternatives,
54
+ ).toEqual(alternatives);
55
+ });
56
+
57
+ it("refuses ids whose length disagrees with total", () => {
58
+ expect(
59
+ ChatMessageAlternativesSchema.safeParse({
60
+ position: 1,
61
+ total: 3,
62
+ ids: [A, B],
63
+ }).success,
64
+ ).toBe(false);
65
+ });
66
+
67
+ it("refuses a position beyond total", () => {
68
+ expect(
69
+ ChatMessageAlternativesSchema.safeParse({
70
+ position: 3,
71
+ total: 2,
72
+ ids: [A, B],
73
+ }).success,
74
+ ).toBe(false);
75
+ });
76
+
77
+ it("refuses a position below 1", () => {
78
+ expect(
79
+ ChatMessageAlternativesSchema.safeParse({
80
+ position: 0,
81
+ total: 2,
82
+ ids: [A, B],
83
+ }).success,
84
+ ).toBe(false);
85
+ });
86
+
87
+ it("refuses a non-uuid sibling id", () => {
88
+ expect(
89
+ ChatMessageAlternativesSchema.safeParse({
90
+ position: 1,
91
+ total: 1,
92
+ ids: ["msg_1"],
93
+ }).success,
94
+ ).toBe(false);
95
+ });
96
+ });
97
+
98
+ describe("ChatBranchSelectRequestSchema", () => {
99
+ it("accepts a uuid messageId", () => {
100
+ const body: ChatBranchSelectRequest = { messageId: A };
101
+ expect(ChatBranchSelectRequestSchema.safeParse(body).success).toBe(true);
102
+ });
103
+
104
+ it("refuses a missing or non-uuid messageId", () => {
105
+ expect(ChatBranchSelectRequestSchema.safeParse({}).success).toBe(false);
106
+ expect(
107
+ ChatBranchSelectRequestSchema.safeParse({ messageId: "msg_1" }).success,
108
+ ).toBe(false);
109
+ });
110
+ });
111
+
112
+ describe("chat barrel exports the alternatives vocabulary", () => {
113
+ it("re-exports the same schemas the module defines", () => {
114
+ expect(barrel.ChatMessageAlternativesSchema).toBe(
115
+ ChatMessageAlternativesSchema,
116
+ );
117
+ expect(barrel.ChatBranchSelectRequestSchema).toBe(
118
+ ChatBranchSelectRequestSchema,
119
+ );
120
+ });
121
+ });
@@ -11,19 +11,37 @@
11
11
  * MessageActionContextSchema, messageActionsFor and isMessageActionAllowed
12
12
  * are exported from the chat barrel
13
13
  * - messageActionsFor called through the barrel is the same function the
14
- * backend enforces: a settled, latest conversation answer offers regenerate
14
+ * backend enforces: a settled conversation answer offers regenerate, latest
15
+ * or not
16
+ * - The versions and feedback vocabulary (response-versions-and-feedback-reasons)
17
+ * is exported from the chat barrel: ChatMessageAlternativesSchema,
18
+ * ChatBranchSelectRequestSchema, FEEDBACK_RATINGS, FeedbackRatingSchema,
19
+ * FEEDBACK_REASONS, FeedbackReasonSchema, FEEDBACK_COMMENT_MAX (2000),
20
+ * PutChatMessageFeedbackRequestSchema and ChatMessageMyFeedbackSchema
15
21
  */
16
22
  import { describe, expect, it } from "vitest";
17
23
  import {
24
+ ChatBranchSelectRequestSchema,
25
+ ChatMessageAlternativesSchema,
26
+ ChatMessageMyFeedbackSchema,
18
27
  ChatStreamMessageMetadataSchema,
28
+ FEEDBACK_COMMENT_MAX,
29
+ FEEDBACK_RATINGS,
30
+ FEEDBACK_REASONS,
31
+ FeedbackRatingSchema,
32
+ FeedbackReasonSchema,
19
33
  isMessageActionAllowed,
20
34
  MESSAGE_ACTION_IDS,
21
35
  MessageActionContextSchema,
22
36
  messageActionsFor,
37
+ PutChatMessageFeedbackRequestSchema,
23
38
  TURN_KINDS,
24
39
  TurnKindSchema,
25
40
  } from "../index.js";
26
41
 
42
+ const ID_A = "11111111-1111-4111-8111-111111111111";
43
+ const ID_B = "22222222-2222-4222-8222-222222222222";
44
+
27
45
  describe("chat barrel exports the turn on the wire", () => {
28
46
  it("exports the closed vocabularies", () => {
29
47
  expect(TURN_KINDS).toContain("conversation");
@@ -58,4 +76,58 @@ describe("chat barrel exports the turn on the wire", () => {
58
76
  expect(isMessageActionAllowed(ctx, "regenerate")).toBe(true);
59
77
  expect(isMessageActionAllowed(ctx, "edit")).toBe(false);
60
78
  });
79
+
80
+ it("offers regenerate on an older answer through the barrel", () => {
81
+ const ctx = MessageActionContextSchema.parse({
82
+ role: "assistant",
83
+ turnKind: "conversation",
84
+ settled: true,
85
+ isLatestOnActiveBranch: false,
86
+ authoredByReader: false,
87
+ });
88
+ expect(isMessageActionAllowed(ctx, "regenerate")).toBe(true);
89
+ });
90
+ });
91
+
92
+ describe("chat barrel exports the versions and feedback vocabulary", () => {
93
+ it("exports the alternatives and branch-select schemas", () => {
94
+ expect(
95
+ ChatMessageAlternativesSchema.safeParse({
96
+ position: 2,
97
+ total: 2,
98
+ ids: [ID_A, ID_B],
99
+ }).success,
100
+ ).toBe(true);
101
+ expect(
102
+ ChatBranchSelectRequestSchema.safeParse({ messageId: ID_B }).success,
103
+ ).toBe(true);
104
+ });
105
+
106
+ it("exports the closed feedback vocabularies", () => {
107
+ expect(FEEDBACK_RATINGS).toEqual(["up", "down"]);
108
+ expect(FeedbackRatingSchema.options).toEqual([...FEEDBACK_RATINGS]);
109
+ expect(FEEDBACK_REASONS).toContain("other");
110
+ expect(FeedbackReasonSchema.options).toEqual([...FEEDBACK_REASONS]);
111
+ expect(FEEDBACK_COMMENT_MAX).toBe(2000);
112
+ });
113
+
114
+ it("exports the feedback request and myFeedback schemas", () => {
115
+ expect(
116
+ PutChatMessageFeedbackRequestSchema.safeParse({
117
+ rating: "down",
118
+ reason: "incorrect",
119
+ comment: "wrong date",
120
+ }).success,
121
+ ).toBe(true);
122
+ expect(
123
+ PutChatMessageFeedbackRequestSchema.safeParse({
124
+ rating: "up",
125
+ reason: "incorrect",
126
+ }).success,
127
+ ).toBe(false);
128
+ expect(
129
+ ChatMessageMyFeedbackSchema.safeParse({ rating: "up", reason: null })
130
+ .success,
131
+ ).toBe(true);
132
+ });
61
133
  });
@@ -0,0 +1,99 @@
1
+ /**
2
+ * Chat message feedback (response-versions-and-feedback-reasons).
3
+ *
4
+ * INVARIANTS TESTED:
5
+ * - A reason or a comment rides only a "down" rating; an up rating carrying
6
+ * either is refused.
7
+ * - A comment is at most FEEDBACK_COMMENT_MAX (2000) characters.
8
+ * - FEEDBACK_REASONS is CLOSED: a reason outside it is refused.
9
+ * - ChatMessageMyFeedback is `{ rating, reason }` with a nullable reason.
10
+ */
11
+ import { describe, expect, it } from "vitest";
12
+ import {
13
+ ChatMessageMyFeedbackSchema,
14
+ FEEDBACK_COMMENT_MAX,
15
+ FEEDBACK_RATINGS,
16
+ FEEDBACK_REASONS,
17
+ PutChatMessageFeedbackRequestSchema,
18
+ } from "../feedback.js";
19
+
20
+ describe("PutChatMessageFeedbackRequestSchema", () => {
21
+ it("accepts a bare up or down rating", () => {
22
+ for (const rating of FEEDBACK_RATINGS) {
23
+ expect(
24
+ PutChatMessageFeedbackRequestSchema.safeParse({ rating }).success,
25
+ ).toBe(true);
26
+ }
27
+ });
28
+
29
+ it("accepts a down rating with a reason and a comment", () => {
30
+ expect(
31
+ PutChatMessageFeedbackRequestSchema.safeParse({
32
+ rating: "down",
33
+ reason: "incorrect",
34
+ comment: "wrong date",
35
+ }).success,
36
+ ).toBe(true);
37
+ });
38
+
39
+ it("refuses a reason with rating up", () => {
40
+ expect(
41
+ PutChatMessageFeedbackRequestSchema.safeParse({
42
+ rating: "up",
43
+ reason: "other",
44
+ }).success,
45
+ ).toBe(false);
46
+ });
47
+
48
+ it("refuses a comment with rating up", () => {
49
+ expect(
50
+ PutChatMessageFeedbackRequestSchema.safeParse({
51
+ rating: "up",
52
+ comment: "nice",
53
+ }).success,
54
+ ).toBe(false);
55
+ });
56
+
57
+ it("refuses a comment over 2000 characters", () => {
58
+ expect(FEEDBACK_COMMENT_MAX).toBe(2000);
59
+ expect(
60
+ PutChatMessageFeedbackRequestSchema.safeParse({
61
+ rating: "down",
62
+ comment: "x".repeat(FEEDBACK_COMMENT_MAX + 1),
63
+ }).success,
64
+ ).toBe(false);
65
+ expect(
66
+ PutChatMessageFeedbackRequestSchema.safeParse({
67
+ rating: "down",
68
+ comment: "x".repeat(FEEDBACK_COMMENT_MAX),
69
+ }).success,
70
+ ).toBe(true);
71
+ });
72
+
73
+ it("refuses a reason outside FEEDBACK_REASONS", () => {
74
+ expect(
75
+ PutChatMessageFeedbackRequestSchema.safeParse({
76
+ rating: "down",
77
+ reason: "rude",
78
+ }).success,
79
+ ).toBe(false);
80
+ expect(FEEDBACK_REASONS).toContain("other");
81
+ });
82
+ });
83
+
84
+ describe("ChatMessageMyFeedbackSchema", () => {
85
+ it("carries a rating and a nullable reason", () => {
86
+ expect(
87
+ ChatMessageMyFeedbackSchema.parse({ rating: "up", reason: null }),
88
+ ).toEqual({ rating: "up", reason: null });
89
+ expect(
90
+ ChatMessageMyFeedbackSchema.parse({ rating: "down", reason: "other" }),
91
+ ).toEqual({ rating: "down", reason: "other" });
92
+ });
93
+
94
+ it("requires the reason key", () => {
95
+ expect(
96
+ ChatMessageMyFeedbackSchema.safeParse({ rating: "up" }).success,
97
+ ).toBe(false);
98
+ });
99
+ });
@@ -6,6 +6,8 @@
6
6
  * - One test per row of the doctrine matrix.
7
7
  * - An unsettled turn offers nothing.
8
8
  * - Regenerate is never offered on a launch, settlement or input-request turn.
9
+ * - Regenerate and edit are offered on older turns too — position gates
10
+ * nothing, because both branch rather than delete.
9
11
  * - Total over TURN_KINDS; an unknown kind or role is never permissive.
10
12
  * - isMessageActionAllowed agrees with messageActionsFor.
11
13
  */
@@ -74,7 +76,7 @@ describe("messageActionsFor", () => {
74
76
  ).toEqual(["copy", "edit"]);
75
77
  });
76
78
 
77
- it("a user turn that is not latest, or not the reader's own, offers copy only", () => {
79
+ it("an older user turn that is the reader's own still offers copy and edit (edit branches)", () => {
78
80
  expect(
79
81
  messageActionsFor(
80
82
  makeCtx({
@@ -83,10 +85,21 @@ describe("messageActionsFor", () => {
83
85
  isLatestOnActiveBranch: false,
84
86
  }),
85
87
  ),
86
- ).toEqual(["copy"]);
87
- expect(
88
- messageActionsFor(makeCtx({ role: "user", authoredByReader: false })),
89
- ).toEqual(["copy"]);
88
+ ).toEqual(["copy", "edit"]);
89
+ });
90
+
91
+ it("a user turn that is not the reader's own offers copy only, latest or not", () => {
92
+ for (const isLatestOnActiveBranch of [true, false]) {
93
+ expect(
94
+ messageActionsFor(
95
+ makeCtx({
96
+ role: "user",
97
+ authoredByReader: false,
98
+ isLatestOnActiveBranch,
99
+ }),
100
+ ),
101
+ ).toEqual(["copy"]);
102
+ }
90
103
  });
91
104
 
92
105
  it("the latest assistant conversation turn offers copy, feedback and regenerate", () => {
@@ -97,10 +110,25 @@ describe("messageActionsFor", () => {
97
110
  ]);
98
111
  });
99
112
 
100
- it("an older assistant conversation turn offers copy and feedback", () => {
113
+ it("offers regenerate on a historical conversation answer", () => {
101
114
  expect(
102
115
  messageActionsFor(makeCtx({ isLatestOnActiveBranch: false })),
103
- ).toEqual(["copy", "feedback"]);
116
+ ).toEqual(["copy", "feedback", "regenerate"]);
117
+ });
118
+
119
+ it("an older unsettled conversation turn still offers nothing", () => {
120
+ for (const role of ["user", "assistant"] as const) {
121
+ expect(
122
+ messageActionsFor(
123
+ makeCtx({
124
+ role,
125
+ settled: false,
126
+ isLatestOnActiveBranch: false,
127
+ authoredByReader: true,
128
+ }),
129
+ ),
130
+ ).toEqual([]);
131
+ }
104
132
  });
105
133
 
106
134
  it("never offers regenerate on a workflow_launch, workflow_settlement or input_request_launch turn", () => {
@@ -117,9 +145,13 @@ describe("messageActionsFor", () => {
117
145
  }
118
146
  });
119
147
 
120
- it("a proactive or refusal turn offers copy only", () => {
148
+ it("a proactive or refusal turn offers copy only, latest or not", () => {
121
149
  for (const turnKind of ["proactive", "refusal"] as const) {
122
- expect(messageActionsFor(makeCtx({ turnKind }))).toEqual(["copy"]);
150
+ for (const isLatestOnActiveBranch of [true, false]) {
151
+ expect(
152
+ messageActionsFor(makeCtx({ turnKind, isLatestOnActiveBranch })),
153
+ ).toEqual(["copy"]);
154
+ }
123
155
  }
124
156
  });
125
157
 
@@ -57,6 +57,7 @@ const makeMessage = (over: Record<string, unknown> = {}) => ({
57
57
  // REQUIRED turn identity — pinned in ./turn-wire.test.ts.
58
58
  presentationGroupId: null,
59
59
  turnKind: "conversation" as const,
60
+ alternatives: null,
60
61
  myFeedback: null,
61
62
  ...over,
62
63
  });
@@ -55,6 +55,7 @@ const makeMessage = (over: Record<string, unknown> = {}) => ({
55
55
  author: userPrincipal("user_1"),
56
56
  presentationGroupId: null,
57
57
  turnKind: "conversation" as const,
58
+ alternatives: null,
58
59
  myFeedback: null,
59
60
  ...over,
60
61
  });