@jskit-ai/assistant-core 0.1.165 → 0.1.167
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/examples/conversation/App.vue +104 -0
- package/examples/conversation/README.md +42 -0
- package/examples/conversation/index.html +2 -0
- package/examples/conversation/main.js +9 -0
- package/examples/conversation/package.json +22 -0
- package/examples/conversation/server.js +123 -0
- package/examples/conversation/vite.config.js +8 -0
- package/package.json +59 -7
- package/src/client/conversation/AssistantComposerActions.vue +43 -0
- package/src/client/conversation/AssistantConversationElement.vue +78 -0
- package/src/client/conversation/AssistantProgress.vue +88 -0
- package/src/client/conversation/AssistantPromptInput.vue +467 -0
- package/src/client/conversation/AssistantTranscript.vue +1361 -0
- package/src/client/conversation/LongTextInlineParts.vue +64 -0
- package/src/client/conversation/LongTextPreviewBlocks.vue +324 -0
- package/src/client/conversation/index.js +9 -0
- package/src/client/conversation/submitText.js +29 -0
- package/src/client/conversation/useScrollToBottom.js +151 -0
- package/src/server/conversation/codexClient.js +328 -0
- package/src/server/conversation/codexEvents.js +623 -0
- package/src/server/conversation/codexTurn.js +280 -0
- package/src/server/conversation/index.js +2 -0
- package/src/server/conversation/memoryStorage.js +76 -0
- package/src/server/conversation/normalize.js +7 -0
- package/src/server/conversation/openCodeClient.js +512 -0
- package/src/server/conversation/transcript.js +92 -0
- package/src/shared/conversation/answerChoiceSugar.js +191 -0
- package/src/shared/conversation/index.js +6 -0
- package/src/shared/conversation/longTextBlocks.js +403 -0
- package/src/shared/conversation/numberedQuestionSugar.js +295 -0
- package/src/shared/conversation/scrollFollowState.js +32 -0
- package/src/shared/conversation/thinkingText.js +13 -0
- package/src/shared/conversation/turns.js +30 -0
- package/src/testing/conversationStorageContract.js +35 -0
- package/test/conversation.test.js +57 -0
|
@@ -0,0 +1,295 @@
|
|
|
1
|
+
import { answerChoiceFromLine } from "./answerChoiceSugar.js";
|
|
2
|
+
|
|
3
|
+
const UI_QUESTION_FIELD_PREFIX = "__ui_question_";
|
|
4
|
+
|
|
5
|
+
function inactiveNumberedQuestionSugar() {
|
|
6
|
+
return {
|
|
7
|
+
intro: "",
|
|
8
|
+
outro: "",
|
|
9
|
+
questions: []
|
|
10
|
+
};
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
function isSingleTextareaMessageField(fields = [], fieldName = "response") {
|
|
14
|
+
return fields.length === 1 &&
|
|
15
|
+
fields[0]?.name === fieldName &&
|
|
16
|
+
fields[0]?.kind === "textarea";
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function optionalValueMatches(actual = "", expected = "") {
|
|
20
|
+
return !expected || String(actual || "") === String(expected || "");
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function canRenderNumberedQuestionSugar({
|
|
24
|
+
fields = [],
|
|
25
|
+
fieldName = "response",
|
|
26
|
+
intentId = "",
|
|
27
|
+
requiredIntentId = "",
|
|
28
|
+
requiredStepStatus = "",
|
|
29
|
+
stepStatus = ""
|
|
30
|
+
} = {}) {
|
|
31
|
+
return Boolean(
|
|
32
|
+
isSingleTextareaMessageField(fields, fieldName) &&
|
|
33
|
+
optionalValueMatches(intentId, requiredIntentId) &&
|
|
34
|
+
optionalValueMatches(stepStatus, requiredStepStatus)
|
|
35
|
+
);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function numberedQuestionMarkerMatch(line = "") {
|
|
39
|
+
return String(line || "").match(/^\[(?:Q)?(\d+)\]\s+(.+)$/iu);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function trailingAnswerChoiceHeadingLine(line = "") {
|
|
43
|
+
return /^(possible answers|choices):$/iu.test(String(line || "").trim());
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function trailingAnswerChoiceLine(line = "") {
|
|
47
|
+
return /^[-*]\s+.+/u.test(String(line || "").trim());
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function numberedQuestionLabelLooksLikeQuestion(label = "") {
|
|
51
|
+
return /\?(?:[*_`”"'’)\]]*)(?:\s|$)/u.test(String(label || "").trim());
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function validNumberedQuestions(questions = []) {
|
|
55
|
+
return questions.every((question) => (
|
|
56
|
+
question.choices.length > 0 || numberedQuestionLabelLooksLikeQuestion(question.label)
|
|
57
|
+
));
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function numberedQuestionChoice(line = "") {
|
|
61
|
+
const choice = answerChoiceFromLine(line);
|
|
62
|
+
if (!choice) {
|
|
63
|
+
return null;
|
|
64
|
+
}
|
|
65
|
+
const label = String(choice.label || "").trim();
|
|
66
|
+
const selectLabel = label.split(/(?:\s*[—–]\s*|\s+-\s+)/u, 1)[0].trim() || label;
|
|
67
|
+
return {
|
|
68
|
+
...choice,
|
|
69
|
+
recommended: choice.recommended === true,
|
|
70
|
+
selectLabel
|
|
71
|
+
};
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function trailingAnswerChoiceBlock(lines = [], startIndex = 0) {
|
|
75
|
+
if (!trailingAnswerChoiceHeadingLine(lines[startIndex])) {
|
|
76
|
+
return null;
|
|
77
|
+
}
|
|
78
|
+
const choices = [];
|
|
79
|
+
let nextIndex = startIndex + 1;
|
|
80
|
+
while (nextIndex < lines.length && trailingAnswerChoiceLine(lines[nextIndex])) {
|
|
81
|
+
const choice = numberedQuestionChoice(lines[nextIndex]);
|
|
82
|
+
if (!choice) {
|
|
83
|
+
return null;
|
|
84
|
+
}
|
|
85
|
+
choices.push(choice);
|
|
86
|
+
nextIndex += 1;
|
|
87
|
+
}
|
|
88
|
+
const uniqueValues = new Set(choices.map((choice) => choice.value));
|
|
89
|
+
if (choices.length < 2 || choices.length > 6 || uniqueValues.size !== choices.length) {
|
|
90
|
+
return null;
|
|
91
|
+
}
|
|
92
|
+
return {
|
|
93
|
+
choices,
|
|
94
|
+
nextIndex
|
|
95
|
+
};
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function questionForMarkerMatch(match = [], index = 0) {
|
|
99
|
+
const numberText = String(match[1] || "");
|
|
100
|
+
const number = Number(numberText);
|
|
101
|
+
const label = String(match[2] || "").trim();
|
|
102
|
+
if (
|
|
103
|
+
!Number.isSafeInteger(number) ||
|
|
104
|
+
number !== index + 1 ||
|
|
105
|
+
String(number) !== numberText ||
|
|
106
|
+
!label
|
|
107
|
+
) {
|
|
108
|
+
return null;
|
|
109
|
+
}
|
|
110
|
+
return {
|
|
111
|
+
choices: [],
|
|
112
|
+
label,
|
|
113
|
+
name: `${UI_QUESTION_FIELD_PREFIX}${number}`,
|
|
114
|
+
number
|
|
115
|
+
};
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
function parseLineNumberedQuestionPrompt(value = "") {
|
|
119
|
+
const lines = String(value || "")
|
|
120
|
+
.split(/\r?\n/u)
|
|
121
|
+
.map((line) => line.trim())
|
|
122
|
+
.filter(Boolean);
|
|
123
|
+
if (!lines.length) {
|
|
124
|
+
return inactiveNumberedQuestionSugar();
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
const intro = [];
|
|
128
|
+
const outro = [];
|
|
129
|
+
const questions = [];
|
|
130
|
+
for (let index = 0; index < lines.length;) {
|
|
131
|
+
const line = lines[index];
|
|
132
|
+
const match = numberedQuestionMarkerMatch(line);
|
|
133
|
+
if (!match) {
|
|
134
|
+
if (!questions.length) {
|
|
135
|
+
intro.push(line);
|
|
136
|
+
index += 1;
|
|
137
|
+
continue;
|
|
138
|
+
}
|
|
139
|
+
const choiceBlock = trailingAnswerChoiceBlock(lines, index);
|
|
140
|
+
if (choiceBlock) {
|
|
141
|
+
const question = questions.at(-1);
|
|
142
|
+
if (question.choices.length) {
|
|
143
|
+
return inactiveNumberedQuestionSugar();
|
|
144
|
+
}
|
|
145
|
+
question.choices = choiceBlock.choices;
|
|
146
|
+
index = choiceBlock.nextIndex;
|
|
147
|
+
continue;
|
|
148
|
+
}
|
|
149
|
+
const trailingLines = lines.slice(index);
|
|
150
|
+
if (
|
|
151
|
+
trailingLines.some((trailingLine) => trailingAnswerChoiceHeadingLine(trailingLine)) ||
|
|
152
|
+
trailingLines.some((trailingLine) => numberedQuestionMarkerMatch(trailingLine))
|
|
153
|
+
) {
|
|
154
|
+
return inactiveNumberedQuestionSugar();
|
|
155
|
+
}
|
|
156
|
+
outro.push(...trailingLines);
|
|
157
|
+
break;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
const question = questionForMarkerMatch(match, questions.length);
|
|
161
|
+
if (!question) {
|
|
162
|
+
return inactiveNumberedQuestionSugar();
|
|
163
|
+
}
|
|
164
|
+
questions.push(question);
|
|
165
|
+
index += 1;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
if (!questions.length || !validNumberedQuestions(questions)) {
|
|
169
|
+
return inactiveNumberedQuestionSugar();
|
|
170
|
+
}
|
|
171
|
+
return {
|
|
172
|
+
intro: intro.join("\n"),
|
|
173
|
+
outro: outro.join("\n"),
|
|
174
|
+
questions
|
|
175
|
+
};
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
function parseInlineNumberedQuestionPrompt(value = "") {
|
|
179
|
+
const source = String(value || "").replace(/\r\n/gu, "\n").trim();
|
|
180
|
+
const firstMarker = source.search(/\[(?:Q)?\d+\]\s+/iu);
|
|
181
|
+
if (firstMarker < 0) {
|
|
182
|
+
return inactiveNumberedQuestionSugar();
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
const intro = source.slice(0, firstMarker).trim();
|
|
186
|
+
const questionText = source.slice(firstMarker).trim();
|
|
187
|
+
if (!questionText || questionText.includes("\n")) {
|
|
188
|
+
return inactiveNumberedQuestionSugar();
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
const markerPattern = /\[(?:Q)?(\d+)\]\s+/giu;
|
|
192
|
+
const markers = [...questionText.matchAll(markerPattern)];
|
|
193
|
+
if (markers.length < 2 || markers[0].index !== 0) {
|
|
194
|
+
return inactiveNumberedQuestionSugar();
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
const questions = [];
|
|
198
|
+
for (const [index, match] of markers.entries()) {
|
|
199
|
+
const labelStart = match.index + match[0].length;
|
|
200
|
+
const nextMarker = markers[index + 1];
|
|
201
|
+
const labelEnd = nextMarker ? nextMarker.index : questionText.length;
|
|
202
|
+
const question = questionForMarkerMatch([
|
|
203
|
+
match[0],
|
|
204
|
+
match[1],
|
|
205
|
+
questionText.slice(labelStart, labelEnd)
|
|
206
|
+
], questions.length);
|
|
207
|
+
if (!question) {
|
|
208
|
+
return inactiveNumberedQuestionSugar();
|
|
209
|
+
}
|
|
210
|
+
questions.push(question);
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
if (!validNumberedQuestions(questions)) {
|
|
214
|
+
return inactiveNumberedQuestionSugar();
|
|
215
|
+
}
|
|
216
|
+
return {
|
|
217
|
+
intro,
|
|
218
|
+
outro: "",
|
|
219
|
+
questions
|
|
220
|
+
};
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
function parseNumberedQuestionPrompt(value = "") {
|
|
224
|
+
const inlineQuestions = parseInlineNumberedQuestionPrompt(value);
|
|
225
|
+
return inlineQuestions.questions.length
|
|
226
|
+
? inlineQuestions
|
|
227
|
+
: parseLineNumberedQuestionPrompt(value);
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
function numberedQuestionSugarForInput(interaction = {}, fields = []) {
|
|
231
|
+
return numberedQuestionSugarForMessageInput({
|
|
232
|
+
fields,
|
|
233
|
+
fieldName: "response",
|
|
234
|
+
message: interaction?.prompt
|
|
235
|
+
});
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
function numberedQuestionSugarForMessageInput({
|
|
239
|
+
fields = [],
|
|
240
|
+
fieldName = "response",
|
|
241
|
+
intentId = "",
|
|
242
|
+
message = "",
|
|
243
|
+
requiredIntentId = "",
|
|
244
|
+
requiredStepStatus = "",
|
|
245
|
+
stepStatus = ""
|
|
246
|
+
} = {}) {
|
|
247
|
+
if (!canRenderNumberedQuestionSugar({
|
|
248
|
+
fields,
|
|
249
|
+
fieldName,
|
|
250
|
+
intentId,
|
|
251
|
+
requiredIntentId,
|
|
252
|
+
requiredStepStatus,
|
|
253
|
+
stepStatus
|
|
254
|
+
})) {
|
|
255
|
+
return inactiveNumberedQuestionSugar();
|
|
256
|
+
}
|
|
257
|
+
return parseNumberedQuestionPrompt(message);
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
function numberedQuestionInputFields(questions = [], options = {}) {
|
|
261
|
+
const autocomplete = String(options?.autocomplete || "").trim();
|
|
262
|
+
const density = String(options?.density || "").trim();
|
|
263
|
+
return questions.map((question) => ({
|
|
264
|
+
...(autocomplete ? { autocomplete } : {}),
|
|
265
|
+
...(density ? { density } : {}),
|
|
266
|
+
kind: "text",
|
|
267
|
+
label: question.label,
|
|
268
|
+
name: question.name,
|
|
269
|
+
required: true,
|
|
270
|
+
requiredMessage: `Answer question ${question.number}.`
|
|
271
|
+
}));
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
function numberedQuestionSubmissionText(questions = [], values = {}) {
|
|
275
|
+
return questions
|
|
276
|
+
.map((question) => `[${question.number}] ${String(values[question.name] || "").trim()}`)
|
|
277
|
+
.join("\n");
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
function numberedQuestionSubmissionFields(questions = [], values = {}, fieldName = "response") {
|
|
281
|
+
return {
|
|
282
|
+
[fieldName]: numberedQuestionSubmissionText(questions, values)
|
|
283
|
+
};
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
export {
|
|
287
|
+
canRenderNumberedQuestionSugar,
|
|
288
|
+
numberedQuestionInputFields,
|
|
289
|
+
numberedQuestionSubmissionFields,
|
|
290
|
+
numberedQuestionSubmissionText,
|
|
291
|
+
numberedQuestionSugarForMessageInput,
|
|
292
|
+
numberedQuestionSugarForInput,
|
|
293
|
+
parseNumberedQuestionPrompt,
|
|
294
|
+
UI_QUESTION_FIELD_PREFIX
|
|
295
|
+
};
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
const DEFAULT_SCROLL_FOLLOW_THRESHOLD_PX = 48;
|
|
2
|
+
|
|
3
|
+
function numericElementValue(value) {
|
|
4
|
+
const number = Number(value);
|
|
5
|
+
return Number.isFinite(number) ? number : 0;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
function scrollDistanceFromBottom(element = null) {
|
|
9
|
+
if (!element) {
|
|
10
|
+
return 0;
|
|
11
|
+
}
|
|
12
|
+
return Math.max(
|
|
13
|
+
0,
|
|
14
|
+
numericElementValue(element.scrollHeight) -
|
|
15
|
+
numericElementValue(element.scrollTop) -
|
|
16
|
+
numericElementValue(element.clientHeight)
|
|
17
|
+
);
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function scrollElementNearBottom(
|
|
21
|
+
element = null,
|
|
22
|
+
thresholdPx = DEFAULT_SCROLL_FOLLOW_THRESHOLD_PX
|
|
23
|
+
) {
|
|
24
|
+
const threshold = Math.max(0, numericElementValue(thresholdPx));
|
|
25
|
+
return scrollDistanceFromBottom(element) <= threshold;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export {
|
|
29
|
+
DEFAULT_SCROLL_FOLLOW_THRESHOLD_PX,
|
|
30
|
+
scrollDistanceFromBottom,
|
|
31
|
+
scrollElementNearBottom
|
|
32
|
+
};
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
const THINKING_HEADING_PATTERN = /^thinking(?:\.{3}|…|:)?$/iu;
|
|
2
|
+
|
|
3
|
+
function normalizeThinkingMessageText(value = "") {
|
|
4
|
+
const lines = String(value || "").trim().split(/\r?\n/u);
|
|
5
|
+
while (lines.length && THINKING_HEADING_PATTERN.test(lines[0].trim())) {
|
|
6
|
+
lines.shift();
|
|
7
|
+
}
|
|
8
|
+
return lines.join("\n").trim();
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export {
|
|
12
|
+
normalizeThinkingMessageText
|
|
13
|
+
};
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
/** Adapt ordered messages to the transcript's turn model without changing their metadata. */
|
|
2
|
+
export function conversationTurnsFromMessages(messages = []) {
|
|
3
|
+
const turns = [];
|
|
4
|
+
let current = null;
|
|
5
|
+
for (const [index, source] of messages.entries()) {
|
|
6
|
+
const message = { ...source, messageId: String(source.messageId || source.id || `message-${index}`) };
|
|
7
|
+
if (message.role === "system") {
|
|
8
|
+
turns.push({ turnId: message.messageId, system: message, messages: [message] });
|
|
9
|
+
current = null;
|
|
10
|
+
continue;
|
|
11
|
+
}
|
|
12
|
+
if (message.role === "user" || !current || current.assistant) {
|
|
13
|
+
current = { turnId: message.messageId, messages: [] };
|
|
14
|
+
turns.push(current);
|
|
15
|
+
}
|
|
16
|
+
if (message.role === "user") current.user = message;
|
|
17
|
+
if (message.role === "assistant") {
|
|
18
|
+
current.pending = ["starting", "inProgress"].includes(message.status);
|
|
19
|
+
for (const [progressIndex, progress] of (message.progressUpdates || []).entries()) {
|
|
20
|
+
current.messages.push({ ...progress, role: progress.role || "thinking", messageId: `${message.messageId}:progress:${progress.id || progressIndex}` });
|
|
21
|
+
}
|
|
22
|
+
if (!message.text && ["failed", "interrupted"].includes(message.status)) {
|
|
23
|
+
message.text = message.status === "interrupted" ? "Stopped." : "Assistant stopped with an error.";
|
|
24
|
+
}
|
|
25
|
+
current.assistant = message;
|
|
26
|
+
}
|
|
27
|
+
if (message.role !== "assistant" || message.text) current.messages.push(message);
|
|
28
|
+
}
|
|
29
|
+
return turns;
|
|
30
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { createConversationTranscript } from "../server/conversation/transcript.js";
|
|
3
|
+
|
|
4
|
+
/** Run against an app's isolated adapter fixture; never point this at production data. */
|
|
5
|
+
export async function verifyConversationStorageContract(storage) {
|
|
6
|
+
const transcript = createConversationTranscript({ storage, clock: () => new Date("2026-01-01T00:00:00Z") });
|
|
7
|
+
const scope = "actor-a:workspace-a:conversation-a";
|
|
8
|
+
const other = "actor-b:workspace-a:conversation-a";
|
|
9
|
+
const attachment = { id: "file-a", name: "notes.txt" };
|
|
10
|
+
const input = { messageId: "request-a", text: "First question", attachments: [attachment] };
|
|
11
|
+
const writes = await Promise.all(Array.from({ length: 5 }, () => transcript.writeConversationUserMessage(scope, input)));
|
|
12
|
+
assert.equal(writes.filter(Boolean).length, 1, "A retried message must create exactly one turn.");
|
|
13
|
+
const first = (await transcript.readConversationLog(scope))[0];
|
|
14
|
+
assert.equal(first.user.messageId, input.messageId);
|
|
15
|
+
assert.deepEqual(first.user.attachments, [attachment]);
|
|
16
|
+
assert.deepEqual(await transcript.readConversationLog(other), [], "Scopes must be isolated.");
|
|
17
|
+
await transcript.writeConversationThinkingMessage(scope, { messageId: "thinking-a", text: "Thinking" });
|
|
18
|
+
await transcript.writeConversationCommentaryMessage(scope, { messageId: "commentary-a", text: "Checking" });
|
|
19
|
+
await transcript.writeConversationAssistantMessage(scope, { messageId: "answer-a", text: "Initial answer" });
|
|
20
|
+
await transcript.upsertConversationAssistantMessage(scope, { turnId: first.turnId, text: "Partial answer" });
|
|
21
|
+
await transcript.upsertConversationAssistantMessage(scope, { turnId: first.turnId, text: "Final answer" });
|
|
22
|
+
await transcript.writeConversationUserMessage(scope, { messageId: "request-b", text: "Second question" });
|
|
23
|
+
const latest = await transcript.readConversationLogPage(scope, { limit: 1 });
|
|
24
|
+
assert.equal(latest.conversationLog[0].user.text, "Second question");
|
|
25
|
+
assert.equal(latest.pagination.hasMoreBefore, true);
|
|
26
|
+
const older = await transcript.readConversationLogPage(scope, { limit: 1, beforeTurnId: latest.pagination.nextBeforeTurnId });
|
|
27
|
+
assert.equal(older.pagination.hasMoreBefore, false);
|
|
28
|
+
assert.equal(older.conversationLog[0].turnId, first.turnId);
|
|
29
|
+
assert.equal(older.conversationLog[0].assistant.text, "Final answer");
|
|
30
|
+
assert.equal(older.conversationLog[0].assistant.messageId, "answer-a", "Final replacement preserves message identity.");
|
|
31
|
+
assert.deepEqual(older.conversationLog[0].messages.map((message) => message.role), ["user", "thinking", "commentary", "assistant"]);
|
|
32
|
+
older.conversationLog[0].user.text = "Client mutation";
|
|
33
|
+
assert.equal((await transcript.readConversationLog(scope))[0].user.text, "First question", "Reads must be detached snapshots.");
|
|
34
|
+
return { scope, other, transcript };
|
|
35
|
+
}
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import test from "node:test";
|
|
3
|
+
import { createMemoryConversationStorage } from "../src/server/conversation/memoryStorage.js";
|
|
4
|
+
import { createConversationTranscript } from "../src/server/conversation/transcript.js";
|
|
5
|
+
import { conversationTurnsFromMessages } from "../src/shared/conversation/turns.js";
|
|
6
|
+
import { verifyConversationStorageContract } from "../src/testing/conversationStorageContract.js";
|
|
7
|
+
|
|
8
|
+
test("memory conversation storage satisfies the replaceable storage contract", async () => {
|
|
9
|
+
await verifyConversationStorageContract(createMemoryConversationStorage());
|
|
10
|
+
});
|
|
11
|
+
|
|
12
|
+
test("failed storage writes roll back and do not poison the next operation", async () => {
|
|
13
|
+
const storage = createMemoryConversationStorage();
|
|
14
|
+
await assert.rejects(storage.write("one", async (transaction) => {
|
|
15
|
+
await transaction.appendMessage("000001", { role: "user", text: "Uncommitted", at: "2026-01-01" });
|
|
16
|
+
throw new Error("disk full");
|
|
17
|
+
}), /disk full/);
|
|
18
|
+
const transcript = createConversationTranscript({ storage });
|
|
19
|
+
assert.deepEqual(await transcript.readConversationLog("one"), []);
|
|
20
|
+
await transcript.writeConversationUserMessage("one", { messageId: "one", text: "Retry" });
|
|
21
|
+
assert.equal((await transcript.readConversationLog("one"))[0].user.text, "Retry");
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
test("storage failures reach the caller instead of acknowledging a saved message", async () => {
|
|
25
|
+
const transcript = createConversationTranscript({ storage: {
|
|
26
|
+
read: async () => { throw new Error("read failed"); },
|
|
27
|
+
write: async () => { throw new Error("write failed"); }
|
|
28
|
+
} });
|
|
29
|
+
await assert.rejects(transcript.readConversationLog("one"), /read failed/);
|
|
30
|
+
await assert.rejects(transcript.writeConversationUserMessage("one", { text: "Question" }), /write failed/);
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
test("transient deletion is isolated and a conversation can be recreated", async () => {
|
|
34
|
+
const storage = createMemoryConversationStorage();
|
|
35
|
+
const transcript = createConversationTranscript({ storage });
|
|
36
|
+
await transcript.writeConversationUserMessage("one", { text: "One" });
|
|
37
|
+
await transcript.writeConversationUserMessage("two", { text: "Two" });
|
|
38
|
+
await storage.deleteConversation("one");
|
|
39
|
+
assert.deepEqual(await transcript.readConversationLog("one"), []);
|
|
40
|
+
assert.equal((await transcript.readConversationLog("two"))[0].user.text, "Two");
|
|
41
|
+
await transcript.writeConversationUserMessage("one", { text: "New" });
|
|
42
|
+
assert.equal((await transcript.readConversationLog("one"))[0].user.text, "New");
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
test("message adaptation retains progress, attachments, system actions and SQL metadata", () => {
|
|
46
|
+
const turns = conversationTurnsFromMessages([
|
|
47
|
+
{ id: "q", role: "user", text: "Question", attachments: [{ id: "file" }] },
|
|
48
|
+
{ id: "a", role: "assistant", text: "", status: "inProgress", progressUpdates: [{ id: "p", text: "Inspecting" }] },
|
|
49
|
+
{ id: "s", role: "system", text: "Repair complete", repair: true },
|
|
50
|
+
{ id: "sql", role: "assistant", text: "Result", sql: "select 1" }
|
|
51
|
+
]);
|
|
52
|
+
assert.equal(turns[0].pending, true);
|
|
53
|
+
assert.equal(turns[0].messages[1].role, "thinking");
|
|
54
|
+
assert.equal(turns[0].user.attachments[0].id, "file");
|
|
55
|
+
assert.equal(turns[1].system.repair, true);
|
|
56
|
+
assert.equal(turns[2].assistant.sql, "select 1");
|
|
57
|
+
});
|