fix (windowed payload): Restrict frontend windowed payload contract to a single flight

This commit is contained in:
Darkatse 2026-04-06 00:39:25 -04:00
parent e459e827cb
commit f786d68c49
8 changed files with 687 additions and 131 deletions

View file

@ -29,6 +29,8 @@ import {
clearWindowedChatState,
DEFAULT_CHAT_WINDOW_LINES,
getWindowedChatState,
getWindowedChatKey,
mergeWindowedChatCursorOffset,
setWindowedChatState,
shiftWindowedMessageSaveState,
} from './scripts/tauri/chat/windowed-state.js';
@ -1631,6 +1633,9 @@ export async function replaceCurrentChat() {
}
}
/** @type {{ state: any, promise: Promise<void> } | null} */
let windowedShowMoreMessagesPending = null;
export async function showMoreMessages(messagesToLoad = null) {
const windowState = getWindowedChatState();
if (windowState && isTauriChatPayloadTransportEnabled()) {
@ -1638,78 +1643,100 @@ export async function showMoreMessages(messagesToLoad = null) {
throw new Error('Windowed chat cursor is missing');
}
const existingPending = windowedShowMoreMessagesPending;
if (existingPending?.state === windowState) {
return existingPending.promise;
}
const count = Number(messagesToLoad || DEFAULT_CHAT_WINDOW_LINES);
const prevHeight = chatElement.prop('scrollHeight');
const showMoreButton = $('#show_more_messages');
const isButtonInView = showMoreButton[0] && isElementInViewport(showMoreButton[0]);
const result = windowState.kind === 'group'
? await loadGroupChatPayloadBefore({
id: windowState.id,
cursor: windowState.cursor,
maxLines: count,
})
: await loadCharacterChatPayloadBefore({
characterName: windowState.characterName,
avatarUrl: windowState.avatarUrl,
fileName: windowState.fileName,
cursor: windowState.cursor,
maxLines: count,
});
const run = (async () => {
const result = windowState.kind === 'group'
? await loadGroupChatPayloadBefore({
id: windowState.id,
cursor: windowState.cursor,
maxLines: count,
})
: await loadCharacterChatPayloadBefore({
characterName: windowState.characterName,
avatarUrl: windowState.avatarUrl,
fileName: windowState.fileName,
cursor: windowState.cursor,
maxLines: count,
});
const messages = result?.messages;
if (!Array.isArray(messages) || messages.length === 0) {
showMoreButton.remove();
if (getWindowedChatState() !== windowState) {
return;
}
const messages = result?.messages;
if (!Array.isArray(messages) || messages.length === 0) {
showMoreButton.remove();
setWindowedChatState({
...windowState,
cursor: result?.cursor ?? windowState.cursor,
hasMoreBefore: false,
});
await eventSource.emit(event_types.MORE_MESSAGES_LOADED);
return;
}
messages.forEach(ensureMessageMediaIsArray);
chat.splice(0, 0, ...messages);
if (this_edit_mes_id >= 0) {
this_edit_mes_id = Number(this_edit_mes_id) + messages.length;
}
const fragment = document.createDocumentFragment();
for (let id = 0; id < messages.length; id += 1) {
const messageElement = updateMessageElement(chat[id], { messageId: id });
fragment.appendChild(messageElement[0]);
}
if (showMoreButton[0]) {
showMoreButton[0].after(fragment);
} else {
chatElement[0].prepend(fragment);
}
updateViewMessageIds(0);
refreshSwipeButtons();
const hasMoreBefore = Boolean(result?.hasMoreBefore);
const shiftedState = shiftWindowedMessageSaveState(windowState, messages.length, 'chat');
setWindowedChatState({
...windowState,
cursor: result?.cursor ?? windowState.cursor,
hasMoreBefore: false,
...shiftedState,
cursor: result.cursor,
hasMoreBefore,
});
if (!hasMoreBefore) {
showMoreButton.remove();
}
if (isButtonInView) {
const newHeight = chatElement.prop('scrollHeight');
chatElement.scrollTop(newHeight - prevHeight);
}
applyStylePins();
applyCharacterTagsToMessageDivs();
await eventSource.emit(event_types.MORE_MESSAGES_LOADED);
return;
})();
windowedShowMoreMessagesPending = { state: windowState, promise: run };
try {
await run;
} finally {
if (windowedShowMoreMessagesPending?.promise === run) {
windowedShowMoreMessagesPending = null;
}
}
messages.forEach(ensureMessageMediaIsArray);
chat.splice(0, 0, ...messages);
if (this_edit_mes_id >= 0) {
this_edit_mes_id = Number(this_edit_mes_id) + messages.length;
}
const fragment = document.createDocumentFragment();
for (let id = 0; id < messages.length; id += 1) {
const messageElement = updateMessageElement(chat[id], { messageId: id });
fragment.appendChild(messageElement[0]);
}
if (showMoreButton[0]) {
showMoreButton[0].after(fragment);
} else {
chatElement[0].prepend(fragment);
}
updateViewMessageIds(0);
refreshSwipeButtons();
const hasMoreBefore = Boolean(result?.hasMoreBefore);
const shiftedState = shiftWindowedMessageSaveState(windowState, messages.length, 'chat');
setWindowedChatState({
...shiftedState,
cursor: result.cursor,
hasMoreBefore,
});
if (!hasMoreBefore) {
showMoreButton.remove();
}
if (isButtonInView) {
const newHeight = chatElement.prop('scrollHeight');
chatElement.scrollTop(newHeight - prevHeight);
}
applyStylePins();
applyCharacterTagsToMessageDivs();
await eventSource.emit(event_types.MORE_MESSAGES_LOADED);
return;
}
@ -1887,6 +1914,9 @@ export function cancelDebouncedChatSave() {
export async function clearChat({ clearData = false } = {}) {
cancelDebouncedChatSave();
cancelDebouncedMetadataSave();
if (clearData) {
clearWindowedChatState();
}
closeMessageEditor();
getCodeHighlightCoordinator().reset();
extension_prompts = {};
@ -7719,15 +7749,17 @@ export async function saveChat({ chatName, withMetadata, mesId, force = false }
String(error?.code || '').toLowerCase() === 'integrity'
|| /integrity/i.test(String(error?.message || ''));
try {
if (isTauriChatPayloadTransportEnabled()) {
const windowState = getWindowedChatState();
if (windowState?.kind === 'character' && windowState.cursor && windowState.fileName === fileName) {
const {
patch,
savedMessageCount: nextSavedMessageCount,
dirtyFromIndex: nextDirtyFromIndex,
} = buildWindowedPayloadPatch(trimmedChat, windowState, 'chat');
try {
if (isTauriChatPayloadTransportEnabled()) {
const windowState = getWindowedChatState();
if (windowState?.kind === 'character' && windowState.cursor && windowState.fileName === fileName) {
const expectedWindowKey = getWindowedChatKey(windowState);
const expectedCursorOffset = windowState.cursor.offset;
const {
patch,
savedMessageCount: nextSavedMessageCount,
dirtyFromIndex: nextDirtyFromIndex,
} = buildWindowedPayloadPatch(trimmedChat, windowState, 'chat');
const cursor = await patchCharacterChatPayloadWindowed({
characterName: characters[this_chid].name,
@ -7735,21 +7767,30 @@ export async function saveChat({ chatName, withMetadata, mesId, force = false }
fileName: fileName,
cursor: windowState.cursor,
header: JSON.stringify(chatHeader),
patch,
force: Boolean(force),
});
patch,
force: Boolean(force),
});
setWindowedChatState({
...windowState,
cursor,
savedMessageCount: nextSavedMessageCount,
dirtyFromIndex: nextDirtyFromIndex,
});
} else {
await saveCharacterChatPayload({
characterName: characters[this_chid].name,
avatarUrl: characters[this_chid].avatar,
fileName: fileName,
const activeWindowState = getWindowedChatState();
if (getWindowedChatKey(activeWindowState) === expectedWindowKey) {
const mergedCursor = mergeWindowedChatCursorOffset(activeWindowState?.cursor, cursor);
const nextWindowState = {
...activeWindowState,
cursor: mergedCursor,
};
if (activeWindowState?.cursor?.offset === expectedCursorOffset) {
nextWindowState.savedMessageCount = nextSavedMessageCount;
nextWindowState.dirtyFromIndex = nextDirtyFromIndex;
}
setWindowedChatState(nextWindowState);
}
} else {
await saveCharacterChatPayload({
characterName: characters[this_chid].name,
avatarUrl: characters[this_chid].avatar,
fileName: fileName,
payload,
force: Boolean(force),
});
@ -7975,8 +8016,13 @@ export async function unshallowCharacter(characterId) {
}
export async function getChat() {
const startedChid = this_chid;
const startedSelectedGroup = selected_group;
const startedCharacter = startedChid !== undefined ? characters[startedChid] : null;
const startedChatFile = startedCharacter?.chat;
try {
await unshallowCharacter(this_chid);
await unshallowCharacter(startedChid);
const usePayloadTransport = isTauriChatPayloadTransportEnabled();
let data;
let windowedCursor = null;
@ -7984,9 +8030,9 @@ export async function getChat() {
if (usePayloadTransport) {
const window = await loadCharacterChatPayloadTail({
characterName: characters[this_chid].name,
avatarUrl: characters[this_chid].avatar,
fileName: characters[this_chid].chat,
characterName: startedCharacter?.name,
avatarUrl: startedCharacter?.avatar,
fileName: startedChatFile,
maxLines: DEFAULT_CHAT_WINDOW_LINES,
allowNotFound: true,
});
@ -8001,9 +8047,9 @@ export async function getChat() {
headers: getRequestHeaders(),
cache: 'no-cache',
body: JSON.stringify({
ch_name: characters[this_chid].name,
file_name: characters[this_chid].chat,
avatar_url: characters[this_chid].avatar,
ch_name: startedCharacter?.name,
file_name: startedChatFile,
avatar_url: startedCharacter?.avatar,
}),
});
@ -8014,6 +8060,14 @@ export async function getChat() {
data = await response.json();
}
const currentCharacter = startedChid !== undefined ? characters[startedChid] : null;
const stillActive = startedSelectedGroup === selected_group
&& startedChid === this_chid
&& currentCharacter?.chat === startedChatFile;
if (!stillActive) {
return;
}
if (Array.isArray(data) && data.length > 0) {
/** @type {ChatHeader} */
const chatHeader = data.shift();
@ -8029,9 +8083,9 @@ export async function getChat() {
if (usePayloadTransport && windowedCursor) {
setWindowedChatState({
kind: 'character',
characterName: characters[this_chid].name,
avatarUrl: characters[this_chid].avatar,
fileName: characters[this_chid].chat,
characterName: currentCharacter.name,
avatarUrl: currentCharacter.avatar,
fileName: currentCharacter.chat,
cursor: windowedCursor,
hasMoreBefore: windowedHasMoreBefore,
savedMessageCount: chat.length,

View file

@ -97,6 +97,8 @@ import {
clearWindowedChatState,
DEFAULT_CHAT_WINDOW_LINES,
getWindowedChatState,
getWindowedChatKey,
mergeWindowedChatCursorOffset,
setWindowedChatState,
} from './tauri/chat/windowed-state.js';
@ -206,7 +208,7 @@ async function regenerateGroup() {
* @param {string} chatId Chat ID
* @returns {Promise<ChatFile>} Array of chat messages
*/
async function loadGroupChat(chatId) {
async function loadGroupChat(chatId, { updateWindowState = false } = {}) {
const normalizedChatId = String(chatId || '').trim();
if (!normalizedChatId) {
return [];
@ -219,18 +221,20 @@ async function loadGroupChat(chatId) {
allowNotFound: true,
});
if (window.cursor) {
const messageCount = Array.isArray(window.payload) ? Math.max(0, window.payload.length - 1) : 0;
setWindowedChatState({
kind: 'group',
id: normalizedChatId,
cursor: window.cursor,
hasMoreBefore: window.hasMoreBefore,
savedMessageCount: messageCount,
dirtyFromIndex: messageCount,
});
} else {
clearWindowedChatState();
if (updateWindowState && String(getCurrentChatId() || '').trim() === normalizedChatId) {
if (window.cursor) {
const messageCount = Array.isArray(window.payload) ? Math.max(0, window.payload.length - 1) : 0;
setWindowedChatState({
kind: 'group',
id: normalizedChatId,
cursor: window.cursor,
hasMoreBefore: window.hasMoreBefore,
savedMessageCount: messageCount,
dirtyFromIndex: messageCount,
});
} else {
clearWindowedChatState();
}
}
return window.payload;
@ -303,12 +307,25 @@ export async function getGroupChat(groupId, reload = false) {
return;
}
const startedGroupId = groupId;
const startedChatId = group.chat_id;
const isStillActive = () => selected_group === startedGroupId && getCurrentChatId() === startedChatId;
// Run validation before any loading
await validateGroup(group);
if (!isStillActive()) {
return;
}
await unshallowGroupMembers(groupId);
if (!isStillActive()) {
return;
}
const chat_id = group.chat_id;
const data = await loadGroupChat(chat_id);
const data = await loadGroupChat(chat_id, { updateWindowState: true });
if (!isStillActive()) {
return;
}
const metadata = data?.[0]?.chat_metadata ?? {};
const freshChat = !metadata.tainted && (!Array.isArray(data) || !data.length);
@ -323,17 +340,26 @@ export async function getGroupChat(groupId, reload = false) {
}
await loadItemizedPrompts(getCurrentChatId());
if (!isStillActive()) {
return;
}
if (group && Array.isArray(group.members) && freshChat) {
chat.splice(0, chat.length);
chatElement.find('.mes').remove();
for (let member of group.members) {
if (!isStillActive()) {
return;
}
const character = characters.find(x => x.avatar === member || x.name === member);
if (!character) {
continue;
}
const mes = await getFirstCharacterMessage(character);
if (!isStillActive()) {
return;
}
// No first message
if (!(mes?.mes)) {
@ -347,10 +373,16 @@ export async function getGroupChat(groupId, reload = false) {
}
await saveGroupChat(groupId, false);
} else if (Array.isArray(data) && data.length) {
if (!isStillActive()) {
return;
}
chat.splice(0, chat.length, ...data);
chat.forEach(ensureMessageMediaIsArray);
chatElement.find('.mes').remove();
await printMessages();
if (!isStillActive()) {
return;
}
const windowState = getWindowedChatState();
if (windowState?.kind === 'group' && windowState.cursor && windowState.id === chat_id) {
@ -363,6 +395,9 @@ export async function getGroupChat(groupId, reload = false) {
}
updateChatMetadata(metadata, true);
if (!isStillActive()) {
return;
}
if (reload) {
select_group_chats(groupId, true);
@ -695,35 +730,46 @@ async function saveGroupChat(groupId, shouldSaveGroup, force = false) {
String(error?.code || '').toLowerCase() === 'integrity'
|| /integrity/i.test(String(error?.message || ''));
try {
if (isTauriChatPayloadTransportEnabled()) {
const windowState = getWindowedChatState();
if (windowState?.kind === 'group' && windowState.cursor && windowState.id === chatId) {
const {
patch,
savedMessageCount: nextSavedMessageCount,
dirtyFromIndex: nextDirtyFromIndex,
} = buildWindowedPayloadPatch(chat, windowState, 'group chat');
try {
if (isTauriChatPayloadTransportEnabled()) {
const windowState = getWindowedChatState();
if (windowState?.kind === 'group' && windowState.cursor && windowState.id === chatId) {
const expectedWindowKey = getWindowedChatKey(windowState);
const expectedCursorOffset = windowState.cursor.offset;
const {
patch,
savedMessageCount: nextSavedMessageCount,
dirtyFromIndex: nextDirtyFromIndex,
} = buildWindowedPayloadPatch(chat, windowState, 'group chat');
const cursor = await patchGroupChatPayloadWindowed({
id: chatId,
cursor: windowState.cursor,
header: JSON.stringify(chatHeader),
patch,
force: Boolean(force),
});
patch,
force: Boolean(force),
});
setWindowedChatState({
...windowState,
cursor,
savedMessageCount: nextSavedMessageCount,
dirtyFromIndex: nextDirtyFromIndex,
});
} else {
await saveGroupChatPayload({
id: chatId,
payload,
force: Boolean(force),
const activeWindowState = getWindowedChatState();
if (getWindowedChatKey(activeWindowState) === expectedWindowKey) {
const mergedCursor = mergeWindowedChatCursorOffset(activeWindowState?.cursor, cursor);
const nextWindowState = {
...activeWindowState,
cursor: mergedCursor,
};
if (activeWindowState?.cursor?.offset === expectedCursorOffset) {
nextWindowState.savedMessageCount = nextSavedMessageCount;
nextWindowState.dirtyFromIndex = nextDirtyFromIndex;
}
setWindowedChatState(nextWindowState);
}
} else {
await saveGroupChatPayload({
id: chatId,
payload,
force: Boolean(force),
});
}
} else {

View file

@ -26,6 +26,33 @@ export function clearWindowedChatState() {
currentWindow = null;
}
export function getWindowedChatKey(windowState) {
if (!windowState) {
return '';
}
if (windowState.kind === 'group') {
return `group:${String(windowState.id || '').trim()}`;
}
return `character:${String(windowState.characterName || '').trim()}|${String(windowState.avatarUrl || '').trim()}|${String(windowState.fileName || '').trim()}`;
}
export function mergeWindowedChatCursorOffset(activeCursor, nextCursor) {
if (!nextCursor) {
return activeCursor ?? null;
}
if (!activeCursor) {
return nextCursor;
}
return {
...nextCursor,
offset: activeCursor.offset,
};
}
function requireWindowedCounter(value, label) {
const normalized = Number(value);
if (!Number.isFinite(normalized) || normalized < 0) {

View file

@ -0,0 +1,59 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import path from 'node:path';
import { fileURLToPath, pathToFileURL } from 'node:url';
const REPO_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
async function importFresh(modulePath) {
const url = `${pathToFileURL(modulePath).href}?t=${Date.now()}-${Math.random()}`;
return import(url);
}
test('prompt-backfill: isWindowedCursorInvalidError matches signature mismatch', async () => {
const mod = await importFresh(path.join(REPO_ROOT, 'src/scripts/tauri/chat/prompt-backfill.js'));
const { isWindowedCursorInvalidError } = mod;
assert.equal(isWindowedCursorInvalidError(new Error('Cursor signature mismatch')), true);
assert.equal(isWindowedCursorInvalidError('Bad Request: Cursor signature mismatch'), true);
});
test('prompt-backfill: isWindowedCursorInvalidError matches out-of-bounds and line boundary', async () => {
const mod = await importFresh(path.join(REPO_ROOT, 'src/scripts/tauri/chat/prompt-backfill.js'));
const { isWindowedCursorInvalidError } = mod;
assert.equal(
isWindowedCursorInvalidError('Internal error: Cursor offset is out of bounds for /tmp/chat.jsonl'),
true,
);
assert.equal(
isWindowedCursorInvalidError('Validation error: Cursor offset is not a line boundary'),
true,
);
});
test('prompt-backfill: isWindowedCursorInvalidError matches before-chat-payload-body error', async () => {
const mod = await importFresh(path.join(REPO_ROOT, 'src/scripts/tauri/chat/prompt-backfill.js'));
const { isWindowedCursorInvalidError } = mod;
assert.equal(
isWindowedCursorInvalidError('Cursor offset is before chat payload body for /tmp/chat.jsonl'),
true,
);
});
test('prompt-backfill: isWindowedCursorInvalidError ignores non-cursor errors', async () => {
const mod = await importFresh(path.join(REPO_ROOT, 'src/scripts/tauri/chat/prompt-backfill.js'));
const { isWindowedCursorInvalidError } = mod;
assert.equal(isWindowedCursorInvalidError(new Error('boom')), false);
assert.equal(isWindowedCursorInvalidError('permission denied: no'), false);
});
test('prompt-backfill: isWindowedCursorInvalidError requires known cursor failure hints', async () => {
const mod = await importFresh(path.join(REPO_ROOT, 'src/scripts/tauri/chat/prompt-backfill.js'));
const { isWindowedCursorInvalidError } = mod;
assert.equal(isWindowedCursorInvalidError('cursor is present but message is unrelated'), false);
assert.equal(isWindowedCursorInvalidError({ message: 'cursor: ???' }), false);
});

View file

@ -0,0 +1,54 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { readFile } from 'node:fs/promises';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
const REPO_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
test('windowed payload: showMoreMessages implements single-flight + CAS commit', async () => {
const source = await readFile(path.join(REPO_ROOT, 'src/script.js'), 'utf8');
assert.match(source, /\bwindowedShowMoreMessagesPending\b/);
assert.match(source, /existingPending\?\.\s*state\s*===\s*windowState/);
assert.match(source, /getWindowedChatState\(\)\s*!==\s*windowState/);
});
test('windowed payload: clearChat(clearData:true) invalidates windowed state immediately', async () => {
const source = await readFile(path.join(REPO_ROOT, 'src/script.js'), 'utf8');
const clearChatStart = source.indexOf('export async function clearChat');
assert.ok(clearChatStart >= 0);
const slice = source.slice(clearChatStart, clearChatStart + 600);
assert.match(slice, /cancelDebouncedMetadataSave\(\);/);
assert.match(slice, /if\s*\(\s*clearData\s*\)\s*\{\s*clearWindowedChatState\(\);\s*\}/s);
});
test('windowed payload: getChat drops stale tail-load results before committing UI/state', async () => {
const source = await readFile(path.join(REPO_ROOT, 'src/script.js'), 'utf8');
const getChatStart = source.indexOf('export async function getChat()');
assert.ok(getChatStart >= 0);
const slice = source.slice(getChatStart, getChatStart + 3200);
assert.match(slice, /const\s+startedChid\s*=\s*this_chid\s*;/);
assert.match(slice, /\bconst\s+stillActive\s*=\s*[\s\S]*?;\s*if\s*\(!stillActive\)\s*\{\s*return;\s*\}/s);
});
test('windowed payload: group tail-load does not mutate window state for background reads', async () => {
const source = await readFile(path.join(REPO_ROOT, 'src/scripts/group-chats.js'), 'utf8');
assert.match(source, /async function loadGroupChat\(\s*chatId\s*,\s*\{\s*updateWindowState\s*=\s*false\s*\}\s*=\s*\{\s*\}\s*\)/);
assert.match(source, /if\s*\(\s*updateWindowState[\s\S]*?setWindowedChatState\s*\(/s);
});
test('windowed payload: windowed patch commit is guarded and preserves active cursor offset', async () => {
const script = await readFile(path.join(REPO_ROOT, 'src/script.js'), 'utf8');
const groupChats = await readFile(path.join(REPO_ROOT, 'src/scripts/group-chats.js'), 'utf8');
assert.match(script, /\bgetWindowedChatKey\b/);
assert.match(script, /\bmergeWindowedChatCursorOffset\b/);
assert.match(groupChats, /\bgetWindowedChatKey\b/);
assert.match(groupChats, /\bmergeWindowedChatCursorOffset\b/);
});

View file

@ -0,0 +1,111 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import path from 'node:path';
import { fileURLToPath, pathToFileURL } from 'node:url';
const REPO_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
async function importFresh(modulePath) {
const url = `${pathToFileURL(modulePath).href}?t=${Date.now()}-${Math.random()}`;
return import(url);
}
test('jsonl: payloadToJsonl formats newline-delimited JSON', async () => {
const mod = await importFresh(path.join(REPO_ROOT, 'src/scripts/tauri/chat/jsonl.js'));
const { payloadToJsonl } = mod;
const payload = [{ a: 1 }, { b: 2 }];
assert.equal(payloadToJsonl(payload), `${JSON.stringify(payload[0])}\n${JSON.stringify(payload[1])}`);
});
test('jsonl: payloadToJsonl rejects non-object entries', async () => {
const mod = await importFresh(path.join(REPO_ROOT, 'src/scripts/tauri/chat/jsonl.js'));
const { payloadToJsonl } = mod;
assert.throws(() => payloadToJsonl([null]), /must be an object/i);
assert.throws(() => payloadToJsonl([1]), /must be an object/i);
});
test('jsonl: jsonlToPayload handles BOM on first payload line', async () => {
const mod = await importFresh(path.join(REPO_ROOT, 'src/scripts/tauri/chat/jsonl.js'));
const { jsonlToPayload } = mod;
const bom = '\uFEFF';
const payload = jsonlToPayload(`${bom}{"a":1}\n{"b":2}`);
assert.deepEqual(payload, [{ a: 1 }, { b: 2 }]);
});
test('jsonl: jsonlToPayload ignores whitespace-only lines', async () => {
const mod = await importFresh(path.join(REPO_ROOT, 'src/scripts/tauri/chat/jsonl.js'));
const { jsonlToPayload } = mod;
const payload = jsonlToPayload('\n \n{"a":1}\n\t\n');
assert.deepEqual(payload, [{ a: 1 }]);
});
test('jsonl: jsonlToPayload surfaces line numbers on invalid JSON', async () => {
const mod = await importFresh(path.join(REPO_ROOT, 'src/scripts/tauri/chat/jsonl.js'));
const { jsonlToPayload } = mod;
assert.throws(() => jsonlToPayload('{"a":1}\n{bad}\n'), /Invalid JSONL at line 2/);
});
test('jsonl: payloadToJsonlByteChunks round-trips and respects maxChunkBytes', async () => {
const mod = await importFresh(path.join(REPO_ROOT, 'src/scripts/tauri/chat/jsonl.js'));
const { payloadToJsonl, payloadToJsonlByteChunks } = mod;
const payload = [{ a: 1 }, { b: 2 }, { c: 3 }];
const maxChunkBytes = 16;
const chunks = Array.from(payloadToJsonlByteChunks(payload, { maxChunkBytes }));
assert.ok(chunks.length > 1);
for (const chunk of chunks) {
assert.ok(chunk.byteLength <= maxChunkBytes);
}
const combined = Buffer.concat(chunks.map((chunk) => Buffer.from(chunk)));
assert.equal(combined.toString('utf8'), payloadToJsonl(payload));
});
test('jsonl: jsonlStreamToPayload parses chunked stream input', async () => {
const mod = await importFresh(path.join(REPO_ROOT, 'src/scripts/tauri/chat/jsonl.js'));
const { jsonlStreamToPayload } = mod;
const encoder = new TextEncoder();
const stream = new ReadableStream({
start(controller) {
controller.enqueue(encoder.encode('{"a":1}\n{"b":'));
controller.enqueue(encoder.encode('2}\n{"c":3}\n'));
controller.close();
},
});
const payload = await jsonlStreamToPayload(stream);
assert.deepEqual(payload, [{ a: 1 }, { b: 2 }, { c: 3 }]);
});
test('jsonl: jsonlStreamToPayload cancels reader on parse error during streaming', async () => {
const mod = await importFresh(path.join(REPO_ROOT, 'src/scripts/tauri/chat/jsonl.js'));
const { jsonlStreamToPayload } = mod;
const encoder = new TextEncoder();
let pushed = false;
let canceled = false;
const stream = new ReadableStream({
pull(controller) {
if (pushed) {
return;
}
pushed = true;
controller.enqueue(encoder.encode('{"a":1}\n{bad}\n{"c":3}\n'));
},
cancel() {
canceled = true;
},
});
await assert.rejects(() => jsonlStreamToPayload(stream), /Invalid JSONL at line 2/);
assert.equal(canceled, true);
});

View file

@ -0,0 +1,166 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import path from 'node:path';
import { fileURLToPath, pathToFileURL } from 'node:url';
const REPO_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
async function importFresh(modulePath) {
const url = `${pathToFileURL(modulePath).href}?t=${Date.now()}-${Math.random()}`;
return import(url);
}
function buildMessages(count) {
return Array.from({ length: count }, (_, index) => ({ id: index, mes: `m-${index}` }));
}
test('windowed-state: get/set/clear windowed chat state', async () => {
const mod = await importFresh(path.join(REPO_ROOT, 'src/scripts/tauri/chat/windowed-state.js'));
const { clearWindowedChatState, getWindowedChatState, setWindowedChatState } = mod;
clearWindowedChatState();
assert.equal(getWindowedChatState(), null);
const state = { kind: 'character', fileName: 'a', savedMessageCount: 0, dirtyFromIndex: 0 };
setWindowedChatState(state);
assert.equal(getWindowedChatState(), state);
clearWindowedChatState();
assert.equal(getWindowedChatState(), null);
});
test('windowed-state: getWindowedChatKey trims and separates kinds', async () => {
const mod = await importFresh(path.join(REPO_ROOT, 'src/scripts/tauri/chat/windowed-state.js'));
const { getWindowedChatKey } = mod;
assert.equal(getWindowedChatKey(null), '');
assert.equal(getWindowedChatKey({ kind: 'group', id: ' grp-1 ' }), 'group:grp-1');
assert.equal(getWindowedChatKey({
kind: 'character',
characterName: ' Alice ',
avatarUrl: ' /User%20Avatars/a.png ',
fileName: ' chat-1 ',
}), 'character:Alice|/User%20Avatars/a.png|chat-1');
});
test('windowed-state: mergeWindowedChatCursorOffset preserves active offset', async () => {
const mod = await importFresh(path.join(REPO_ROOT, 'src/scripts/tauri/chat/windowed-state.js'));
const { mergeWindowedChatCursorOffset } = mod;
assert.equal(mergeWindowedChatCursorOffset(null, null), null);
assert.deepEqual(
mergeWindowedChatCursorOffset(null, { offset: 10, size: 1, modifiedMillis: 2 }),
{ offset: 10, size: 1, modifiedMillis: 2 },
);
assert.deepEqual(
mergeWindowedChatCursorOffset({ offset: 10, size: 1, modifiedMillis: 2 }, null),
{ offset: 10, size: 1, modifiedMillis: 2 },
);
assert.deepEqual(
mergeWindowedChatCursorOffset(
{ offset: 10, size: 1, modifiedMillis: 2 },
{ offset: 999, size: 2, modifiedMillis: 3 },
),
{ offset: 10, size: 2, modifiedMillis: 3 },
);
});
test('windowed-state: buildWindowedPayloadPatch rewriteFromIndex when dirtyFromIndex < savedMessageCount', async () => {
const mod = await importFresh(path.join(REPO_ROOT, 'src/scripts/tauri/chat/windowed-state.js'));
const { buildWindowedPayloadPatch } = mod;
const messages = buildMessages(5);
const windowState = { savedMessageCount: 5, dirtyFromIndex: 2 };
const result = buildWindowedPayloadPatch(messages, windowState, 'chat');
assert.equal(result.patch.kind, 'rewriteFromIndex');
assert.equal(result.patch.startIndex, 2);
assert.deepEqual(result.patch.lines, messages.slice(2).map((entry) => JSON.stringify(entry)));
assert.equal(result.savedMessageCount, messages.length);
assert.equal(result.dirtyFromIndex, messages.length);
});
test('windowed-state: buildWindowedPayloadPatch truncates when messages shorter than savedMessageCount', async () => {
const mod = await importFresh(path.join(REPO_ROOT, 'src/scripts/tauri/chat/windowed-state.js'));
const { buildWindowedPayloadPatch } = mod;
const messages = buildMessages(3);
const windowState = { savedMessageCount: 5, dirtyFromIndex: 5 };
const result = buildWindowedPayloadPatch(messages, windowState, 'chat');
assert.deepEqual(result.patch, { kind: 'rewriteFromIndex', startIndex: 3, lines: [] });
assert.equal(result.savedMessageCount, messages.length);
assert.equal(result.dirtyFromIndex, messages.length);
});
test('windowed-state: buildWindowedPayloadPatch appends when messages longer than savedMessageCount', async () => {
const mod = await importFresh(path.join(REPO_ROOT, 'src/scripts/tauri/chat/windowed-state.js'));
const { buildWindowedPayloadPatch } = mod;
const messages = buildMessages(5);
const windowState = { savedMessageCount: 3, dirtyFromIndex: 3 };
const result = buildWindowedPayloadPatch(messages, windowState, 'chat');
assert.equal(result.patch.kind, 'append');
assert.deepEqual(result.patch.lines, messages.slice(3).map((entry) => JSON.stringify(entry)));
assert.equal(result.savedMessageCount, messages.length);
assert.equal(result.dirtyFromIndex, messages.length);
});
test('windowed-state: buildWindowedPayloadPatch full rewrite when unchanged but non-empty', async () => {
const mod = await importFresh(path.join(REPO_ROOT, 'src/scripts/tauri/chat/windowed-state.js'));
const { buildWindowedPayloadPatch } = mod;
const messages = buildMessages(2);
const windowState = { savedMessageCount: 2, dirtyFromIndex: 2 };
const result = buildWindowedPayloadPatch(messages, windowState, 'chat');
assert.deepEqual(result.patch, {
kind: 'rewriteFromIndex',
startIndex: 0,
lines: messages.map((entry) => JSON.stringify(entry)),
});
});
test('windowed-state: buildWindowedPayloadPatch returns empty append for empty message list', async () => {
const mod = await importFresh(path.join(REPO_ROOT, 'src/scripts/tauri/chat/windowed-state.js'));
const { buildWindowedPayloadPatch } = mod;
const result = buildWindowedPayloadPatch([], { savedMessageCount: 0, dirtyFromIndex: 0 }, 'chat');
assert.deepEqual(result.patch, { kind: 'append', lines: [] });
assert.equal(result.savedMessageCount, 0);
assert.equal(result.dirtyFromIndex, 0);
});
test('windowed-state: shiftWindowedMessageSaveState shifts counters without mutating original', async () => {
const mod = await importFresh(path.join(REPO_ROOT, 'src/scripts/tauri/chat/windowed-state.js'));
const { shiftWindowedMessageSaveState } = mod;
const windowState = { kind: 'character', savedMessageCount: 2, dirtyFromIndex: 1, foo: 'bar' };
const shifted = shiftWindowedMessageSaveState(windowState, 3, 'chat');
assert.notEqual(shifted, windowState);
assert.equal(shifted.foo, 'bar');
assert.equal(shifted.savedMessageCount, 5);
assert.equal(shifted.dirtyFromIndex, 4);
assert.equal(windowState.savedMessageCount, 2);
assert.equal(windowState.dirtyFromIndex, 1);
});
test('windowed-state: readWindowedMessageSaveState throws on missing counters', async () => {
const mod = await importFresh(path.join(REPO_ROOT, 'src/scripts/tauri/chat/windowed-state.js'));
const { readWindowedMessageSaveState } = mod;
assert.throws(
() => readWindowedMessageSaveState({}, 'chat'),
/savedMessageCount is missing/i,
);
assert.throws(
() => readWindowedMessageSaveState({ savedMessageCount: 0 }, 'chat'),
/dirtyFromIndex is missing/i,
);
});

View file

@ -0,0 +1,39 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import path from 'node:path';
import { fileURLToPath, pathToFileURL } from 'node:url';
const REPO_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
async function importFresh(modulePath) {
const url = `${pathToFileURL(modulePath).href}?t=${Date.now()}-${Math.random()}`;
return import(url);
}
test('transport: normalizeChatFileName strips .jsonl and trims', async () => {
const mod = await importFresh(path.join(REPO_ROOT, 'src/scripts/tauri/chat/transport.js'));
const { normalizeChatFileName } = mod;
assert.equal(normalizeChatFileName(' hello.jsonl '), 'hello');
assert.equal(normalizeChatFileName('world.JSONL'), 'world');
assert.equal(normalizeChatFileName('already-normalized'), 'already-normalized');
assert.equal(normalizeChatFileName(''), '');
assert.equal(normalizeChatFileName(null), '');
});
test('transport: resolveCharacterDirectoryId prefers avatar internal id', async () => {
const mod = await importFresh(path.join(REPO_ROOT, 'src/scripts/tauri/chat/transport.js'));
const { resolveCharacterDirectoryId } = mod;
assert.equal(resolveCharacterDirectoryId('Alice', 'User Avatars/abc123.png'), 'abc123');
assert.equal(resolveCharacterDirectoryId('Alice', 'thumbnail?file=foo.png'), 'foo');
assert.equal(resolveCharacterDirectoryId('Alice', 'thumbnail?file=my%20avatar.png'), 'my avatar');
});
test('transport: resolveCharacterDirectoryId falls back to character name when avatar is missing', async () => {
const mod = await importFresh(path.join(REPO_ROOT, 'src/scripts/tauri/chat/transport.js'));
const { resolveCharacterDirectoryId } = mod;
assert.equal(resolveCharacterDirectoryId(' Alice ', null), 'Alice');
assert.equal(resolveCharacterDirectoryId(' Alice ', ''), 'Alice');
});