@company-semantics/contracts 93.3.0 → 94.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 +4 -0
- package/src/chat/README.md +26 -0
- package/src/chat/__tests__/chat-exports.test.ts +61 -0
- package/src/chat/__tests__/message-actions.test.ts +208 -0
- package/src/chat/__tests__/message-mentions.test.ts +4 -0
- package/src/chat/__tests__/multi-party.test.ts +3 -0
- package/src/chat/__tests__/stream-metadata.test.ts +73 -0
- package/src/chat/__tests__/turn-kind.test.ts +34 -0
- package/src/chat/__tests__/turn-wire.test.ts +93 -0
- package/src/chat/index.ts +17 -0
- package/src/chat/message-actions.ts +143 -0
- package/src/chat/schemas.ts +30 -0
- package/src/chat/stream-metadata.ts +40 -0
- package/src/chat/turn-kind.ts +43 -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 = '826e3d4d23ef' as const;
|
|
3
|
+
export const SPEC_HASH_FULL = '826e3d4d23ef986d25b22b32497686755ce2b9bebc0ad90efd7768b4e1e076de' as const;
|
package/src/api/generated.ts
CHANGED
|
@@ -5569,6 +5569,10 @@ export interface components {
|
|
|
5569
5569
|
requestId: string;
|
|
5570
5570
|
questionId: string;
|
|
5571
5571
|
};
|
|
5572
|
+
presentationGroupId: string | null;
|
|
5573
|
+
/** @enum {string} */
|
|
5574
|
+
turnKind: "conversation" | "workflow_launch" | "workflow_settlement" | "input_request_launch" | "proactive" | "refusal";
|
|
5575
|
+
myFeedback: ("up" | "down") | null;
|
|
5572
5576
|
}[];
|
|
5573
5577
|
};
|
|
5574
5578
|
SuccessResponse: {
|
package/src/chat/README.md
CHANGED
|
@@ -55,6 +55,20 @@ A chat can belong to a SUBJECT — the durable case a hand-off to chat is about
|
|
|
55
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
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
57
|
|
|
58
|
+
## Turn on the wire
|
|
59
|
+
|
|
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
|
+
|
|
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
|
|
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
|
+
- `myFeedback` is the READER's own rating, per-reader like `myRole`
|
|
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
|
+
- `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
|
|
68
|
+
- Its per-kind table is `satisfies Record<TurnKind, …>`, so a new kind fails to compile until its eligibility is decided
|
|
69
|
+
- Execution actions (cancel, continue, retry, run again) are NOT message actions and are not here — they reach a person as affordances
|
|
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
|
+
|
|
58
72
|
<!-- BEGIN GENERATED: readme-public-api — derived from code by `pnpm readme-api`. Do not edit. -->
|
|
59
73
|
|
|
60
74
|
## Public API
|
|
@@ -114,6 +128,8 @@ A chat can belong to a SUBJECT — the durable case a hand-off to chat is about
|
|
|
114
128
|
- `ChatRuntimeProfileInfo` _(type)_ — Runtime profile metadata for UI rendering.
|
|
115
129
|
- `ChatSseEvent` _(type)_ — SSE event types for chat updates.
|
|
116
130
|
- `ChatSseEventSchema`
|
|
131
|
+
- `ChatStreamMessageMetadata` _(type)_
|
|
132
|
+
- `ChatStreamMessageMetadataSchema`
|
|
117
133
|
- `ChatSuccessResponse` _(type)_ — Generic mutation success confirmation.
|
|
118
134
|
- `ChatSuccessResponseSchema` — Generic success confirmation for mutations that return no entity data.
|
|
119
135
|
- `ChatSummary` _(type)_ — Lightweight chat summary for list views.
|
|
@@ -141,6 +157,11 @@ A chat can belong to a SUBJECT — the durable case a hand-off to chat is about
|
|
|
141
157
|
- `LaunchDispositionSchema`
|
|
142
158
|
- `LaunchTargetSuperseded` _(type)_
|
|
143
159
|
- `LaunchTargetSupersededSchema` — The 409 body the authoritative launch write returns when the posted chat is not — or can no longer become —…
|
|
160
|
+
- `MESSAGE_ACTION_IDS` — The message and conversation actions a turn's action row can carry.
|
|
161
|
+
- `MessageActionContext` _(type)_
|
|
162
|
+
- `MessageActionContextSchema` — The wire facts eligibility is decided from — all of them known to the app when it draws a turn and to the…
|
|
163
|
+
- `MessageActionId` _(type)_
|
|
164
|
+
- `MessageActionIdSchema`
|
|
144
165
|
- `ProactiveChatResolution` _(type)_ — Resolving an occurrence id to the CURRENT reader's chat — one of the four bounded states `ready` (with…
|
|
145
166
|
- `ProactiveChatResolutionSchema` — Resolving an occurrence id to THIS reader's chat (INV-PROACTIVE-LINK).
|
|
146
167
|
- `RecognizedProactiveKind` _(type)_ — A wire `proactiveKind` after recognition: a kind this contracts version knows, or the raw string it did not —…
|
|
@@ -148,12 +169,17 @@ A chat can belong to a SUBJECT — the durable case a hand-off to chat is about
|
|
|
148
169
|
- `ResolveLaunchResponseSchema` — Response of the read-only launch resolution.
|
|
149
170
|
- `StartFreshChatResponse` _(type)_
|
|
150
171
|
- `StartFreshChatResponseSchema` — Response of the start-fresh write: the NEW active generation of the subject, linked back to the chat it…
|
|
172
|
+
- `TURN_KINDS` — The kinds of chat turn.
|
|
151
173
|
- `TitleGenerationRequest` _(type)_ — Request to generate a title for a chat.
|
|
152
174
|
- `TitleGenerationResponse` _(type)_ — Response from title generation.
|
|
153
175
|
- `TitleGenerationResponseSchema`
|
|
154
176
|
- `TitleSource` _(type)_ — Title source - tracks how the title was set. - auto: Generated automatically by LLM - manual: Set by user…
|
|
155
177
|
- `TitleSourceSchema`
|
|
178
|
+
- `TurnKind` _(type)_
|
|
179
|
+
- `TurnKindSchema`
|
|
156
180
|
- `UpdateChatParticipantRequest` _(type)_ — Request for PATCH /api/chats/:chatId/participants/:userId — the owner moving someone between Read only and…
|
|
181
|
+
- `isMessageActionAllowed` — Whether `action` is among `messageActionsFor(ctx)` — the backend's check.
|
|
182
|
+
- `messageActionsFor` — The actions a turn offers, in MESSAGEACTIONIDS order. - Input that does not parse (unknown kind, unknown…
|
|
157
183
|
- `recognizeProactiveKind` — Recognize a wire `proactiveKind`.
|
|
158
184
|
|
|
159
185
|
<!-- END GENERATED: readme-public-api -->
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The turn-on-the-wire vocabulary is reachable from the `./chat` entry
|
|
3
|
+
* (chat-turn-identity-and-message-actions).
|
|
4
|
+
*
|
|
5
|
+
* The backend and the app import these from `@company-semantics/contracts/chat`,
|
|
6
|
+
* which maps to `src/chat/index.ts`. A symbol that exists in its module but is
|
|
7
|
+
* missing from the barrel is invisible to every consumer.
|
|
8
|
+
*
|
|
9
|
+
* INVARIANTS TESTED:
|
|
10
|
+
* - TURN_KINDS, ChatStreamMessageMetadataSchema, MESSAGE_ACTION_IDS,
|
|
11
|
+
* MessageActionContextSchema, messageActionsFor and isMessageActionAllowed
|
|
12
|
+
* are exported from the chat barrel
|
|
13
|
+
* - messageActionsFor called through the barrel is the same function the
|
|
14
|
+
* backend enforces: a settled, latest conversation answer offers regenerate
|
|
15
|
+
*/
|
|
16
|
+
import { describe, expect, it } from "vitest";
|
|
17
|
+
import {
|
|
18
|
+
ChatStreamMessageMetadataSchema,
|
|
19
|
+
isMessageActionAllowed,
|
|
20
|
+
MESSAGE_ACTION_IDS,
|
|
21
|
+
MessageActionContextSchema,
|
|
22
|
+
messageActionsFor,
|
|
23
|
+
TURN_KINDS,
|
|
24
|
+
TurnKindSchema,
|
|
25
|
+
} from "../index.js";
|
|
26
|
+
|
|
27
|
+
describe("chat barrel exports the turn on the wire", () => {
|
|
28
|
+
it("exports the closed vocabularies", () => {
|
|
29
|
+
expect(TURN_KINDS).toContain("conversation");
|
|
30
|
+
expect(TurnKindSchema.options).toEqual([...TURN_KINDS]);
|
|
31
|
+
expect(MESSAGE_ACTION_IDS).toEqual([
|
|
32
|
+
"copy",
|
|
33
|
+
"feedback",
|
|
34
|
+
"regenerate",
|
|
35
|
+
"edit",
|
|
36
|
+
]);
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
it("exports the live-stream metadata schema", () => {
|
|
40
|
+
const parsed = ChatStreamMessageMetadataSchema.safeParse({
|
|
41
|
+
messageId: "11111111-1111-4111-8111-111111111111",
|
|
42
|
+
presentationGroupId: null,
|
|
43
|
+
turnKind: "conversation",
|
|
44
|
+
createdAt: "2026-10-09T12:00:00.000Z",
|
|
45
|
+
});
|
|
46
|
+
expect(parsed.success).toBe(true);
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
it("calls messageActionsFor through the barrel", () => {
|
|
50
|
+
const ctx = MessageActionContextSchema.parse({
|
|
51
|
+
role: "assistant",
|
|
52
|
+
turnKind: "conversation",
|
|
53
|
+
settled: true,
|
|
54
|
+
isLatestOnActiveBranch: true,
|
|
55
|
+
authoredByReader: false,
|
|
56
|
+
});
|
|
57
|
+
expect(messageActionsFor(ctx)).toEqual(["copy", "feedback", "regenerate"]);
|
|
58
|
+
expect(isMessageActionAllowed(ctx, "regenerate")).toBe(true);
|
|
59
|
+
expect(isMessageActionAllowed(ctx, "edit")).toBe(false);
|
|
60
|
+
});
|
|
61
|
+
});
|
|
@@ -0,0 +1,208 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* messageActionsFor — the ONE eligibility decision the app renders from and
|
|
3
|
+
* the backend enforces (chat-action-system).
|
|
4
|
+
*
|
|
5
|
+
* INVARIANTS TESTED:
|
|
6
|
+
* - One test per row of the doctrine matrix.
|
|
7
|
+
* - An unsettled turn offers nothing.
|
|
8
|
+
* - Regenerate is never offered on a launch, settlement or input-request turn.
|
|
9
|
+
* - Total over TURN_KINDS; an unknown kind or role is never permissive.
|
|
10
|
+
* - isMessageActionAllowed agrees with messageActionsFor.
|
|
11
|
+
*/
|
|
12
|
+
import { describe, expect, it } from "vitest";
|
|
13
|
+
import {
|
|
14
|
+
MESSAGE_ACTION_IDS,
|
|
15
|
+
MessageActionContextSchema,
|
|
16
|
+
isMessageActionAllowed,
|
|
17
|
+
messageActionsFor,
|
|
18
|
+
type MessageActionContext,
|
|
19
|
+
} from "../message-actions.js";
|
|
20
|
+
import { TURN_KINDS } from "../turn-kind.js";
|
|
21
|
+
|
|
22
|
+
function makeCtx(
|
|
23
|
+
over: Partial<MessageActionContext> = {},
|
|
24
|
+
): MessageActionContext {
|
|
25
|
+
return {
|
|
26
|
+
role: "assistant",
|
|
27
|
+
turnKind: "conversation",
|
|
28
|
+
settled: true,
|
|
29
|
+
isLatestOnActiveBranch: true,
|
|
30
|
+
authoredByReader: false,
|
|
31
|
+
...over,
|
|
32
|
+
};
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
describe("MESSAGE_ACTION_IDS", () => {
|
|
36
|
+
it("names the four message and conversation actions, in order", () => {
|
|
37
|
+
expect(MESSAGE_ACTION_IDS).toEqual([
|
|
38
|
+
"copy",
|
|
39
|
+
"feedback",
|
|
40
|
+
"regenerate",
|
|
41
|
+
"edit",
|
|
42
|
+
]);
|
|
43
|
+
});
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
describe("MessageActionContextSchema", () => {
|
|
47
|
+
it("accepts a valid context and refuses an unknown turn kind", () => {
|
|
48
|
+
expect(MessageActionContextSchema.safeParse(makeCtx()).success).toBe(true);
|
|
49
|
+
expect(
|
|
50
|
+
MessageActionContextSchema.safeParse({
|
|
51
|
+
...makeCtx(),
|
|
52
|
+
turnKind: "tool_call",
|
|
53
|
+
}).success,
|
|
54
|
+
).toBe(false);
|
|
55
|
+
});
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
describe("messageActionsFor", () => {
|
|
59
|
+
it("an unsettled turn offers nothing", () => {
|
|
60
|
+
for (const turnKind of TURN_KINDS) {
|
|
61
|
+
for (const role of ["user", "assistant"] as const) {
|
|
62
|
+
expect(
|
|
63
|
+
messageActionsFor(
|
|
64
|
+
makeCtx({ role, turnKind, settled: false, authoredByReader: true }),
|
|
65
|
+
),
|
|
66
|
+
).toEqual([]);
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
it("a user turn that is latest and the reader's own offers copy and edit", () => {
|
|
72
|
+
expect(
|
|
73
|
+
messageActionsFor(makeCtx({ role: "user", authoredByReader: true })),
|
|
74
|
+
).toEqual(["copy", "edit"]);
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
it("a user turn that is not latest, or not the reader's own, offers copy only", () => {
|
|
78
|
+
expect(
|
|
79
|
+
messageActionsFor(
|
|
80
|
+
makeCtx({
|
|
81
|
+
role: "user",
|
|
82
|
+
authoredByReader: true,
|
|
83
|
+
isLatestOnActiveBranch: false,
|
|
84
|
+
}),
|
|
85
|
+
),
|
|
86
|
+
).toEqual(["copy"]);
|
|
87
|
+
expect(
|
|
88
|
+
messageActionsFor(makeCtx({ role: "user", authoredByReader: false })),
|
|
89
|
+
).toEqual(["copy"]);
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
it("the latest assistant conversation turn offers copy, feedback and regenerate", () => {
|
|
93
|
+
expect(messageActionsFor(makeCtx())).toEqual([
|
|
94
|
+
"copy",
|
|
95
|
+
"feedback",
|
|
96
|
+
"regenerate",
|
|
97
|
+
]);
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
it("an older assistant conversation turn offers copy and feedback", () => {
|
|
101
|
+
expect(
|
|
102
|
+
messageActionsFor(makeCtx({ isLatestOnActiveBranch: false })),
|
|
103
|
+
).toEqual(["copy", "feedback"]);
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
it("never offers regenerate on a workflow_launch, workflow_settlement or input_request_launch turn", () => {
|
|
107
|
+
for (const turnKind of [
|
|
108
|
+
"workflow_launch",
|
|
109
|
+
"workflow_settlement",
|
|
110
|
+
"input_request_launch",
|
|
111
|
+
] as const) {
|
|
112
|
+
for (const isLatestOnActiveBranch of [true, false]) {
|
|
113
|
+
expect(
|
|
114
|
+
messageActionsFor(makeCtx({ turnKind, isLatestOnActiveBranch })),
|
|
115
|
+
).toEqual(["copy", "feedback"]);
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
it("a proactive or refusal turn offers copy only", () => {
|
|
121
|
+
for (const turnKind of ["proactive", "refusal"] as const) {
|
|
122
|
+
expect(messageActionsFor(makeCtx({ turnKind }))).toEqual(["copy"]);
|
|
123
|
+
}
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
it("never offers edit on an assistant turn, nor regenerate on a user turn", () => {
|
|
127
|
+
for (const turnKind of TURN_KINDS) {
|
|
128
|
+
expect(
|
|
129
|
+
messageActionsFor(makeCtx({ turnKind, authoredByReader: true })),
|
|
130
|
+
).not.toContain("edit");
|
|
131
|
+
expect(
|
|
132
|
+
messageActionsFor(
|
|
133
|
+
makeCtx({ role: "user", turnKind, authoredByReader: true }),
|
|
134
|
+
),
|
|
135
|
+
).not.toContain("regenerate");
|
|
136
|
+
}
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
it("is total: every turn kind and role yields a list of known actions", () => {
|
|
140
|
+
// `satisfies Record<TurnKind, …>` proves totality at compile time; this
|
|
141
|
+
// loops the vocabulary at runtime.
|
|
142
|
+
for (const turnKind of TURN_KINDS) {
|
|
143
|
+
for (const role of ["user", "assistant"] as const) {
|
|
144
|
+
const actions = messageActionsFor(makeCtx({ role, turnKind }));
|
|
145
|
+
expect(actions).toContain("copy");
|
|
146
|
+
for (const action of actions)
|
|
147
|
+
expect(MESSAGE_ACTION_IDS).toContain(action);
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
it("an unknown turn kind or role offers nothing", () => {
|
|
153
|
+
const unknownKind = {
|
|
154
|
+
...makeCtx(),
|
|
155
|
+
turnKind: "tool_call",
|
|
156
|
+
} as unknown as MessageActionContext;
|
|
157
|
+
const unknownRole = {
|
|
158
|
+
...makeCtx(),
|
|
159
|
+
role: "system",
|
|
160
|
+
} as unknown as MessageActionContext;
|
|
161
|
+
const inherited = {
|
|
162
|
+
...makeCtx(),
|
|
163
|
+
turnKind: "toString",
|
|
164
|
+
} as unknown as MessageActionContext;
|
|
165
|
+
expect(messageActionsFor(unknownKind)).toEqual([]);
|
|
166
|
+
expect(messageActionsFor(unknownRole)).toEqual([]);
|
|
167
|
+
expect(messageActionsFor(inherited)).toEqual([]);
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
it("is pure: the same input yields the same result and the result is a fresh list", () => {
|
|
171
|
+
const first = messageActionsFor(makeCtx());
|
|
172
|
+
const second = messageActionsFor(makeCtx());
|
|
173
|
+
expect(first).toEqual(second);
|
|
174
|
+
expect(first).not.toBe(second);
|
|
175
|
+
});
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
describe("isMessageActionAllowed", () => {
|
|
179
|
+
it("agrees with messageActionsFor for every kind, role, position and action", () => {
|
|
180
|
+
for (const turnKind of TURN_KINDS) {
|
|
181
|
+
for (const role of ["user", "assistant"] as const) {
|
|
182
|
+
for (const isLatestOnActiveBranch of [true, false]) {
|
|
183
|
+
const ctx = makeCtx({
|
|
184
|
+
role,
|
|
185
|
+
turnKind,
|
|
186
|
+
isLatestOnActiveBranch,
|
|
187
|
+
authoredByReader: true,
|
|
188
|
+
});
|
|
189
|
+
const offered = messageActionsFor(ctx);
|
|
190
|
+
for (const action of MESSAGE_ACTION_IDS) {
|
|
191
|
+
expect(isMessageActionAllowed(ctx, action)).toBe(
|
|
192
|
+
offered.includes(action),
|
|
193
|
+
);
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
it("refuses regenerate on a settled workflow turn the server is asked to regenerate", () => {
|
|
201
|
+
expect(
|
|
202
|
+
isMessageActionAllowed(
|
|
203
|
+
makeCtx({ turnKind: "workflow_settlement" }),
|
|
204
|
+
"regenerate",
|
|
205
|
+
),
|
|
206
|
+
).toBe(false);
|
|
207
|
+
});
|
|
208
|
+
});
|
|
@@ -54,6 +54,10 @@ const makeMessage = (over: Record<string, unknown> = {}) => ({
|
|
|
54
54
|
// required-ness itself is pinned in ./multi-party.test.ts; here it is just
|
|
55
55
|
// part of a well-formed message.
|
|
56
56
|
author: userPrincipal("user_1"),
|
|
57
|
+
// REQUIRED turn identity — pinned in ./turn-wire.test.ts.
|
|
58
|
+
presentationGroupId: null,
|
|
59
|
+
turnKind: "conversation" as const,
|
|
60
|
+
myFeedback: null,
|
|
57
61
|
...over,
|
|
58
62
|
});
|
|
59
63
|
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ChatStreamMessageMetadataSchema — the AI SDK `start` chunk metadata
|
|
3
|
+
* (chat-action-system).
|
|
4
|
+
*
|
|
5
|
+
* INVARIANTS TESTED:
|
|
6
|
+
* - a valid metadata value round-trips unchanged
|
|
7
|
+
* - a null presentation group parses (the turn stands alone)
|
|
8
|
+
* - a non-uuid messageId is refused
|
|
9
|
+
* - each field is required; an unknown turnKind is refused
|
|
10
|
+
*/
|
|
11
|
+
import { describe, expect, it } from "vitest";
|
|
12
|
+
import {
|
|
13
|
+
ChatStreamMessageMetadataSchema,
|
|
14
|
+
type ChatStreamMessageMetadata,
|
|
15
|
+
} from "../stream-metadata.js";
|
|
16
|
+
|
|
17
|
+
function makeMetadata(
|
|
18
|
+
over: Partial<Record<keyof ChatStreamMessageMetadata, unknown>> = {},
|
|
19
|
+
): Record<string, unknown> {
|
|
20
|
+
return {
|
|
21
|
+
messageId: "6f1c2a3b-4d5e-4f60-8a7b-9c0d1e2f3a4b",
|
|
22
|
+
presentationGroupId: "0e9d8c7b-6a5f-4e3d-9c2b-1a0f9e8d7c6b",
|
|
23
|
+
turnKind: "workflow_launch",
|
|
24
|
+
createdAt: "2026-10-08T12:00:00.000Z",
|
|
25
|
+
...over,
|
|
26
|
+
};
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
describe("ChatStreamMessageMetadataSchema", () => {
|
|
30
|
+
it("round-trips a valid metadata value", () => {
|
|
31
|
+
const value = makeMetadata();
|
|
32
|
+
expect(ChatStreamMessageMetadataSchema.parse(value)).toEqual(value);
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
it("accepts a null presentationGroupId", () => {
|
|
36
|
+
const value = makeMetadata({
|
|
37
|
+
presentationGroupId: null,
|
|
38
|
+
turnKind: "conversation",
|
|
39
|
+
});
|
|
40
|
+
expect(ChatStreamMessageMetadataSchema.parse(value)).toEqual(value);
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
it("refuses a non-uuid messageId", () => {
|
|
44
|
+
expect(
|
|
45
|
+
ChatStreamMessageMetadataSchema.safeParse(
|
|
46
|
+
makeMetadata({ messageId: "msg-1" }),
|
|
47
|
+
).success,
|
|
48
|
+
).toBe(false);
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
it("refuses a value missing any field", () => {
|
|
52
|
+
for (const key of [
|
|
53
|
+
"messageId",
|
|
54
|
+
"presentationGroupId",
|
|
55
|
+
"turnKind",
|
|
56
|
+
"createdAt",
|
|
57
|
+
] as const) {
|
|
58
|
+
const value = makeMetadata();
|
|
59
|
+
delete value[key];
|
|
60
|
+
expect(ChatStreamMessageMetadataSchema.safeParse(value).success).toBe(
|
|
61
|
+
false,
|
|
62
|
+
);
|
|
63
|
+
}
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
it("refuses an unknown turnKind", () => {
|
|
67
|
+
expect(
|
|
68
|
+
ChatStreamMessageMetadataSchema.safeParse(
|
|
69
|
+
makeMetadata({ turnKind: "execution" }),
|
|
70
|
+
).success,
|
|
71
|
+
).toBe(false);
|
|
72
|
+
});
|
|
73
|
+
});
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* TURN_KINDS — the closed vocabulary of chat turn kinds (chat-action-system).
|
|
3
|
+
*
|
|
4
|
+
* INVARIANTS TESTED:
|
|
5
|
+
* - the union is exactly the six kinds, in declaration order
|
|
6
|
+
* - TurnKindSchema accepts every member and refuses anything else
|
|
7
|
+
*/
|
|
8
|
+
import { describe, expect, it } from "vitest";
|
|
9
|
+
import { TURN_KINDS, TurnKindSchema } from "../turn-kind.js";
|
|
10
|
+
|
|
11
|
+
describe("TURN_KINDS", () => {
|
|
12
|
+
it("names the six turn kinds, in order", () => {
|
|
13
|
+
expect(TURN_KINDS).toEqual([
|
|
14
|
+
"conversation",
|
|
15
|
+
"workflow_launch",
|
|
16
|
+
"workflow_settlement",
|
|
17
|
+
"input_request_launch",
|
|
18
|
+
"proactive",
|
|
19
|
+
"refusal",
|
|
20
|
+
]);
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
it("TurnKindSchema accepts every member", () => {
|
|
24
|
+
for (const kind of TURN_KINDS) {
|
|
25
|
+
expect(TurnKindSchema.parse(kind)).toBe(kind);
|
|
26
|
+
}
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
it("TurnKindSchema refuses an unknown kind", () => {
|
|
30
|
+
expect(TurnKindSchema.safeParse("execution").success).toBe(false);
|
|
31
|
+
expect(TurnKindSchema.safeParse("Conversation").success).toBe(false);
|
|
32
|
+
expect(TurnKindSchema.safeParse(null).success).toBe(false);
|
|
33
|
+
});
|
|
34
|
+
});
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The chat turn on the wire (chat-action-system).
|
|
3
|
+
*
|
|
4
|
+
* INVARIANTS TESTED:
|
|
5
|
+
* - `presentationGroupId`, `turnKind` and `myFeedback` are REQUIRED on every
|
|
6
|
+
* chat message. Every row has an answer — null for no group, a kind for every
|
|
7
|
+
* row, null for no rating — so a missing field is a backend that has not
|
|
8
|
+
* adopted this release, and refusing it is the intended failure.
|
|
9
|
+
* - A null group and a null rating parse: the turn stands alone / is unrated.
|
|
10
|
+
* - The group is a uuid; anything else is refused.
|
|
11
|
+
* - `turnKind` is CLOSED: an unknown kind is refused, never passed through.
|
|
12
|
+
* - `myFeedback` is exactly `up`, `down` or null.
|
|
13
|
+
*/
|
|
14
|
+
import { describe, expect, it } from "vitest";
|
|
15
|
+
import { userPrincipal } from "../../identity/agent";
|
|
16
|
+
import { ChatMessageSchema } from "../schemas.js";
|
|
17
|
+
import { TURN_KINDS } from "../turn-kind.js";
|
|
18
|
+
|
|
19
|
+
// One well-formed message; every negative case changes exactly ONE field.
|
|
20
|
+
const makeMessage = (over: Record<string, unknown> = {}) => ({
|
|
21
|
+
id: "msg_1",
|
|
22
|
+
role: "assistant" as const,
|
|
23
|
+
content: "Done.",
|
|
24
|
+
sequenceNumber: 1,
|
|
25
|
+
createdAt: "2026-10-08T00:00:00.000Z",
|
|
26
|
+
mentions: [],
|
|
27
|
+
author: { kind: "agent" as const },
|
|
28
|
+
presentationGroupId: "3f2b8c1e-6d4a-4f7b-9a2e-1c5d8e9f0a1b",
|
|
29
|
+
turnKind: "workflow_settlement" as const,
|
|
30
|
+
myFeedback: "up" as const,
|
|
31
|
+
...over,
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
const without = (key: string) => {
|
|
35
|
+
const message: Record<string, unknown> = makeMessage();
|
|
36
|
+
delete message[key];
|
|
37
|
+
return message;
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
describe("ChatMessageSchema turn identity", () => {
|
|
41
|
+
it("accepts a well-formed grouped, rated turn", () => {
|
|
42
|
+
expect(ChatMessageSchema.safeParse(makeMessage()).success).toBe(true);
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
it("refuses a message without presentationGroupId, turnKind or myFeedback", () => {
|
|
46
|
+
for (const key of ["presentationGroupId", "turnKind", "myFeedback"]) {
|
|
47
|
+
expect(ChatMessageSchema.safeParse(without(key)).success).toBe(false);
|
|
48
|
+
}
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
it("parses a null group and a null rating", () => {
|
|
52
|
+
const parsed = ChatMessageSchema.parse(
|
|
53
|
+
makeMessage({
|
|
54
|
+
role: "user",
|
|
55
|
+
author: userPrincipal("user_1"),
|
|
56
|
+
presentationGroupId: null,
|
|
57
|
+
turnKind: "conversation",
|
|
58
|
+
myFeedback: null,
|
|
59
|
+
}),
|
|
60
|
+
);
|
|
61
|
+
expect(parsed.presentationGroupId).toBeNull();
|
|
62
|
+
expect(parsed.myFeedback).toBeNull();
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
it("accepts every turn kind", () => {
|
|
66
|
+
for (const turnKind of TURN_KINDS) {
|
|
67
|
+
expect(
|
|
68
|
+
ChatMessageSchema.safeParse(makeMessage({ turnKind })).success,
|
|
69
|
+
).toBe(true);
|
|
70
|
+
}
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
it("refuses an unknown turnKind", () => {
|
|
74
|
+
expect(
|
|
75
|
+
ChatMessageSchema.safeParse(makeMessage({ turnKind: "workflow" }))
|
|
76
|
+
.success,
|
|
77
|
+
).toBe(false);
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
it("refuses a non-uuid presentationGroupId", () => {
|
|
81
|
+
expect(
|
|
82
|
+
ChatMessageSchema.safeParse(makeMessage({ presentationGroupId: "run_1" }))
|
|
83
|
+
.success,
|
|
84
|
+
).toBe(false);
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
it("refuses a rating other than up, down or null", () => {
|
|
88
|
+
expect(
|
|
89
|
+
ChatMessageSchema.safeParse(makeMessage({ myFeedback: "neutral" }))
|
|
90
|
+
.success,
|
|
91
|
+
).toBe(false);
|
|
92
|
+
});
|
|
93
|
+
});
|
package/src/chat/index.ts
CHANGED
|
@@ -138,6 +138,23 @@ export {
|
|
|
138
138
|
LaunchTargetSupersededSchema,
|
|
139
139
|
} from "./continuity";
|
|
140
140
|
|
|
141
|
+
// The turn on the wire (chat-turn-identity-and-message-actions): the closed
|
|
142
|
+
// turn-kind vocabulary, the live-stream message metadata for the AI SDK start
|
|
143
|
+
// chunk, and the one pure eligibility function the app renders from and the
|
|
144
|
+
// backend enforces (doctrine: chat-action-system)
|
|
145
|
+
export type { TurnKind } from "./turn-kind";
|
|
146
|
+
export { TURN_KINDS, TurnKindSchema } from "./turn-kind";
|
|
147
|
+
export type { ChatStreamMessageMetadata } from "./stream-metadata";
|
|
148
|
+
export { ChatStreamMessageMetadataSchema } from "./stream-metadata";
|
|
149
|
+
export type { MessageActionId, MessageActionContext } from "./message-actions";
|
|
150
|
+
export {
|
|
151
|
+
MESSAGE_ACTION_IDS,
|
|
152
|
+
MessageActionIdSchema,
|
|
153
|
+
MessageActionContextSchema,
|
|
154
|
+
messageActionsFor,
|
|
155
|
+
isMessageActionAllowed,
|
|
156
|
+
} from "./message-actions";
|
|
157
|
+
|
|
141
158
|
// Runtime profile types and constants
|
|
142
159
|
export type {
|
|
143
160
|
ChatRuntimeProfile,
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Chat message action eligibility (chat-action-system).
|
|
3
|
+
*
|
|
4
|
+
* Which MESSAGE actions (copy, feedback) and CONVERSATION actions (regenerate,
|
|
5
|
+
* edit) a chat turn offers is decided by ONE pure function over wire facts:
|
|
6
|
+
* the turn's role, its TurnKind and its position. The app renders the action
|
|
7
|
+
* row from `messageActionsFor`; the backend enforces the SAME function on every
|
|
8
|
+
* regenerate, edit and feedback request. Hiding a button is never the security
|
|
9
|
+
* boundary.
|
|
10
|
+
*
|
|
11
|
+
* Execution actions (Cancel, Continue, Retry operation, Run again) are NOT
|
|
12
|
+
* here: they act on durable work, not on a message, and reach a person as
|
|
13
|
+
* affordances from the closed catalog (affordance-system).
|
|
14
|
+
*
|
|
15
|
+
* INVARIANTS:
|
|
16
|
+
* - PURE and TOTAL: no I/O, no clock, no defaults. Every input yields a list.
|
|
17
|
+
* - An unsettled turn offers NOTHING — the row appears once, when it settles.
|
|
18
|
+
* - Regenerate is never offered on a workflow_launch, workflow_settlement,
|
|
19
|
+
* input_request_launch, proactive or refusal turn: it would ask the model for
|
|
20
|
+
* new prose without re-running the work.
|
|
21
|
+
* - Regenerate and edit are offered only on the latest turn of the active
|
|
22
|
+
* branch; edit only to the reader who authored it.
|
|
23
|
+
* - An input the schema does not recognize (an unknown kind or role) offers
|
|
24
|
+
* NOTHING. Unknown is never permissive.
|
|
25
|
+
*
|
|
26
|
+
* LOAD ORDER: imports only zod and `./turn-kind` (a leaf); never `./schemas`.
|
|
27
|
+
*/
|
|
28
|
+
|
|
29
|
+
import { z } from "zod";
|
|
30
|
+
import { TurnKindSchema, type TurnKind } from "./turn-kind";
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* The message and conversation actions a turn's action row can carry. CLOSED
|
|
34
|
+
* and append-only. The order is the order the row draws them in, and the
|
|
35
|
+
* order `messageActionsFor` returns them in.
|
|
36
|
+
*/
|
|
37
|
+
export const MESSAGE_ACTION_IDS = [
|
|
38
|
+
/** Copy the turn's readable prose. Side-effect-free. */
|
|
39
|
+
"copy",
|
|
40
|
+
/** Rate the turn up or down. Measures a message; never approves anything. */
|
|
41
|
+
"feedback",
|
|
42
|
+
/** A new answer to the same request; supersedes the old one. Never re-runs work. */
|
|
43
|
+
"regenerate",
|
|
44
|
+
/** Rewrite the reader's own prompt; supersedes the branch after it. */
|
|
45
|
+
"edit",
|
|
46
|
+
] as const;
|
|
47
|
+
|
|
48
|
+
export const MessageActionIdSchema = z.enum(MESSAGE_ACTION_IDS);
|
|
49
|
+
export type MessageActionId = z.infer<typeof MessageActionIdSchema>;
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* The wire facts eligibility is decided from — all of them known to the app
|
|
53
|
+
* when it draws a turn and to the backend when it receives a request.
|
|
54
|
+
*/
|
|
55
|
+
export const MessageActionContextSchema = z.object({
|
|
56
|
+
role: z.enum(["user", "assistant"]),
|
|
57
|
+
turnKind: TurnKindSchema,
|
|
58
|
+
/**
|
|
59
|
+
* The turn is done: not streaming, and no launched workflow behind it is in
|
|
60
|
+
* flight or awaiting settlement delivery.
|
|
61
|
+
*/
|
|
62
|
+
settled: z.boolean(),
|
|
63
|
+
/** The turn is the latest of its role on the active branch. */
|
|
64
|
+
isLatestOnActiveBranch: z.boolean(),
|
|
65
|
+
/** The reader is the turn's author (a user turn they wrote). */
|
|
66
|
+
authoredByReader: z.boolean(),
|
|
67
|
+
});
|
|
68
|
+
export type MessageActionContext = z.infer<typeof MessageActionContextSchema>;
|
|
69
|
+
|
|
70
|
+
/** `always` is offered on every settled turn; `latest` only on the latest. */
|
|
71
|
+
type ActionRule = {
|
|
72
|
+
always: readonly MessageActionId[];
|
|
73
|
+
latest: readonly MessageActionId[];
|
|
74
|
+
};
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* The eligibility table. `satisfies Record<TurnKind, …>` makes a new TurnKind a
|
|
78
|
+
* compile error here until its eligibility is decided. Only a conversation
|
|
79
|
+
* turn is a request/answer pair, so only it offers regenerate or edit; a user
|
|
80
|
+
* row of any other kind (none exists today) is copy-only.
|
|
81
|
+
*/
|
|
82
|
+
const TURN_ACTIONS = {
|
|
83
|
+
conversation: {
|
|
84
|
+
user: { always: ["copy"], latest: ["edit"] },
|
|
85
|
+
assistant: { always: ["copy", "feedback"], latest: ["regenerate"] },
|
|
86
|
+
},
|
|
87
|
+
workflow_launch: {
|
|
88
|
+
user: { always: ["copy"], latest: [] },
|
|
89
|
+
assistant: { always: ["copy", "feedback"], latest: [] },
|
|
90
|
+
},
|
|
91
|
+
workflow_settlement: {
|
|
92
|
+
user: { always: ["copy"], latest: [] },
|
|
93
|
+
assistant: { always: ["copy", "feedback"], latest: [] },
|
|
94
|
+
},
|
|
95
|
+
input_request_launch: {
|
|
96
|
+
user: { always: ["copy"], latest: [] },
|
|
97
|
+
assistant: { always: ["copy", "feedback"], latest: [] },
|
|
98
|
+
},
|
|
99
|
+
proactive: {
|
|
100
|
+
user: { always: ["copy"], latest: [] },
|
|
101
|
+
assistant: { always: ["copy"], latest: [] },
|
|
102
|
+
},
|
|
103
|
+
refusal: {
|
|
104
|
+
user: { always: ["copy"], latest: [] },
|
|
105
|
+
assistant: { always: ["copy"], latest: [] },
|
|
106
|
+
},
|
|
107
|
+
} as const satisfies Record<
|
|
108
|
+
TurnKind,
|
|
109
|
+
Record<MessageActionContext["role"], ActionRule>
|
|
110
|
+
>;
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* The actions a turn offers, in MESSAGE_ACTION_IDS order.
|
|
114
|
+
*
|
|
115
|
+
* - Input that does not parse (unknown kind, unknown role) → [].
|
|
116
|
+
* - Not settled → [].
|
|
117
|
+
* - User turn → its `always`, plus `latest` iff latest on the active branch
|
|
118
|
+
* AND authored by the reader.
|
|
119
|
+
* - Assistant turn → its `always`, plus `latest` iff latest on the active
|
|
120
|
+
* branch.
|
|
121
|
+
*/
|
|
122
|
+
export function messageActionsFor(
|
|
123
|
+
ctx: MessageActionContext,
|
|
124
|
+
): readonly MessageActionId[] {
|
|
125
|
+
const parsed = MessageActionContextSchema.safeParse(ctx);
|
|
126
|
+
if (!parsed.success || !parsed.data.settled) return [];
|
|
127
|
+
const { role, turnKind, isLatestOnActiveBranch, authoredByReader } =
|
|
128
|
+
parsed.data;
|
|
129
|
+
const rule: ActionRule = TURN_ACTIONS[turnKind][role];
|
|
130
|
+
const latest =
|
|
131
|
+
role === "user"
|
|
132
|
+
? isLatestOnActiveBranch && authoredByReader
|
|
133
|
+
: isLatestOnActiveBranch;
|
|
134
|
+
return latest ? [...rule.always, ...rule.latest] : [...rule.always];
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/** Whether `action` is among `messageActionsFor(ctx)` — the backend's check. */
|
|
138
|
+
export function isMessageActionAllowed(
|
|
139
|
+
ctx: MessageActionContext,
|
|
140
|
+
action: MessageActionId,
|
|
141
|
+
): boolean {
|
|
142
|
+
return messageActionsFor(ctx).includes(action);
|
|
143
|
+
}
|
package/src/chat/schemas.ts
CHANGED
|
@@ -25,6 +25,7 @@ import {
|
|
|
25
25
|
proactiveChatFields,
|
|
26
26
|
TitleSourceSchema,
|
|
27
27
|
} from "./enums";
|
|
28
|
+
import { TurnKindSchema } from "./turn-kind";
|
|
28
29
|
|
|
29
30
|
// The chat vocabulary and the SSE event contract, re-exported so this module
|
|
30
31
|
// stays the single import site for every chat schema.
|
|
@@ -285,6 +286,35 @@ export const ChatMessageSchema = z.object({
|
|
|
285
286
|
*
|
|
286
287
|
*/
|
|
287
288
|
discussionScope: ChatDiscussionScopeSchema.optional(),
|
|
289
|
+
|
|
290
|
+
/**
|
|
291
|
+
* The PRESENTATION GROUP this turn belongs to: every row of one launched
|
|
292
|
+
* piece of work draws as ONE response, with one action row
|
|
293
|
+
* (chat-action-system). REQUIRED, and null means the turn stands alone —
|
|
294
|
+
* every row has an answer, so there is no absence to make optional.
|
|
295
|
+
*
|
|
296
|
+
* A group is a SEPARATE CONCEPT FROM THE WORKFLOW it may name
|
|
297
|
+
* (INV-LAUNCHTURN-2): it is a presentation fact, never a workflow or run id,
|
|
298
|
+
* and a client must not join on it as one. The settlement row and the review
|
|
299
|
+
* hand-off INHERIT the anchor's group, so a launch, its settlement and its
|
|
300
|
+
* hand-off render together. Superseded rows are never on this wire — the
|
|
301
|
+
* transcript carries the active branch only.
|
|
302
|
+
*/
|
|
303
|
+
presentationGroupId: z.string().uuid().nullable(),
|
|
304
|
+
|
|
305
|
+
/**
|
|
306
|
+
* What KIND of turn this row is — derived by the server from what persisted
|
|
307
|
+
* it, never inferred by a client from content. REQUIRED: every row is exactly
|
|
308
|
+
* one kind. Not a principal kind; `author` is unchanged by it.
|
|
309
|
+
*/
|
|
310
|
+
turnKind: TurnKindSchema,
|
|
311
|
+
|
|
312
|
+
/**
|
|
313
|
+
* The READER's current rating of this turn — the person this response was
|
|
314
|
+
* projected for, never an aggregate of other participants. REQUIRED so a
|
|
315
|
+
* reload restores the rating; null on user turns and on unrated turns.
|
|
316
|
+
*/
|
|
317
|
+
myFeedback: z.enum(["up", "down"]).nullable(),
|
|
288
318
|
});
|
|
289
319
|
|
|
290
320
|
// =============================================================================
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Live-stream message metadata (chat-action-system).
|
|
3
|
+
*
|
|
4
|
+
* The `messageMetadata` the backend attaches to the AI SDK `start` chunk of a
|
|
5
|
+
* streamed assistant turn. It exists so a LIVE message is already the
|
|
6
|
+
* persisted message: the app adopts `messageId` as the live message's id, so
|
|
7
|
+
* the live message's id IS the persisted row id (doctrine rule 10), and it
|
|
8
|
+
* carries its presentation group and turn kind before any reload. Without
|
|
9
|
+
* it, a client could only draw a live turn as a loose message and regroup it
|
|
10
|
+
* after the next fetch — the flicker the presentation group exists to end.
|
|
11
|
+
*
|
|
12
|
+
* Every field mirrors the same-named field on `ChatMessage`, with the same
|
|
13
|
+
* meaning: a client that reads the live metadata and later reloads the
|
|
14
|
+
* transcript sees the same id, group, kind and timestamp.
|
|
15
|
+
*
|
|
16
|
+
* LOAD ORDER: imports only zod, `../api/primitives` and the `./turn-kind`
|
|
17
|
+
* leaf. It must NEVER import `./schemas`.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
import { z } from "zod";
|
|
21
|
+
import { IsoDateTime } from "../api/primitives";
|
|
22
|
+
import { TurnKindSchema } from "./turn-kind";
|
|
23
|
+
|
|
24
|
+
export const ChatStreamMessageMetadataSchema = z.object({
|
|
25
|
+
/** The persisted row id of this message — the live message adopts it. */
|
|
26
|
+
messageId: z.string().uuid(),
|
|
27
|
+
/**
|
|
28
|
+
* The presentation group this turn joins; null when it stands alone.
|
|
29
|
+
* REQUIRED, exactly as on `ChatMessage` — every turn has an answer.
|
|
30
|
+
*/
|
|
31
|
+
presentationGroupId: z.string().uuid().nullable(),
|
|
32
|
+
/** What kind of turn this is, derived by the server. REQUIRED. */
|
|
33
|
+
turnKind: TurnKindSchema,
|
|
34
|
+
/** The persisted row's creation time, so the live turn orders as it will on reload. */
|
|
35
|
+
createdAt: IsoDateTime,
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
export type ChatStreamMessageMetadata = z.infer<
|
|
39
|
+
typeof ChatStreamMessageMetadataSchema
|
|
40
|
+
>;
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Chat turn kind (chat-action-system).
|
|
3
|
+
*
|
|
4
|
+
* Every chat message row is exactly one KIND of turn. The kind is a wire
|
|
5
|
+
* fact the server derives from what persisted the row — never something a
|
|
6
|
+
* client infers from content — so a client can decide how to draw a turn
|
|
7
|
+
* and which message actions it offers from the wire alone.
|
|
8
|
+
*
|
|
9
|
+
* A turn kind is NOT a principal kind: it never changes who authored the
|
|
10
|
+
* row (`author` stays a PrincipalRef), and it is not the presentation group
|
|
11
|
+
* — a group is a separate concept from the workflow it may name.
|
|
12
|
+
*
|
|
13
|
+
* LOAD ORDER: this module is a LEAF. It imports only zod and must NEVER
|
|
14
|
+
* import `./schemas` (or any module that does) — `./schemas`,
|
|
15
|
+
* `./stream-metadata` and `./message-actions` all import this file, and the
|
|
16
|
+
* reverse edge would reach its declarations in the temporal dead zone.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
import { z } from "zod";
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* The kinds of chat turn. CLOSED and append-only: a new kind is a new
|
|
23
|
+
* eligibility decision, and every `Record<TurnKind, …>` table must fail to
|
|
24
|
+
* compile until that decision is made. Each kind is defined by the
|
|
25
|
+
* persisted fact that produces it.
|
|
26
|
+
*/
|
|
27
|
+
export const TURN_KINDS = [
|
|
28
|
+
/** A user turn, or a model-authored answer to one — no launch behind it. */
|
|
29
|
+
"conversation",
|
|
30
|
+
/** A launch-only anchor whose launch kind names a workflow. */
|
|
31
|
+
"workflow_launch",
|
|
32
|
+
/** A settlement row: its launch identity carries the `workflow-settled:` prefix. */
|
|
33
|
+
"workflow_settlement",
|
|
34
|
+
/** A launch-only anchor whose launch kind names an input request. */
|
|
35
|
+
"input_request_launch",
|
|
36
|
+
/** An opener written by the ProactiveChatWriter, not in reply to a turn. */
|
|
37
|
+
"proactive",
|
|
38
|
+
/** The row recording a launch the server refused. */
|
|
39
|
+
"refusal",
|
|
40
|
+
] as const;
|
|
41
|
+
|
|
42
|
+
export const TurnKindSchema = z.enum(TURN_KINDS);
|
|
43
|
+
export type TurnKind = z.infer<typeof TurnKindSchema>;
|