@company-semantics/contracts 97.0.0 → 98.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 +11 -6
- package/src/chat/README.md +31 -2
- package/src/chat/__tests__/alternatives.test.ts +121 -0
- package/src/chat/__tests__/chat-exports.test.ts +73 -1
- package/src/chat/__tests__/feedback-reasons.test.ts +99 -0
- package/src/chat/__tests__/message-actions.test.ts +41 -9
- package/src/chat/__tests__/message-mentions.test.ts +1 -0
- package/src/chat/__tests__/multi-party.test.ts +1 -0
- package/src/chat/__tests__/turn-wire.test.ts +43 -4
- package/src/chat/feedback.ts +80 -0
- package/src/chat/index.ts +26 -0
- package/src/chat/message-actions.ts +36 -30
- package/src/chat/schemas.ts +51 -1
- package/src/chat/types.ts +15 -0
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 = '51e9ee445972' as const;
|
|
3
|
+
export const SPEC_HASH_FULL = '51e9ee445972ebf381cd85489343494ecdd81453574bbec8696a038ef4fd327f' as const;
|
package/src/api/generated.ts
CHANGED
|
@@ -1712,7 +1712,7 @@ export interface paths {
|
|
|
1712
1712
|
};
|
|
1713
1713
|
/**
|
|
1714
1714
|
* 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
|
|
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, and the current execution epoch’s start and completion times. Org-scoped: a workflow in another workspace answers 404.
|
|
1716
1716
|
*/
|
|
1717
1717
|
get: operations["getAnalysisWorkflow"];
|
|
1718
1718
|
put?: never;
|
|
@@ -5652,7 +5652,16 @@ export interface components {
|
|
|
5652
5652
|
presentationGroupId: string | null;
|
|
5653
5653
|
/** @enum {string} */
|
|
5654
5654
|
turnKind: "conversation" | "workflow_launch" | "workflow_settlement" | "input_request_launch" | "proactive" | "refusal";
|
|
5655
|
-
|
|
5655
|
+
alternatives: {
|
|
5656
|
+
position: number;
|
|
5657
|
+
total: number;
|
|
5658
|
+
ids: string[];
|
|
5659
|
+
} | null;
|
|
5660
|
+
myFeedback: {
|
|
5661
|
+
/** @enum {string} */
|
|
5662
|
+
rating: "up" | "down";
|
|
5663
|
+
reason: ("incorrect" | "didnt_follow_instructions" | "not_helpful" | "missing_context" | "tool_or_workflow_issue" | "other") | null;
|
|
5664
|
+
} | null;
|
|
5656
5665
|
}[];
|
|
5657
5666
|
};
|
|
5658
5667
|
SuccessResponse: {
|
|
@@ -7756,10 +7765,6 @@ export interface components {
|
|
|
7756
7765
|
})[];
|
|
7757
7766
|
}) | null;
|
|
7758
7767
|
error?: string;
|
|
7759
|
-
capabilities: {
|
|
7760
|
-
canStop: boolean;
|
|
7761
|
-
canContinue: boolean;
|
|
7762
|
-
};
|
|
7763
7768
|
executionAffordances: ({
|
|
7764
7769
|
/** @constant */
|
|
7765
7770
|
id: "workflow.stop";
|
package/src/chat/README.md
CHANGED
|
@@ -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
|
|
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
|
|
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("
|
|
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
|
-
|
|
88
|
-
|
|
89
|
-
|
|
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("
|
|
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
|
-
|
|
150
|
+
for (const isLatestOnActiveBranch of [true, false]) {
|
|
151
|
+
expect(
|
|
152
|
+
messageActionsFor(makeCtx({ turnKind, isLatestOnActiveBranch })),
|
|
153
|
+
).toEqual(["copy"]);
|
|
154
|
+
}
|
|
123
155
|
}
|
|
124
156
|
});
|
|
125
157
|
|
|
@@ -9,7 +9,11 @@
|
|
|
9
9
|
* - A null group and a null rating parse: the turn stands alone / is unrated.
|
|
10
10
|
* - The group is a uuid; anything else is refused.
|
|
11
11
|
* - `turnKind` is CLOSED: an unknown kind is refused, never passed through.
|
|
12
|
-
* - `myFeedback` is
|
|
12
|
+
* - `myFeedback` is `{ rating, reason }` or null: the rating is exactly `up`
|
|
13
|
+
* or `down`, the reason one of the CLOSED FEEDBACK_REASONS or null, and the
|
|
14
|
+
* old bare-string shape is refused.
|
|
15
|
+
* - `alternatives` is REQUIRED; null means the turn has no sibling versions
|
|
16
|
+
* (the shape of a present value is pinned in alternatives.test.ts).
|
|
13
17
|
*/
|
|
14
18
|
import { describe, expect, it } from "vitest";
|
|
15
19
|
import { userPrincipal } from "../../identity/agent";
|
|
@@ -27,7 +31,8 @@ const makeMessage = (over: Record<string, unknown> = {}) => ({
|
|
|
27
31
|
author: { kind: "agent" as const },
|
|
28
32
|
presentationGroupId: "3f2b8c1e-6d4a-4f7b-9a2e-1c5d8e9f0a1b",
|
|
29
33
|
turnKind: "workflow_settlement" as const,
|
|
30
|
-
|
|
34
|
+
alternatives: null,
|
|
35
|
+
myFeedback: { rating: "up" as const, reason: null },
|
|
31
36
|
...over,
|
|
32
37
|
});
|
|
33
38
|
|
|
@@ -86,8 +91,42 @@ describe("ChatMessageSchema turn identity", () => {
|
|
|
86
91
|
|
|
87
92
|
it("refuses a rating other than up, down or null", () => {
|
|
88
93
|
expect(
|
|
89
|
-
ChatMessageSchema.safeParse(
|
|
90
|
-
|
|
94
|
+
ChatMessageSchema.safeParse(
|
|
95
|
+
makeMessage({ myFeedback: { rating: "neutral", reason: null } }),
|
|
96
|
+
).success,
|
|
97
|
+
).toBe(false);
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
it("refuses the bare-string rating shape", () => {
|
|
101
|
+
expect(
|
|
102
|
+
ChatMessageSchema.safeParse(makeMessage({ myFeedback: "up" })).success,
|
|
91
103
|
).toBe(false);
|
|
92
104
|
});
|
|
105
|
+
|
|
106
|
+
it("parses a down rating with a closed reason", () => {
|
|
107
|
+
const parsed = ChatMessageSchema.parse(
|
|
108
|
+
makeMessage({ myFeedback: { rating: "down", reason: "incorrect" } }),
|
|
109
|
+
);
|
|
110
|
+
expect(parsed.myFeedback).toEqual({ rating: "down", reason: "incorrect" });
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
it("refuses a reason outside FEEDBACK_REASONS", () => {
|
|
114
|
+
expect(
|
|
115
|
+
ChatMessageSchema.safeParse(
|
|
116
|
+
makeMessage({ myFeedback: { rating: "down", reason: "rude" } }),
|
|
117
|
+
).success,
|
|
118
|
+
).toBe(false);
|
|
119
|
+
});
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
describe("ChatMessageSchema alternatives", () => {
|
|
123
|
+
it("refuses a message without alternatives", () => {
|
|
124
|
+
expect(ChatMessageSchema.safeParse(without("alternatives")).success).toBe(
|
|
125
|
+
false,
|
|
126
|
+
);
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
it("parses null alternatives: the turn has no siblings", () => {
|
|
130
|
+
expect(ChatMessageSchema.parse(makeMessage()).alternatives).toBeNull();
|
|
131
|
+
});
|
|
93
132
|
});
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Chat message feedback (response-versions-and-feedback-reasons,
|
|
3
|
+
* ADR-CONTRACTS-228).
|
|
4
|
+
*
|
|
5
|
+
* Feedback MEASURES a message. It is never approval of anything the message
|
|
6
|
+
* proposed (chat-action-system rule 8): a thumbs-up does not authorize,
|
|
7
|
+
* confirm or execute.
|
|
8
|
+
*
|
|
9
|
+
* INVARIANTS:
|
|
10
|
+
* - FEEDBACK_REASONS is CLOSED and append-only.
|
|
11
|
+
* - A reason or a comment rides only a "down" rating.
|
|
12
|
+
* - LOAD ORDER: this module is a LEAF. It imports only zod and must NEVER
|
|
13
|
+
* import `./schemas` (or any module that does) — `./schemas` imports this
|
|
14
|
+
* file, and the reverse edge would reach its declarations in the temporal
|
|
15
|
+
* dead zone.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import { z } from "zod";
|
|
19
|
+
|
|
20
|
+
/** The two ratings a reader can give a turn. */
|
|
21
|
+
export const FEEDBACK_RATINGS = ["up", "down"] as const;
|
|
22
|
+
export const FeedbackRatingSchema = z.enum(FEEDBACK_RATINGS);
|
|
23
|
+
export type FeedbackRating = z.infer<typeof FeedbackRatingSchema>;
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Why a reader rated a turn down. CLOSED and append-only: a reason is a
|
|
27
|
+
* category the product can count, so free text belongs in `comment`, never
|
|
28
|
+
* here.
|
|
29
|
+
*/
|
|
30
|
+
export const FEEDBACK_REASONS = [
|
|
31
|
+
/** The answer states something false. */
|
|
32
|
+
"incorrect",
|
|
33
|
+
/** The answer ignored what the prompt asked for. */
|
|
34
|
+
"didnt_follow_instructions",
|
|
35
|
+
/** Accurate, but it did not help. */
|
|
36
|
+
"not_helpful",
|
|
37
|
+
/** The answer lacked context it should have had. */
|
|
38
|
+
"missing_context",
|
|
39
|
+
/** A tool call or launched workflow misbehaved. */
|
|
40
|
+
"tool_or_workflow_issue",
|
|
41
|
+
/** None of the above; the comment says why. */
|
|
42
|
+
"other",
|
|
43
|
+
] as const;
|
|
44
|
+
export const FeedbackReasonSchema = z.enum(FEEDBACK_REASONS);
|
|
45
|
+
export type FeedbackReason = z.infer<typeof FeedbackReasonSchema>;
|
|
46
|
+
|
|
47
|
+
/** Longest free-text comment a down rating may carry. */
|
|
48
|
+
export const FEEDBACK_COMMENT_MAX = 2000;
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Body of PUT /api/chats/{id}/messages/{messageId}/feedback (operationId
|
|
52
|
+
* putChatMessageFeedback). Named after the generated operation's component so
|
|
53
|
+
* the backend can replace its route-local schema of the same name.
|
|
54
|
+
*/
|
|
55
|
+
export const PutChatMessageFeedbackRequestSchema = z
|
|
56
|
+
.object({
|
|
57
|
+
rating: FeedbackRatingSchema,
|
|
58
|
+
reason: FeedbackReasonSchema.optional(),
|
|
59
|
+
comment: z.string().max(FEEDBACK_COMMENT_MAX).optional(),
|
|
60
|
+
})
|
|
61
|
+
.refine(
|
|
62
|
+
(b) =>
|
|
63
|
+
b.rating === "down" ||
|
|
64
|
+
(b.reason === undefined && b.comment === undefined),
|
|
65
|
+
{ message: "reason and comment ride only a down rating" },
|
|
66
|
+
);
|
|
67
|
+
export type PutChatMessageFeedbackRequest = z.infer<
|
|
68
|
+
typeof PutChatMessageFeedbackRequestSchema
|
|
69
|
+
>;
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* The reader's own rating as `ChatMessage.myFeedback` carries it. `reason` is
|
|
73
|
+
* null on an up rating and on a down rating given without one. The comment is
|
|
74
|
+
* write-only — it is never projected back onto the transcript.
|
|
75
|
+
*/
|
|
76
|
+
export const ChatMessageMyFeedbackSchema = z.object({
|
|
77
|
+
rating: FeedbackRatingSchema,
|
|
78
|
+
reason: FeedbackReasonSchema.nullable(),
|
|
79
|
+
});
|
|
80
|
+
export type ChatMessageMyFeedback = z.infer<typeof ChatMessageMyFeedbackSchema>;
|
package/src/chat/index.ts
CHANGED
|
@@ -41,6 +41,10 @@ export type {
|
|
|
41
41
|
ChatListResponse,
|
|
42
42
|
CreateChatResponse,
|
|
43
43
|
ChatMessage,
|
|
44
|
+
// Sibling versions of a turn and the branch-select request body
|
|
45
|
+
// (response-versions-and-feedback-reasons, ADR-CONTRACTS-228)
|
|
46
|
+
ChatMessageAlternatives,
|
|
47
|
+
ChatBranchSelectRequest,
|
|
44
48
|
// Mentions on a chat turn (ADR-CONTRACTS-160): a PrincipalRef target, so the
|
|
45
49
|
// agent is a legal mention target on the chat surface too.
|
|
46
50
|
ChatMessageMention,
|
|
@@ -88,6 +92,8 @@ export {
|
|
|
88
92
|
ChatListResponseSchema,
|
|
89
93
|
CreateChatResponseSchema,
|
|
90
94
|
ChatMessageSchema,
|
|
95
|
+
ChatMessageAlternativesSchema,
|
|
96
|
+
ChatBranchSelectRequestSchema,
|
|
91
97
|
ChatMessageMentionSchema,
|
|
92
98
|
ChatMentionableResponseSchema,
|
|
93
99
|
// Participants: the role vocabulary, one participant row, and the list
|
|
@@ -155,6 +161,26 @@ export {
|
|
|
155
161
|
isMessageActionAllowed,
|
|
156
162
|
} from "./message-actions";
|
|
157
163
|
|
|
164
|
+
// Feedback (response-versions-and-feedback-reasons): the rating vocabulary,
|
|
165
|
+
// the CLOSED reasons a down rating may carry, the PUT feedback request body and
|
|
166
|
+
// the reader's own rating as ChatMessage.myFeedback carries it. Feedback
|
|
167
|
+
// measures a turn; it is never approval.
|
|
168
|
+
export type {
|
|
169
|
+
FeedbackRating,
|
|
170
|
+
FeedbackReason,
|
|
171
|
+
PutChatMessageFeedbackRequest,
|
|
172
|
+
ChatMessageMyFeedback,
|
|
173
|
+
} from "./feedback";
|
|
174
|
+
export {
|
|
175
|
+
FEEDBACK_RATINGS,
|
|
176
|
+
FeedbackRatingSchema,
|
|
177
|
+
FEEDBACK_REASONS,
|
|
178
|
+
FeedbackReasonSchema,
|
|
179
|
+
FEEDBACK_COMMENT_MAX,
|
|
180
|
+
PutChatMessageFeedbackRequestSchema,
|
|
181
|
+
ChatMessageMyFeedbackSchema,
|
|
182
|
+
} from "./feedback";
|
|
183
|
+
|
|
158
184
|
// Runtime profile types and constants
|
|
159
185
|
export type {
|
|
160
186
|
ChatRuntimeProfile,
|
|
@@ -18,8 +18,11 @@
|
|
|
18
18
|
* - Regenerate is never offered on a workflow_launch, workflow_settlement,
|
|
19
19
|
* input_request_launch, proactive or refusal turn: it would ask the model for
|
|
20
20
|
* new prose without re-running the work.
|
|
21
|
-
* - Regenerate and edit are offered
|
|
22
|
-
*
|
|
21
|
+
* - Regenerate and edit are offered on ANY settled conversation turn, not only
|
|
22
|
+
* the latest: both BRANCH (add a sibling version) and never delete, so an
|
|
23
|
+
* older turn is as safe to act on as the latest. Edit only to the reader who
|
|
24
|
+
* authored the prompt (response-versions-and-feedback-reasons).
|
|
25
|
+
* - Position (`isLatestOnActiveBranch`) is on the context but gates NOTHING.
|
|
23
26
|
* - An input the schema does not recognize (an unknown kind or role) offers
|
|
24
27
|
* NOTHING. Unknown is never permissive.
|
|
25
28
|
*
|
|
@@ -39,9 +42,9 @@ export const MESSAGE_ACTION_IDS = [
|
|
|
39
42
|
"copy",
|
|
40
43
|
/** Rate the turn up or down. Measures a message; never approves anything. */
|
|
41
44
|
"feedback",
|
|
42
|
-
/** A new answer to the same request;
|
|
45
|
+
/** A new answer to the same request; adds a sibling version. Never re-runs work. */
|
|
43
46
|
"regenerate",
|
|
44
|
-
/** Rewrite the reader's own prompt;
|
|
47
|
+
/** Rewrite the reader's own prompt; adds a sibling version of it. */
|
|
45
48
|
"edit",
|
|
46
49
|
] as const;
|
|
47
50
|
|
|
@@ -60,17 +63,24 @@ export const MessageActionContextSchema = z.object({
|
|
|
60
63
|
* flight or awaiting settlement delivery.
|
|
61
64
|
*/
|
|
62
65
|
settled: z.boolean(),
|
|
63
|
-
/**
|
|
66
|
+
/**
|
|
67
|
+
* The turn is the latest of its role on the active branch. Carried for the
|
|
68
|
+
* reader's orientation; it no longer gates any action — regenerate and edit
|
|
69
|
+
* branch, so they are offered on older turns too.
|
|
70
|
+
*/
|
|
64
71
|
isLatestOnActiveBranch: z.boolean(),
|
|
65
72
|
/** The reader is the turn's author (a user turn they wrote). */
|
|
66
73
|
authoredByReader: z.boolean(),
|
|
67
74
|
});
|
|
68
75
|
export type MessageActionContext = z.infer<typeof MessageActionContextSchema>;
|
|
69
76
|
|
|
70
|
-
/**
|
|
77
|
+
/**
|
|
78
|
+
* `always` is offered on every settled turn; `authored` only on a settled user
|
|
79
|
+
* turn the reader wrote.
|
|
80
|
+
*/
|
|
71
81
|
type ActionRule = {
|
|
72
82
|
always: readonly MessageActionId[];
|
|
73
|
-
|
|
83
|
+
authored: readonly MessageActionId[];
|
|
74
84
|
};
|
|
75
85
|
|
|
76
86
|
/**
|
|
@@ -81,28 +91,28 @@ type ActionRule = {
|
|
|
81
91
|
*/
|
|
82
92
|
const TURN_ACTIONS = {
|
|
83
93
|
conversation: {
|
|
84
|
-
user: { always: ["copy"],
|
|
85
|
-
assistant: { always: ["copy", "feedback"],
|
|
94
|
+
user: { always: ["copy"], authored: ["edit"] },
|
|
95
|
+
assistant: { always: ["copy", "feedback", "regenerate"], authored: [] },
|
|
86
96
|
},
|
|
87
97
|
workflow_launch: {
|
|
88
|
-
user: { always: ["copy"],
|
|
89
|
-
assistant: { always: ["copy", "feedback"],
|
|
98
|
+
user: { always: ["copy"], authored: [] },
|
|
99
|
+
assistant: { always: ["copy", "feedback"], authored: [] },
|
|
90
100
|
},
|
|
91
101
|
workflow_settlement: {
|
|
92
|
-
user: { always: ["copy"],
|
|
93
|
-
assistant: { always: ["copy", "feedback"],
|
|
102
|
+
user: { always: ["copy"], authored: [] },
|
|
103
|
+
assistant: { always: ["copy", "feedback"], authored: [] },
|
|
94
104
|
},
|
|
95
105
|
input_request_launch: {
|
|
96
|
-
user: { always: ["copy"],
|
|
97
|
-
assistant: { always: ["copy", "feedback"],
|
|
106
|
+
user: { always: ["copy"], authored: [] },
|
|
107
|
+
assistant: { always: ["copy", "feedback"], authored: [] },
|
|
98
108
|
},
|
|
99
109
|
proactive: {
|
|
100
|
-
user: { always: ["copy"],
|
|
101
|
-
assistant: { always: ["copy"],
|
|
110
|
+
user: { always: ["copy"], authored: [] },
|
|
111
|
+
assistant: { always: ["copy"], authored: [] },
|
|
102
112
|
},
|
|
103
113
|
refusal: {
|
|
104
|
-
user: { always: ["copy"],
|
|
105
|
-
assistant: { always: ["copy"],
|
|
114
|
+
user: { always: ["copy"], authored: [] },
|
|
115
|
+
assistant: { always: ["copy"], authored: [] },
|
|
106
116
|
},
|
|
107
117
|
} as const satisfies Record<
|
|
108
118
|
TurnKind,
|
|
@@ -114,24 +124,20 @@ const TURN_ACTIONS = {
|
|
|
114
124
|
*
|
|
115
125
|
* - Input that does not parse (unknown kind, unknown role) → [].
|
|
116
126
|
* - Not settled → [].
|
|
117
|
-
* - User turn → its `always`, plus `
|
|
118
|
-
*
|
|
119
|
-
* -
|
|
120
|
-
* branch.
|
|
127
|
+
* - User turn → its `always`, plus `authored` iff authored by the reader.
|
|
128
|
+
* - Assistant turn → its `always`.
|
|
129
|
+
* - Position on the branch gates nothing.
|
|
121
130
|
*/
|
|
122
131
|
export function messageActionsFor(
|
|
123
132
|
ctx: MessageActionContext,
|
|
124
133
|
): readonly MessageActionId[] {
|
|
125
134
|
const parsed = MessageActionContextSchema.safeParse(ctx);
|
|
126
135
|
if (!parsed.success || !parsed.data.settled) return [];
|
|
127
|
-
const { role, turnKind,
|
|
128
|
-
parsed.data;
|
|
136
|
+
const { role, turnKind, authoredByReader } = parsed.data;
|
|
129
137
|
const rule: ActionRule = TURN_ACTIONS[turnKind][role];
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
: isLatestOnActiveBranch;
|
|
134
|
-
return latest ? [...rule.always, ...rule.latest] : [...rule.always];
|
|
138
|
+
return role === "user" && authoredByReader
|
|
139
|
+
? [...rule.always, ...rule.authored]
|
|
140
|
+
: [...rule.always];
|
|
135
141
|
}
|
|
136
142
|
|
|
137
143
|
/** Whether `action` is among `messageActionsFor(ctx)` — the backend's check. */
|
package/src/chat/schemas.ts
CHANGED
|
@@ -25,6 +25,7 @@ import {
|
|
|
25
25
|
proactiveChatFields,
|
|
26
26
|
TitleSourceSchema,
|
|
27
27
|
} from "./enums";
|
|
28
|
+
import { ChatMessageMyFeedbackSchema } from "./feedback";
|
|
28
29
|
import { TurnKindSchema } from "./turn-kind";
|
|
29
30
|
|
|
30
31
|
// The chat vocabulary and the SSE event contract, re-exported so this module
|
|
@@ -238,6 +239,41 @@ export const ChatDiscussionScopeSchema = z
|
|
|
238
239
|
})
|
|
239
240
|
.strict(); // an unknown key is an ERROR, not surplus
|
|
240
241
|
|
|
242
|
+
/**
|
|
243
|
+
* The VERSIONS of one turn (response-versions-and-feedback-reasons,
|
|
244
|
+
* ADR-CONTRACTS-228). Regenerating an answer or editing a prompt never deletes
|
|
245
|
+
* the turn it replaces — it ADDS A SIBLING VERSION, and the reader picks which
|
|
246
|
+
* one the active branch runs through.
|
|
247
|
+
*
|
|
248
|
+
* `ids` lists every sibling, this one included, ORDERED BY CREATION — so the
|
|
249
|
+
* selector reads "2 / 3" from `position` / `total` and steps through `ids`
|
|
250
|
+
* without a second fetch. `position` is 1-based. The refinements make the three
|
|
251
|
+
* fields agree by construction: a client never has to reconcile a `total` that
|
|
252
|
+
* disagrees with the list it was sent.
|
|
253
|
+
*/
|
|
254
|
+
export const ChatMessageAlternativesSchema = z
|
|
255
|
+
.object({
|
|
256
|
+
position: z.number().int().min(1),
|
|
257
|
+
total: z.number().int().min(1),
|
|
258
|
+
ids: z.array(z.string().uuid()),
|
|
259
|
+
})
|
|
260
|
+
.refine((a) => a.ids.length === a.total, {
|
|
261
|
+
message: "ids.length must equal total",
|
|
262
|
+
})
|
|
263
|
+
.refine((a) => a.position <= a.total, {
|
|
264
|
+
message: "position must not exceed total",
|
|
265
|
+
});
|
|
266
|
+
|
|
267
|
+
/**
|
|
268
|
+
* Request body that makes one version the active branch
|
|
269
|
+
* (response-versions-and-feedback-reasons). `messageId` names the SIBLING to
|
|
270
|
+
* select; the server re-checks it is a version of a turn in this chat — naming
|
|
271
|
+
* an id is a lookup, not authority.
|
|
272
|
+
*/
|
|
273
|
+
export const ChatBranchSelectRequestSchema = z.object({
|
|
274
|
+
messageId: z.string().uuid(),
|
|
275
|
+
});
|
|
276
|
+
|
|
241
277
|
export const ChatMessageSchema = z.object({
|
|
242
278
|
id: z.string(),
|
|
243
279
|
role: z.enum(["user", "assistant"]),
|
|
@@ -309,12 +345,26 @@ export const ChatMessageSchema = z.object({
|
|
|
309
345
|
*/
|
|
310
346
|
turnKind: TurnKindSchema,
|
|
311
347
|
|
|
348
|
+
/**
|
|
349
|
+
* The sibling VERSIONS of this turn, when it has been regenerated (answer)
|
|
350
|
+
* or edited (prompt) — see {@link ChatMessageAlternativesSchema}. REQUIRED,
|
|
351
|
+
* and null means this turn has no siblings: every row has an answer, so there
|
|
352
|
+
* is no absence to make optional. The transcript carries the ACTIVE branch
|
|
353
|
+
* only; the other versions are reachable by id, never inlined.
|
|
354
|
+
*/
|
|
355
|
+
alternatives: ChatMessageAlternativesSchema.nullable(),
|
|
356
|
+
|
|
312
357
|
/**
|
|
313
358
|
* The READER's current rating of this turn — the person this response was
|
|
314
359
|
* projected for, never an aggregate of other participants. REQUIRED so a
|
|
315
360
|
* reload restores the rating; null on user turns and on unrated turns.
|
|
361
|
+
*
|
|
362
|
+
* `{ rating, reason }` (response-versions-and-feedback-reasons): `reason` is
|
|
363
|
+
* one of the CLOSED FEEDBACK_REASONS, and only ever set on a down rating.
|
|
364
|
+
* Feedback MEASURES the turn — it is NOT APPROVAL of anything the turn
|
|
365
|
+
* proposed, and never authorizes, confirms or executes.
|
|
316
366
|
*/
|
|
317
|
-
myFeedback:
|
|
367
|
+
myFeedback: ChatMessageMyFeedbackSchema.nullable(),
|
|
318
368
|
});
|
|
319
369
|
|
|
320
370
|
// =============================================================================
|
package/src/chat/types.ts
CHANGED
|
@@ -30,6 +30,8 @@ import {
|
|
|
30
30
|
ChatListResponseSchema,
|
|
31
31
|
CreateChatResponseSchema,
|
|
32
32
|
ChatMessageSchema,
|
|
33
|
+
ChatMessageAlternativesSchema,
|
|
34
|
+
ChatBranchSelectRequestSchema,
|
|
33
35
|
ChatMessageMentionSchema,
|
|
34
36
|
ChatMentionableResponseSchema,
|
|
35
37
|
ChatParticipantRoleSchema,
|
|
@@ -227,6 +229,19 @@ export type CreateChatResponse = z.infer<typeof CreateChatResponseSchema>;
|
|
|
227
229
|
/** Individual message in a chat response (GET /api/chats/:id). */
|
|
228
230
|
export type ChatMessage = z.infer<typeof ChatMessageSchema>;
|
|
229
231
|
|
|
232
|
+
/**
|
|
233
|
+
* The sibling versions of one turn — 1-based `position`, `total`, and every
|
|
234
|
+
* sibling id ordered by creation (response-versions-and-feedback-reasons).
|
|
235
|
+
*/
|
|
236
|
+
export type ChatMessageAlternatives = z.infer<
|
|
237
|
+
typeof ChatMessageAlternativesSchema
|
|
238
|
+
>;
|
|
239
|
+
|
|
240
|
+
/** Request body that makes one sibling version the active branch. */
|
|
241
|
+
export type ChatBranchSelectRequest = z.infer<
|
|
242
|
+
typeof ChatBranchSelectRequestSchema
|
|
243
|
+
>;
|
|
244
|
+
|
|
230
245
|
/**
|
|
231
246
|
* One mention on a chat turn — a `PrincipalRef` target, a nullable UTF-16
|
|
232
247
|
* code-unit range into `content`, and a read-time display name.
|