LibreChat/api/app/clients/specs/BaseClient.test.js
Marco Beretta d920328bfa
💬 style: Unify Message Row Layout and Edit Surfaces (#14770)
* style: Unify message row layout and edit surfaces

Route chat, share, and search messages through a shared MessageRow so
user turns render as right-aligned bubbles and assistant turns keep a
visible identity column.

Replace per-part text editors with one edit surface that keeps tools,
errors, and artifacts visible. Preserve non-text fields when saving
content parts, copy the full serialized message, and hide hover actions
that do not apply during streaming or errors.

* style: Align edit footer and lighten editor field in dark mode

Drop the divider above the user edit footer so both edit surfaces share
the same footer treatment.

Move the editor fields to surface-tertiary-alt. Light mode is unchanged
at #fff, while dark mode lifts from #0d0d0d to #2f2f2f so the field sits
above the #212121 panel instead of sinking into near-black.

* style: Drop focus border and ring from message editors

The editor fields changed border color and added a ring on focus. Keep
the border static and rely on the app-level focus handling instead.

* fix: Keep a triggered message action visible when the row is not hovered

Hover actions fade out on non-last rows, and mobile.css only restored
display and visibility for an active button, never opacity. Opening the
fork popover therefore left it anchored to an invisible trigger once the
pointer left the row. Skip the fade entirely while a button is active.

Extract the recipe the three toolbars repeated so the rule has one home.

Rework the streaming guard to the contract the toolbar now implements:
edit and fork are omitted from a streaming response rather than rendered
disabled, and the settled turn above keeps its own actions. It asserted
the removed disabled-and-transparent behaviour and its opacity check only
held because the growing response shifted the row out from under the
pointer.

* style: Trim message edit chrome and stabilize the status row

The edit surface was a titled card sitting inside the conversation: a
bordered panel with an "Edit message" heading wrapping bordered fields,
which read as a settings dialog rather than an inline editor. Drop the
card background, border and heading, and take the footer buttons down to
the small size so the editor reads as a field in the message flow. The
captured row goes from 253px to 187px.

Move "Unsaved changes" into the footer and merge the rerun hint into the
same slot. Both previously added their own row, so typing pushed the rest
of the conversation down. The slot is clamped to two lines, which stays
under the 36px button row, so the footer height holds at 36px regardless
of which message is showing.

* test: Cover message edit layout stability

Add a mock e2e spec that measures the edit footer and section boxes and
asserts they hold steady as the status text appears, for both the
single-part user editor and a multi-part response.

The multi-part case needs an assistant message with two editable parts,
so add an E2E_THINK_REPLY marker to the fake model. Its think tags are
parsed downstream by the agents stream pipeline, which yields a reasoning
part followed by a text part.

* fix: Read the fork popover open state from its store

Fork mirrored the popover state into its own useState and reset it from an
onClose prop. Ariakit 0.4 has no onClose, and React's DOM types accept the
name on any element, so it type-checked, landed on a div and never fired.
Closing by Escape or an outside click therefore left the button reading as
active until the trigger was clicked again.

Read the state from the store instead so every close path clears it.

* fix: Keep the whole toolbar visible while an action is open

Only the triggered button escaped the hover fade, so opening the editor or
the fork popover left the row as a single floating button once the pointer
moved away. Mark the active button and have every action in the toolbar key
off it, so the group stays opaque for as long as a surface is open.

The marker is a dedicated class rather than the existing `active`, which
HoverButtons pins to the edit button of every assistant message and would
hold those toolbars open permanently.

The existing guard pressed Escape to close the editor while focus sat on the
body, so the editor never closed and its assertion only held because the
sibling faded regardless. Close the editor through its own control, and drop
focus before measuring the fade now that Escape returns it to the trigger.

* fix: Withhold copy while a response is still streaming

Text-to-speech, fork and feedback were all withheld from a message that is
still generating, but copy was rendered throughout, so the button offered to
put half a sentence on the clipboard. Gate it on the same condition.

That empties the toolbar for the duration, and SubRow collapses an empty row,
so a streaming response now carries no actions at all until it settles. Both
guards encoded the old contract: the unit test asserted copy was present and
counted a single button, and the browser guard used copy as its proof that the
toolbar had mounted. The settled turn above takes over that role.

* fix: Move retry navigation to the outer edge of a user turn

A user turn is right-aligned, but its sibling navigation rendered ahead of the
actions, so the retry counter sat inboard of the icons instead of under the
edge of the bubble it belongs to. Order it last on user turns.

* fix: Ride the stream instead of chasing it

Following a generating answer went through a helper throttled at 145ms, so the
thread caught up in visible jerks rather than flowing. It now writes the scroll
position directly on each frame, which is what an answer arriving a few pixels
at a time actually needs, and glides only for the one long trip a turn makes,
when sending has to travel from wherever the reader was down to the newest
word.

Whether to follow at all is now answered by where the reader is and which way
they were going, rather than by the abort flag. `useMessageProcess` raises that
flag on any wheel at all, downward ones included, through a throttle whose
trailing call lands after the gesture has ended, so nothing timed to the
gesture could outlive it. Scrolling down to the newest word could therefore
never resume the ride, while the scroll-to-bottom button, which touches no
wheel, always could.

Arrival is judged on the scroll it produces rather than the wheel tick that
started it, because wheel scrolling is animated and at tick time the thread is
still far short of where the tick is taking it. Arriving also counts from
further out than leaving does: while an answer streams the end recedes between
the last tick and the frame that measures it, so judging arrival as tightly as
departure leaves a reader unable to catch it at all.

* fix: Reveal retry navigation on hover while an answer generates

Copy, edit, fork and read-aloud are all withheld from a response that is still
generating, which left the retry counter as the only thing rendering under a
half-written answer. It now reveals on hover there, like the actions it sits
with, and stays put on a settled turn.

* fix: Keep a refused rerun from discarding the edit

While a response is streaming, the edit action stays available on every earlier
row, and those editors see a per-message submitting flag that is false, so
Update and rerun is enabled. The send itself is still refused: ask() returns
false for the duration of the active submission. Both editors ignored that and
closed anyway, so the draft went with them and no rerun ever started.

Both rerun paths now check the result and leave the editor untouched when the
send is refused, so the work survives until the thread is free.

* fix: Let an upward gesture beat the pending send glide

Sending arms a smooth glide down to the newest word, and the landing re-pins the
thread to the bottom. The landing was scheduled two ways, on scrollend and on a
700ms fallback, and neither was ever cancelled. A reader who changed their mind
and headed up mid-flight was pinned again regardless, then dragged back by the
next streaming resize. The fallback fires for the whole window, so this held even
after the glide had visibly settled.

The gesture now marks the glide interrupted, wherever it lets go of the bottom,
and the landing stands down when it sees that. A glide the reader leaves alone
still re-affirms the ride.

* fix: Fade retry navigation on every streaming response format

Every other action is withheld from the row that is still generating, so the
retry counter is the only thing left under a half-written answer. The plain text
row already faded it to hover-only there; the structured rows did not, and left
it sitting on its own.

Both structured paths now apply the same condition, and the class string the
three of them share moves next to the hover action styles it belongs with.

* i18n: Correct the copy the edit surface rewrite left behind

The multi-part hint told the reader to save first and then rerun, but a save
closes the editor and reopening seeds the drafts from what was just saved, so
there is nothing left to rerun and the button stays disabled. Rerunning carries a
single edited section by design, so the hint now states that limit rather than
pointing at a step that is not there.

Drop com_ui_save_submit as well: the per-part editor that used it is gone.

* test: Make the message visual baselines opt-in

The suite asserts sixteen screenshots and the repository tracks none, so
Playwright's default treats every one as a miss and the mock e2e job fails on
Linux. Baselines only compare cleanly against the machine that produced them, and
nothing here can generate ones that match the runner image.

The flows keep running and asserting their structure, which is where their value
was; only the pixel comparison is now gated behind E2E_VISUAL_SNAPSHOTS.

* style: Restore import order in the reworked message files

The repository sorter and CI disagreed with what these files were left holding
after the edit surface rework. No behavior change.

* test: Follow the reworded rerun hint in the edit layout spec

The multi-part hint was restated in the previous commit; this assertion still
expected the old wording and would have failed the mock e2e suite.

* fix: Leave the send glide alone while the answer streams in

Every delta of an answer reruns the scroll effect, and the plain follow writes
scrollTop outright, which cancels an animation on its first frame. So the glide a
send starts was killed by the first token to arrive and the reader was snapped
down instead of carried.

The follow now stands down while a glide is travelling, which is what the hook
already documented but only enforced on the resize path.

* fix: Write a saved edit onto the thread as it stands

An earlier turn stays editable while the newest answer streams, and the save
captured the thread before the request but wrote it back after. Every delta that
landed during the round trip was overwritten. Most of the time the next delta
re-merged and the damage showed as a one-frame truncation, but a save that
resolved after the stream's final write left the cache wrong for the rest of the
session.

The thread is now read once the request has resolved, which is what the content
part editor already did.

The editor actions in this file also wrap again rather than hold one unbreakable
row, for the reason given in the following commit.

* fix: Let the editor actions wrap on a narrow row

At 320px an assistant turn gives the editor about 252px once page padding, the
identity column and the row gap are taken out, and Cancel, Save and Update &
rerun need more than that in English alone. The group was pinned with shrink-0,
so it ran past the edge of the row instead of wrapping. A longer translated label
makes it worse, and the user turn had no margin left either.

Both editors wrap again, which is what the footer did before the status row was
folded into it.

* fix: Catch up to the new bottom when the glide lands

Following stands down for the length of the glide, so an answer that arrives
while it travels moves the bottom past the target the glide aimed at. A short
response that finished before the glide reported landing left the thread a few
lines short of its own end, with nothing left to correct it.

Landing now closes whatever gap opened, unless the reader took over on the way.

* test: Follow the renamed rerun button in the edit flow specs

The button became 'Update & rerun' when the edit surfaces were unified, but two
edit-flow specs still located 'Save & Submit' and would have waited for it until
they timed out. A type comment named the old button too.

* fix: Judge the first thread scroll against a real position

The direction check seeded its last-position ref at 0, so the first scroll
event on an opened thread, which arrives carrying a large positive
scrollTop, read as a jump downward. Near the end that cleared the abort
flag and re-pinned a reader to the stream they were scrolling away from.

Take the first event as a baseline and judge direction from the next.

* fix: Hold the content part editor to what it replaced

EditContentParts took over from EditTextPart and left two of its behaviors
behind.

An emptied box now blocks Save and rerun instead of persisting a blank
part. EditTextPart refused the same edit through its form's required rule
and the sibling EditMessage still does, so both editors hold one line. The
keyboard shortcuts reach the save paths directly, so they are guarded
there too, and the footer says why the buttons are down.

The editor also follows the chat direction again, taking dir and text
alignment from the same setting EditMessage reads.

* fix: Hold the footer height while a response streams

Every action is withheld from the row that is still generating, and a lone
sibling counter renders nothing, so the footer measured zero until the answer
landed and then sprang to the height of the buttons. The transcript stepped
upward under the reader at the moment a response completed.

The placeholder that used to reserve this space went when the footer became
unconditional, so hold the height on the row itself instead.

* fix: Remember where the thread was put before judging a gesture

Direction is judged against the last sample, and the thread is placed at its
end without the reader touching it. With no record of where it was put, their
first gesture was spent taking the baseline instead of being obeyed: a single
PageUp cleared no flag of its own, so the next streamed resize rode the reader
straight back to the end they were leaving.

Every programmatic move now records the position it left the thread at, so the
sentinel stands only until something has actually placed it.

* fix: Spend the start of a turn only once it can be honored

A reader who scrolls away during one answer leaves the abort flag raised, and
nothing lowers it until the next connection opens, which is after this effect
has already seen the send. Marking the turn as started on that first pass spent
it against a closed gate: by the time the flag cleared there was no start left
to honor, the reader was still detached, and the answer they had just asked for
streamed on offscreen.

Record the turn as started only on the pass that acts on it.

* fix: Show the part edits that survived a refused save

The editor saves every changed part through one button, but the endpoint
takes a single part per call and nothing rolls a write back. A part the
server refused therefore left the earlier ones stored while the editor
reported that the message could not be saved, so cancelling from there
walked away from edits that were already live.

Record the writes that landed and reconcile the transcript with them
whichever way the save ended. The refused parts are the only ones left
holding a draft, so a retry no longer rewrites what already arrived.

* fix: Stop a shared transcript from calling the sharer the reader

The share row reused the chat view's user label, which reads "You". It is
the screen-reader heading for the user turn, so anyone opening a share
link heard every prompt the sharer wrote credited to themselves.

Use the neutral "User" label on this surface. It keeps the localization
the row gained, unlike the untranslated string it replaced.

* fix: Let go of the stream when an interaction settles over several resizes

Expanding a tool result mid-answer renders the container first and fills it
once its contents arrive, so one gesture produces more than one resize. Only
the first was credited to the interaction. The second read the reader as still
riding the stream and put them back on the bottom they had just left.

The suppressed resize now settles the ride as well as the near-bottom measure,
using the position the interaction actually left the reader at, so an
interaction that kept them on the end still streams.

* fix: Edit inside a structured text part instead of flattening it

A text content part holds either a string or a { value, annotations } object.
The Assistants thread sync persists the structured form with its file
citations intact, and the editor reads the part through the same union, so
saving an edit wrote a bare string over the whole object and took every
citation with it.

The same object was handed to the tokenizer, which measures length, so a part
that had been edited this way also stored a NaN token count. Write the edit
into value, keep the rest of the part, and count the text itself.

* fix: Keep a saved part's citations in the transcript it is written back to

A text or think part holds either a bare string or a { value, annotations }
object, and the editor already read both through getPartText. Writing the
draft back into the local message cache put the string over the whole value,
so a response carrying file citations lost them the moment it was edited and
did not get them back until a refetch.

Reading and writing now go through the same accessor, so an edit lands in the
shape it was read from and the rest of the part survives.

* fix: Let the message editor follow the chosen font size

Editing a message dropped the draft to a fixed 14px regardless of the
Font Size setting. On dev the textarea carried the markdown class, so it
read --markdown-font-size like the rendered message does; restyling it
into a bordered box replaced that with text-sm, and the new per-part
editor was written the same way. Anyone on Extra Small, Large or Extra
Large saw the text jump the moment they entered edit mode.

Share the .message-content typography with the editors through a
message-editor-text class so a draft is sized like the message it
replaces and keeps tracking the setting.
2026-08-13 19:30:39 -04:00

1960 lines
70 KiB
JavaScript

const { Constants, ContentTypes } = require('librechat-data-provider');
const { FakeClient, initializeFakeClient } = require('./FakeClient');
function deferred() {
let resolve;
const promise = new Promise((resolvePromise) => {
resolve = resolvePromise;
});
return { promise, resolve };
}
jest.mock('~/db/connect');
jest.mock('~/server/services/Config', () => ({
getAppConfig: jest.fn().mockResolvedValue({
// Default app config for tests
paths: { uploads: '/tmp' },
fileStrategy: 'local',
memory: { disabled: false },
}),
}));
jest.mock('~/models', () => ({
User: jest.fn(),
Key: jest.fn(),
Session: jest.fn(),
Balance: jest.fn(),
Transaction: jest.fn(),
getMessages: jest.fn().mockResolvedValue([]),
saveMessage: jest.fn(),
updateMessage: jest.fn(),
deleteMessagesSince: jest.fn(),
deleteMessages: jest.fn(),
getConvoTitle: jest.fn(),
getConvo: jest.fn(),
saveConvo: jest.fn(),
deleteConvos: jest.fn(),
getPreset: jest.fn(),
getPresets: jest.fn(),
savePreset: jest.fn(),
deletePresets: jest.fn(),
findFileById: jest.fn(),
createFile: jest.fn(),
updateFile: jest.fn(),
deleteFile: jest.fn(),
deleteFiles: jest.fn(),
getFiles: jest.fn(),
updateFileUsage: jest.fn(),
}));
const { getConvo, getFiles, getMessages, saveConvo, saveMessage } = require('~/models');
jest.mock('@librechat/agents', () => {
const actual = jest.requireActual('@librechat/agents');
return {
...actual,
ChatOpenAI: jest.fn().mockImplementation(() => {
return {};
}),
};
});
let parentMessageId;
let conversationId;
const fakeMessages = [];
const userMessage = 'Hello, ChatGPT!';
const apiKey = 'fake-api-key';
const messageHistory = [
{ role: 'user', isCreatedByUser: true, text: 'Hello', messageId: '1' },
{ role: 'assistant', isCreatedByUser: false, text: 'Hi', messageId: '2', parentMessageId: '1' },
{
role: 'user',
isCreatedByUser: true,
text: "What's up",
messageId: '3',
parentMessageId: '2',
},
];
describe('BaseClient', () => {
let TestClient;
const options = {
// debug: true,
modelOptions: {
model: 'gpt-4o-mini',
temperature: 0,
},
};
beforeEach(() => {
TestClient = initializeFakeClient(apiKey, options, fakeMessages);
TestClient.summarizeMessages = jest.fn().mockResolvedValue({
summaryMessage: {
role: 'system',
content: 'Refined answer',
},
summaryTokenCount: 5,
});
});
test('returns the input messages without instructions when addInstructions() is called with empty instructions', () => {
const messages = [{ content: 'Hello' }, { content: 'How are you?' }, { content: 'Goodbye' }];
const instructions = '';
const result = TestClient.addInstructions(messages, instructions);
expect(result).toEqual(messages);
});
test('returns the input messages with instructions properly added when addInstructions() is called with non-empty instructions', () => {
const messages = [{ content: 'Hello' }, { content: 'How are you?' }, { content: 'Goodbye' }];
const instructions = { content: 'Please respond to the question.' };
const result = TestClient.addInstructions(messages, instructions);
const expected = [
{ content: 'Please respond to the question.' },
{ content: 'Hello' },
{ content: 'How are you?' },
{ content: 'Goodbye' },
];
expect(result).toEqual(expected);
});
test('returns the input messages with instructions properly added when addInstructions() with legacy flag', () => {
const messages = [{ content: 'Hello' }, { content: 'How are you?' }, { content: 'Goodbye' }];
const instructions = { content: 'Please respond to the question.' };
const result = TestClient.addInstructions(messages, instructions, true);
const expected = [
{ content: 'Hello' },
{ content: 'How are you?' },
{ content: 'Please respond to the question.' },
{ content: 'Goodbye' },
];
expect(result).toEqual(expected);
});
test('concats messages correctly in concatenateMessages()', () => {
const messages = [
{ name: 'User', content: 'Hello' },
{ name: 'Assistant', content: 'How can I help you?' },
{ name: 'User', content: 'I have a question.' },
];
const result = TestClient.concatenateMessages(messages);
const expected =
'User:\nHello\n\nAssistant:\nHow can I help you?\n\nUser:\nI have a question.\n\n';
expect(result).toBe(expected);
});
test('refines messages correctly in summarizeMessages()', async () => {
const messagesToRefine = [
{ role: 'user', content: 'Hello', tokenCount: 10 },
{ role: 'assistant', content: 'How can I help you?', tokenCount: 20 },
];
const remainingContextTokens = 100;
const expectedRefinedMessage = {
role: 'system',
content: 'Refined answer',
};
const result = await TestClient.summarizeMessages({ messagesToRefine, remainingContextTokens });
expect(result.summaryMessage).toEqual(expectedRefinedMessage);
});
test('gets messages within token limit (under limit) correctly in getMessagesWithinTokenLimit()', async () => {
TestClient.maxContextTokens = 100;
TestClient.shouldSummarize = true;
const messages = [
{ role: 'user', content: 'Hello', tokenCount: 5 },
{ role: 'assistant', content: 'How can I help you?', tokenCount: 19 },
{ role: 'user', content: 'I have a question.', tokenCount: 18 },
];
const expectedContext = [
{ role: 'user', content: 'Hello', tokenCount: 5 }, // 'Hello'.length
{ role: 'assistant', content: 'How can I help you?', tokenCount: 19 },
{ role: 'user', content: 'I have a question.', tokenCount: 18 },
];
// Subtract 3 tokens for Assistant Label priming after all messages have been counted.
const expectedRemainingContextTokens = 58 - 3; // (100 - 5 - 19 - 18) - 3
const expectedMessagesToRefine = [];
const lastExpectedMessage =
expectedMessagesToRefine?.[expectedMessagesToRefine.length - 1] ?? {};
const expectedIndex = messages.findIndex((msg) => msg.content === lastExpectedMessage?.content);
const result = await TestClient.getMessagesWithinTokenLimit({ messages });
expect(result.context).toEqual(expectedContext);
expect(result.messagesToRefine.length - 1).toEqual(expectedIndex);
expect(result.remainingContextTokens).toBe(expectedRemainingContextTokens);
expect(result.messagesToRefine).toEqual(expectedMessagesToRefine);
});
test('gets result over token limit correctly in getMessagesWithinTokenLimit()', async () => {
TestClient.maxContextTokens = 50; // Set a lower limit
TestClient.shouldSummarize = true;
const messages = [
{ role: 'user', content: 'Hello', tokenCount: 30 },
{ role: 'assistant', content: 'How can I help you?', tokenCount: 30 },
{ role: 'user', content: 'I have a question.', tokenCount: 5 },
{ role: 'user', content: 'I need a coffee, stat!', tokenCount: 19 },
{ role: 'assistant', content: 'Sure, I can help with that.', tokenCount: 18 },
];
// Subtract 3 tokens for Assistant Label priming after all messages have been counted.
const expectedRemainingContextTokens = 5; // (50 - 18 - 19 - 5) - 3
const expectedMessagesToRefine = [
{ role: 'user', content: 'Hello', tokenCount: 30 },
{ role: 'assistant', content: 'How can I help you?', tokenCount: 30 },
];
const expectedContext = [
{ role: 'user', content: 'I have a question.', tokenCount: 5 },
{ role: 'user', content: 'I need a coffee, stat!', tokenCount: 19 },
{ role: 'assistant', content: 'Sure, I can help with that.', tokenCount: 18 },
];
const lastExpectedMessage =
expectedMessagesToRefine?.[expectedMessagesToRefine.length - 1] ?? {};
const expectedIndex = messages.findIndex((msg) => msg.content === lastExpectedMessage?.content);
const result = await TestClient.getMessagesWithinTokenLimit({ messages });
expect(result.context).toEqual(expectedContext);
expect(result.messagesToRefine.length - 1).toEqual(expectedIndex);
expect(result.remainingContextTokens).toBe(expectedRemainingContextTokens);
expect(result.messagesToRefine).toEqual(expectedMessagesToRefine);
});
describe('getMessagesForConversation', () => {
it('should return an empty array if the parentMessageId does not exist', () => {
const result = TestClient.constructor.getMessagesForConversation({
messages: unorderedMessages,
parentMessageId: '999',
});
expect(result).toEqual([]);
});
it('should handle messages with messageId property', () => {
const messagesWithMessageId = [
{ messageId: '1', parentMessageId: null, text: 'Message 1' },
{ messageId: '2', parentMessageId: '1', text: 'Message 2' },
];
const result = TestClient.constructor.getMessagesForConversation({
messages: messagesWithMessageId,
parentMessageId: '2',
});
expect(result).toEqual([
{ messageId: '1', parentMessageId: null, text: 'Message 1' },
{ messageId: '2', parentMessageId: '1', text: 'Message 2' },
]);
});
const messagesWithNullParent = [
{ id: '1', parentMessageId: null, text: 'Message 1' },
{ id: '2', parentMessageId: null, text: 'Message 2' },
];
it('should handle messages with null parentMessageId that are not root', () => {
const result = TestClient.constructor.getMessagesForConversation({
messages: messagesWithNullParent,
parentMessageId: '2',
});
expect(result).toEqual([{ id: '2', parentMessageId: null, text: 'Message 2' }]);
});
const cyclicMessages = [
{ id: '3', parentMessageId: '2', text: 'Message 3' },
{ id: '1', parentMessageId: '3', text: 'Message 1' },
{ id: '2', parentMessageId: '1', text: 'Message 2' },
];
it('should handle cyclic references without going into an infinite loop', () => {
const result = TestClient.constructor.getMessagesForConversation({
messages: cyclicMessages,
parentMessageId: '3',
});
expect(result).toEqual([
{ id: '1', parentMessageId: '3', text: 'Message 1' },
{ id: '2', parentMessageId: '1', text: 'Message 2' },
{ id: '3', parentMessageId: '2', text: 'Message 3' },
]);
});
const unorderedMessages = [
{ id: '3', parentMessageId: '2', text: 'Message 3' },
{ id: '2', parentMessageId: '1', text: 'Message 2' },
{ id: '1', parentMessageId: Constants.NO_PARENT, text: 'Message 1' },
];
it('should return ordered messages based on parentMessageId', () => {
const result = TestClient.constructor.getMessagesForConversation({
messages: unorderedMessages,
parentMessageId: '3',
});
expect(result).toEqual([
{ id: '1', parentMessageId: Constants.NO_PARENT, text: 'Message 1' },
{ id: '2', parentMessageId: '1', text: 'Message 2' },
{ id: '3', parentMessageId: '2', text: 'Message 3' },
]);
});
const unorderedBranchedMessages = [
{ id: '4', parentMessageId: '2', text: 'Message 4', summary: 'Summary for Message 4' },
{ id: '10', parentMessageId: '7', text: 'Message 10' },
{ id: '1', parentMessageId: null, text: 'Message 1' },
{ id: '6', parentMessageId: '5', text: 'Message 7' },
{ id: '7', parentMessageId: '5', text: 'Message 7' },
{ id: '2', parentMessageId: '1', text: 'Message 2' },
{ id: '8', parentMessageId: '6', text: 'Message 8' },
{ id: '5', parentMessageId: '3', text: 'Message 5' },
{ id: '3', parentMessageId: '1', text: 'Message 3' },
{ id: '6', parentMessageId: '4', text: 'Message 6' },
{ id: '8', parentMessageId: '7', text: 'Message 9' },
{ id: '9', parentMessageId: '7', text: 'Message 9' },
{ id: '11', parentMessageId: '2', text: 'Message 11', summary: 'Summary for Message 11' },
];
it('should return ordered messages from a branched array based on parentMessageId', () => {
const result = TestClient.constructor.getMessagesForConversation({
messages: unorderedBranchedMessages,
parentMessageId: '10',
summary: true,
});
expect(result).toEqual([
{ id: '1', parentMessageId: null, text: 'Message 1' },
{ id: '3', parentMessageId: '1', text: 'Message 3' },
{ id: '5', parentMessageId: '3', text: 'Message 5' },
{ id: '7', parentMessageId: '5', text: 'Message 7' },
{ id: '10', parentMessageId: '7', text: 'Message 10' },
]);
});
it('should return an empty array if no messages are provided', () => {
const result = TestClient.constructor.getMessagesForConversation({
messages: [],
parentMessageId: '3',
});
expect(result).toEqual([]);
});
it('should map over the ordered messages if mapMethod is provided', () => {
const mapMethod = (msg) => msg.text;
const result = TestClient.constructor.getMessagesForConversation({
messages: unorderedMessages,
parentMessageId: '3',
mapMethod,
});
expect(result).toEqual(['Message 1', 'Message 2', 'Message 3']);
});
let unorderedMessagesWithSummary = [
{ id: '4', parentMessageId: '3', text: 'Message 4' },
{ id: '2', parentMessageId: '1', text: 'Message 2', summary: 'Summary for Message 2' },
{ id: '3', parentMessageId: '2', text: 'Message 3', summary: 'Summary for Message 3' },
{ id: '1', parentMessageId: null, text: 'Message 1' },
];
it('should start with the message that has a summary property and continue until the specified parentMessageId', () => {
const result = TestClient.constructor.getMessagesForConversation({
messages: unorderedMessagesWithSummary,
parentMessageId: '4',
summary: true,
});
expect(result).toEqual([
{
id: '3',
parentMessageId: '2',
role: 'system',
text: 'Message 3',
content: [{ type: 'text', text: 'Summary for Message 3' }],
summary: 'Summary for Message 3',
},
{ id: '4', parentMessageId: '3', text: 'Message 4' },
]);
});
it('should handle multiple summaries and return the branch from the latest to the parentMessageId', () => {
unorderedMessagesWithSummary = [
{ id: '5', parentMessageId: '4', text: 'Message 5' },
{ id: '2', parentMessageId: '1', text: 'Message 2', summary: 'Summary for Message 2' },
{ id: '3', parentMessageId: '2', text: 'Message 3', summary: 'Summary for Message 3' },
{ id: '4', parentMessageId: '3', text: 'Message 4', summary: 'Summary for Message 4' },
{ id: '1', parentMessageId: null, text: 'Message 1' },
];
const result = TestClient.constructor.getMessagesForConversation({
messages: unorderedMessagesWithSummary,
parentMessageId: '5',
summary: true,
});
expect(result).toEqual([
{
id: '4',
parentMessageId: '3',
role: 'system',
text: 'Message 4',
content: [{ type: 'text', text: 'Summary for Message 4' }],
summary: 'Summary for Message 4',
},
{ id: '5', parentMessageId: '4', text: 'Message 5' },
]);
});
it('should handle summary at root edge case and continue until the parentMessageId', () => {
unorderedMessagesWithSummary = [
{ id: '5', parentMessageId: '4', text: 'Message 5' },
{ id: '1', parentMessageId: null, text: 'Message 1', summary: 'Summary for Message 1' },
{ id: '4', parentMessageId: '3', text: 'Message 4', summary: 'Summary for Message 4' },
{ id: '2', parentMessageId: '1', text: 'Message 2', summary: 'Summary for Message 2' },
{ id: '3', parentMessageId: '2', text: 'Message 3', summary: 'Summary for Message 3' },
];
const result = TestClient.constructor.getMessagesForConversation({
messages: unorderedMessagesWithSummary,
parentMessageId: '5',
summary: true,
});
expect(result).toEqual([
{
id: '4',
parentMessageId: '3',
role: 'system',
text: 'Message 4',
content: [{ type: 'text', text: 'Summary for Message 4' }],
summary: 'Summary for Message 4',
},
{ id: '5', parentMessageId: '4', text: 'Message 5' },
]);
});
it('should detect summary content block and use it over legacy fields (summary mode)', () => {
const messagesWithContentBlock = [
{ id: '3', parentMessageId: '2', text: 'Message 3' },
{
id: '2',
parentMessageId: '1',
text: 'Message 2',
content: [
{ type: 'text', text: 'Original text' },
{ type: 'summary', text: 'Content block summary', tokenCount: 42 },
],
},
{ id: '1', parentMessageId: null, text: 'Message 1' },
];
const result = TestClient.constructor.getMessagesForConversation({
messages: messagesWithContentBlock,
parentMessageId: '3',
summary: true,
});
expect(result).toHaveLength(2);
expect(result[0].role).toBe('system');
expect(result[0].content).toEqual([{ type: 'text', text: 'Content block summary' }]);
expect(result[0].tokenCount).toBe(42);
});
it('should prefer content block summary over legacy summary field', () => {
const messagesWithBoth = [
{ id: '2', parentMessageId: '1', text: 'Message 2' },
{
id: '1',
parentMessageId: null,
text: 'Message 1',
summary: 'Legacy summary',
summaryTokenCount: 10,
content: [{ type: 'summary', text: 'Content block summary', tokenCount: 20 }],
},
];
const result = TestClient.constructor.getMessagesForConversation({
messages: messagesWithBoth,
parentMessageId: '2',
summary: true,
});
expect(result).toHaveLength(2);
expect(result[0].content).toEqual([{ type: 'text', text: 'Content block summary' }]);
expect(result[0].tokenCount).toBe(20);
});
it('should fallback to legacy summary when no content block exists', () => {
const messagesWithLegacy = [
{ id: '2', parentMessageId: '1', text: 'Message 2' },
{
id: '1',
parentMessageId: null,
text: 'Message 1',
summary: 'Legacy summary only',
summaryTokenCount: 15,
},
];
const result = TestClient.constructor.getMessagesForConversation({
messages: messagesWithLegacy,
parentMessageId: '2',
summary: true,
});
expect(result).toHaveLength(2);
expect(result[0].content).toEqual([{ type: 'text', text: 'Legacy summary only' }]);
expect(result[0].tokenCount).toBe(15);
});
});
describe('findSummaryContentBlock', () => {
it('should find a summary block in the content array', () => {
const message = {
content: [
{ type: 'text', text: 'some text' },
{ type: 'summary', text: 'Summary of conversation', tokenCount: 50 },
],
};
const result = TestClient.constructor.findSummaryContentBlock(message);
expect(result).toBeTruthy();
expect(result.text).toBe('Summary of conversation');
expect(result.tokenCount).toBe(50);
});
it('should return null when no summary block exists', () => {
const message = {
content: [
{ type: 'text', text: 'some text' },
{ type: 'tool_call', tool_call: {} },
],
};
expect(TestClient.constructor.findSummaryContentBlock(message)).toBeNull();
});
it('should return null for string content', () => {
const message = { content: 'just a string' };
expect(TestClient.constructor.findSummaryContentBlock(message)).toBeNull();
});
it('should return null for missing content', () => {
expect(TestClient.constructor.findSummaryContentBlock({})).toBeNull();
expect(TestClient.constructor.findSummaryContentBlock(null)).toBeNull();
});
it('should skip summary blocks with no text', () => {
const message = {
content: [{ type: 'summary', tokenCount: 10 }],
};
expect(TestClient.constructor.findSummaryContentBlock(message)).toBeNull();
});
});
describe('sendMessage', () => {
test('sendMessage should return a response message', async () => {
const expectedResult = expect.objectContaining({
sender: TestClient.sender,
text: expect.any(String),
isCreatedByUser: false,
messageId: expect.any(String),
parentMessageId: expect.any(String),
conversationId: expect.any(String),
});
const response = await TestClient.sendMessage(userMessage);
parentMessageId = response.messageId;
conversationId = response.conversationId;
expect(response).toEqual(expectedResult);
});
test('should replace responseMessageId with new UUID when isRegenerate is true and messageId ends with underscore', async () => {
const mockCrypto = require('crypto');
const newUUID = 'new-uuid-1234';
jest.spyOn(mockCrypto, 'randomUUID').mockReturnValue(newUUID);
const opts = {
isRegenerate: true,
responseMessageId: 'existing-message-id_',
};
await TestClient.setMessageOptions(opts);
expect(TestClient.responseMessageId).toBe(newUUID);
expect(TestClient.responseMessageId).not.toBe('existing-message-id_');
mockCrypto.randomUUID.mockRestore();
});
test('should not replace responseMessageId when isRegenerate is false', async () => {
const opts = {
isRegenerate: false,
responseMessageId: 'existing-message-id_',
};
await TestClient.setMessageOptions(opts);
expect(TestClient.responseMessageId).toBe('existing-message-id_');
});
test('should not replace responseMessageId when it does not end with underscore', async () => {
const opts = {
isRegenerate: true,
responseMessageId: 'existing-message-id',
};
await TestClient.setMessageOptions(opts);
expect(TestClient.responseMessageId).toBe('existing-message-id');
});
test('sendMessage should work with provided conversationId and parentMessageId', async () => {
const userMessage = 'Second message in the conversation';
const opts = {
conversationId,
parentMessageId,
getReqData: jest.fn(),
onStart: jest.fn(),
};
const expectedResult = expect.objectContaining({
sender: TestClient.sender,
text: expect.any(String),
isCreatedByUser: false,
messageId: expect.any(String),
parentMessageId: expect.any(String),
conversationId: opts.conversationId,
});
const response = await TestClient.sendMessage(userMessage, opts);
parentMessageId = response.messageId;
expect(response.conversationId).toEqual(conversationId);
expect(response).toEqual(expectedResult);
expect(opts.getReqData).toHaveBeenCalled();
expect(opts.onStart).toHaveBeenCalled();
expect(TestClient.getBuildMessagesOptions).toHaveBeenCalled();
expect(TestClient.getSaveOptions).toHaveBeenCalled();
});
test('should return chat history', async () => {
TestClient = initializeFakeClient(apiKey, options, messageHistory);
const chatMessages = await TestClient.loadHistory(conversationId, '2');
expect(TestClient.currentMessages).toHaveLength(2);
expect(chatMessages[0].text).toEqual('Hello');
const chatMessages2 = await TestClient.loadHistory(conversationId, '3');
expect(TestClient.currentMessages).toHaveLength(3);
expect(chatMessages2[chatMessages2.length - 1].text).toEqual("What's up");
});
test('loadHistory should scope database reads to the current user', async () => {
const user = 'user-123';
TestClient = new FakeClient(apiKey, options);
TestClient.user = user;
getMessages.mockResolvedValueOnce([
{
role: 'user',
isCreatedByUser: true,
text: 'Hello',
messageId: '1',
conversationId,
},
]);
const chatMessages = await TestClient.loadHistory(conversationId, '1');
expect(getMessages).toHaveBeenCalledWith({ conversationId, user });
expect(chatMessages).toHaveLength(1);
expect(chatMessages[0].text).toBe('Hello');
});
/* Most of the new sendMessage logic revolving around edited/continued AI messages
* can be summarized by the following test. The condition will load the entire history up to
* the message that is being edited, which will trigger the AI API to 'continue' the response.
* The 'userMessage' is only passed by convention and is not necessary for the generation.
*/
it('should not push userMessage to currentMessages when isEdited is true and vice versa', async () => {
const overrideParentMessageId = 'user-message-id';
const responseMessageId = 'response-message-id';
const newHistory = messageHistory.slice();
newHistory.push({
role: 'assistant',
isCreatedByUser: false,
text: 'test message',
messageId: responseMessageId,
parentMessageId: '3',
});
TestClient = initializeFakeClient(apiKey, options, newHistory);
const sendMessageOptions = {
isEdited: true,
overrideParentMessageId,
parentMessageId: '3',
responseMessageId,
};
await TestClient.sendMessage('test message', sendMessageOptions);
const currentMessages = TestClient.currentMessages;
expect(currentMessages[currentMessages.length - 1].messageId).not.toEqual(
overrideParentMessageId,
);
// Test the opposite case
sendMessageOptions.isEdited = false;
await TestClient.sendMessage('test message', sendMessageOptions);
const currentMessages2 = TestClient.currentMessages;
expect(currentMessages2[currentMessages2.length - 1].messageId).toEqual(
overrideParentMessageId,
);
});
it('applies edited reasoning content from its typed payload before regeneration', async () => {
const responseMessageId = 'response-with-reasoning';
const newHistory = [
...messageHistory,
{
role: 'assistant',
isCreatedByUser: false,
messageId: responseMessageId,
parentMessageId: '3',
content: [
{ type: ContentTypes.THINK, think: 'Original reasoning', phase: 'analysis' },
{ type: ContentTypes.TEXT, text: 'Original response' },
],
},
];
TestClient = initializeFakeClient(apiKey, options, newHistory);
await TestClient.sendMessage('test message', {
isEdited: true,
overrideParentMessageId: 'user-message-id',
parentMessageId: '3',
responseMessageId,
editedContent: {
index: 0,
type: ContentTypes.THINK,
[ContentTypes.THINK]: 'Updated reasoning',
},
});
const editedResponse = TestClient.currentMessages[TestClient.currentMessages.length - 1];
expect(editedResponse.content[0]).toEqual({
type: ContentTypes.THINK,
think: 'Updated reasoning',
phase: 'analysis',
});
});
test('setOptions is called with the correct arguments only when replaceOptions is set to true', async () => {
TestClient.setOptions = jest.fn();
const opts = { conversationId: '123', parentMessageId: '456', replaceOptions: true };
await TestClient.sendMessage('Hello, world!', opts);
expect(TestClient.setOptions).toHaveBeenCalledWith(opts);
TestClient.setOptions.mockClear();
});
test('loadHistory is called with the correct arguments', async () => {
const opts = { conversationId: '123', parentMessageId: '456' };
await TestClient.sendMessage('Hello, world!', opts);
expect(TestClient.loadHistory).toHaveBeenCalledWith(
opts.conversationId,
opts.parentMessageId,
);
});
test('getReqData is called with the correct arguments', async () => {
const getReqData = jest.fn();
const opts = { getReqData };
const response = await TestClient.sendMessage('Hello, world!', opts);
expect(getReqData).toHaveBeenCalledWith(
expect.objectContaining({
userMessage: expect.objectContaining({ text: 'Hello, world!' }),
conversationId: response.conversationId,
responseMessageId: response.messageId,
}),
);
});
test('onStart is called with the correct arguments', async () => {
const onStart = jest.fn();
const opts = { onStart };
await TestClient.sendMessage('Hello, world!', opts);
expect(onStart).toHaveBeenCalledWith(
expect.objectContaining({ text: 'Hello, world!' }),
expect.any(String),
/** `isNewConvo` */
true,
);
});
test('saveMessageToDatabase is called with the correct arguments', async () => {
const saveOptions = TestClient.getSaveOptions();
const user = {};
const opts = { user };
const saveSpy = jest.spyOn(TestClient, 'saveMessageToDatabase');
await TestClient.sendMessage('Hello, world!', opts);
expect(saveSpy).toHaveBeenCalledWith(
expect.objectContaining({
sender: expect.any(String),
text: expect.any(String),
isCreatedByUser: expect.any(Boolean),
messageId: expect.any(String),
parentMessageId: expect.any(String),
conversationId: expect.any(String),
}),
saveOptions,
user,
);
});
test('does not start the completed response write when terminal ownership is denied', async () => {
const hookStarted = deferred();
const terminalDecision = deferred();
const beforeResponsePersistence = jest.fn(() => {
hookStarted.resolve();
return terminalDecision.promise;
});
const saveSpy = jest.spyOn(TestClient, 'saveMessageToDatabase');
const responsePromise = TestClient.sendMessage('Race Stop against completion.', {
user: {},
beforeResponsePersistence,
});
await hookStarted.promise;
expect(beforeResponsePersistence).toHaveBeenCalledTimes(1);
expect(
saveSpy.mock.calls.filter(([message]) => message?.isCreatedByUser === false),
).toHaveLength(0);
terminalDecision.resolve(false);
const response = await responsePromise;
expect(beforeResponsePersistence).toHaveBeenCalledWith(response);
expect(
saveSpy.mock.calls.filter(([message]) => message?.isCreatedByUser === false),
).toHaveLength(0);
expect(TestClient.savedMessageIds.has(response.messageId)).toBe(false);
await expect(response.databasePromise).resolves.toEqual({ persistenceSkipped: true });
});
test('starts the completed response write only after terminal ownership is granted', async () => {
const hookStarted = deferred();
const terminalDecision = deferred();
const beforeResponsePersistence = jest.fn(() => {
hookStarted.resolve();
return terminalDecision.promise;
});
const saveSpy = jest.spyOn(TestClient, 'saveMessageToDatabase');
const responsePromise = TestClient.sendMessage('Complete after winning ownership.', {
user: {},
beforeResponsePersistence,
});
await hookStarted.promise;
expect(
saveSpy.mock.calls.filter(([message]) => message?.isCreatedByUser === false),
).toHaveLength(0);
terminalDecision.resolve(true);
const response = await responsePromise;
expect(
saveSpy.mock.calls.filter(([message]) => message?.isCreatedByUser === false),
).toHaveLength(1);
await expect(response.databasePromise).resolves.toEqual(expect.any(Object));
});
test('persists the generation-time Langfuse sampling decision for agent responses', async () => {
const previousSampleRate = process.env.LANGFUSE_SAMPLE_RATE;
process.env.LANGFUSE_SAMPLE_RATE = '0';
TestClient.options.endpoint = 'agents';
const saveSpy = jest.spyOn(TestClient, 'saveMessageToDatabase');
try {
const response = await TestClient.sendMessage('Hello, world!', { user: {} });
expect(response.langfuseSampled).toBe(false);
expect(response.langfuseDestinationIds).toEqual([]);
expect(saveSpy).toHaveBeenCalledWith(
expect.objectContaining({
langfuseSampled: false,
langfuseDestinationIds: [],
}),
expect.any(Object),
expect.any(Object),
);
} finally {
if (previousSampleRate == null) {
delete process.env.LANGFUSE_SAMPLE_RATE;
} else {
process.env.LANGFUSE_SAMPLE_RATE = previousSampleRate;
}
}
});
test('persists no Langfuse destination when a sampled trace has no configured export', async () => {
const envKeys = [
'LANGFUSE_PUBLIC_KEY',
'LANGFUSE_SECRET_KEY',
'LANGFUSE_FANOUT_ENABLED',
'LANGFUSE_FANOUT_COLLECTOR_URL',
'TENANT_ISOLATION_STRICT',
];
const previousEnv = Object.fromEntries(envKeys.map((key) => [key, process.env[key]]));
const previousSampleRate = process.env.LANGFUSE_SAMPLE_RATE;
envKeys.forEach((key) => delete process.env[key]);
process.env.LANGFUSE_SAMPLE_RATE = '1';
TestClient.options.endpoint = 'agents';
const saveSpy = jest.spyOn(TestClient, 'saveMessageToDatabase');
try {
const response = await TestClient.sendMessage('Hello, world!', { user: {} });
expect(response.langfuseSampled).toBe(true);
expect(response.langfuseDestinationIds).toEqual([]);
expect(saveSpy).toHaveBeenCalledWith(
expect.objectContaining({
langfuseSampled: true,
langfuseDestinationIds: [],
}),
expect.any(Object),
expect.any(Object),
);
} finally {
for (const [key, value] of Object.entries(previousEnv)) {
if (value == null) {
delete process.env[key];
} else {
process.env[key] = value;
}
}
if (previousSampleRate == null) {
delete process.env.LANGFUSE_SAMPLE_RATE;
} else {
process.env.LANGFUSE_SAMPLE_RATE = previousSampleRate;
}
}
});
test('should handle existing conversation when getConvo retrieves one', async () => {
const existingConvo = {
conversationId: 'existing-convo-id',
endpoint: 'openai',
endpointType: 'openai',
model: 'gpt-3.5-turbo',
messages: [
{ role: 'user', content: 'Existing message 1' },
{ role: 'assistant', content: 'Existing response 1' },
],
temperature: 1,
};
const { temperature: _temp, ...newConvo } = existingConvo;
const user = {
id: 'user-id',
};
getConvo.mockResolvedValue(existingConvo);
saveConvo.mockResolvedValue(newConvo);
TestClient = initializeFakeClient(
apiKey,
{
...options,
req: {
user,
},
},
[],
);
const saveSpy = jest.spyOn(TestClient, 'saveMessageToDatabase');
const newMessage = 'New message in existing conversation';
const response = await TestClient.sendMessage(newMessage, {
user,
conversationId: existingConvo.conversationId,
});
expect(getConvo).toHaveBeenCalledWith(user.id, existingConvo.conversationId);
expect(TestClient.conversationId).toBe(existingConvo.conversationId);
expect(response.conversationId).toBe(existingConvo.conversationId);
expect(TestClient.fetchedConvo).toBe(true);
expect(saveSpy).toHaveBeenCalledWith(
expect.objectContaining({
conversationId: existingConvo.conversationId,
text: newMessage,
}),
expect.any(Object),
expect.any(Object),
);
expect(saveConvo).toHaveBeenCalledTimes(2);
expect(saveConvo).toHaveBeenCalledWith(
expect.any(Object),
expect.objectContaining({
conversationId: existingConvo.conversationId,
}),
expect.objectContaining({
context: 'api/app/clients/BaseClient.js - saveMessageToDatabase #saveConvo',
unsetFields: {
temperature: 1,
},
}),
);
await TestClient.sendMessage('Another message', {
conversationId: existingConvo.conversationId,
});
expect(getConvo).toHaveBeenCalledTimes(1);
});
test('should correctly handle existing conversation and unset fields appropriately', async () => {
const existingConvo = {
conversationId: 'existing-convo-id',
endpoint: 'openai',
endpointType: 'openai',
model: 'gpt-3.5-turbo',
messages: [
{ role: 'user', content: 'Existing message 1' },
{ role: 'assistant', content: 'Existing response 1' },
],
title: 'Existing Conversation',
someExistingField: 'existingValue',
anotherExistingField: 'anotherValue',
temperature: 0.7,
modelLabel: 'GPT-3.5',
};
getConvo.mockResolvedValue(existingConvo);
saveConvo.mockResolvedValue(existingConvo);
TestClient = initializeFakeClient(
apiKey,
{
...options,
modelOptions: {
model: 'gpt-4',
temperature: 0.5,
},
},
[],
);
const newMessage = 'New message in existing conversation';
await TestClient.sendMessage(newMessage, {
conversationId: existingConvo.conversationId,
});
expect(saveConvo).toHaveBeenCalledTimes(2);
const saveConvoCall = saveConvo.mock.calls[0];
const [, savedFields, saveOptions] = saveConvoCall;
// Instead of checking all excludedKeys, we'll just check specific fields
// that we know should be excluded
expect(savedFields).not.toHaveProperty('messages');
expect(savedFields).not.toHaveProperty('title');
// Only check that someExistingField is in unsetFields
expect(saveOptions.unsetFields).toHaveProperty('someExistingField', 1);
// Mock saveConvo to return the expected fields
saveConvo.mockImplementation((req, fields) => {
return Promise.resolve({
...fields,
endpoint: 'openai',
endpointType: 'openai',
model: 'gpt-4',
temperature: 0.5,
});
});
// Only check the conversationId since that's the only field we can be sure about
expect(savedFields).toHaveProperty('conversationId', 'existing-convo-id');
expect(TestClient.fetchedConvo).toBe(true);
await TestClient.sendMessage('Another message', {
conversationId: existingConvo.conversationId,
});
expect(getConvo).toHaveBeenCalledTimes(1);
const secondSaveConvoCall = saveConvo.mock.calls[1];
expect(secondSaveConvoCall[2]).toHaveProperty('unsetFields', {});
});
test('sendCompletion is called with the correct arguments', async () => {
const payload = {}; // Mock payload
TestClient.buildMessages.mockReturnValue({ prompt: payload, tokenCountMap: null });
const opts = {};
await TestClient.sendMessage('Hello, world!', opts);
expect(TestClient.sendCompletion).toHaveBeenCalledWith(payload, opts);
});
test('records history and message-build startup milestones', async () => {
const startupTelemetry = { mark: jest.fn() };
TestClient.options.startupTelemetry = startupTelemetry;
await TestClient.sendMessage('Hello, world!', {});
expect(startupTelemetry.mark.mock.calls.map(([milestone]) => milestone)).toEqual([
'history_loaded',
'messages_built',
]);
});
test('getTokenCount for response is called with the correct arguments', async () => {
const tokenCountMap = {}; // Mock tokenCountMap
TestClient.buildMessages.mockReturnValue({ prompt: [], tokenCountMap });
TestClient.getTokenCountForResponse = jest.fn();
const response = await TestClient.sendMessage('Hello, world!', {});
expect(TestClient.getTokenCountForResponse).toHaveBeenCalledWith(response);
});
test('returns an object with the correct shape', async () => {
const response = await TestClient.sendMessage('Hello, world!', {});
expect(response).toEqual(
expect.objectContaining({
sender: expect.any(String),
text: expect.any(String),
isCreatedByUser: expect.any(Boolean),
messageId: expect.any(String),
parentMessageId: expect.any(String),
conversationId: expect.any(String),
}),
);
});
test('saveMessageToDatabase returns early when this.options is null (client disposed)', async () => {
const savedOptions = TestClient.options;
TestClient.options = null;
saveMessage.mockClear();
const result = await TestClient.saveMessageToDatabase(
{ messageId: 'msg-1', conversationId: 'conv-1', isCreatedByUser: true, text: 'hi' },
{},
null,
);
expect(result).toEqual({});
expect(saveMessage).not.toHaveBeenCalled();
TestClient.options = savedOptions;
});
test('saveMessageToDatabase uses snapshot of options, immune to mid-await disposal', async () => {
const savedOptions = TestClient.options;
saveMessage.mockClear();
saveConvo.mockClear();
// Make db.saveMessage yield, simulating I/O suspension during which disposal occurs
saveMessage.mockImplementation(async (_reqCtx, msgData) => {
// Simulate disposeClient nullifying client.options while awaiting
TestClient.options = null;
return msgData;
});
saveConvo.mockResolvedValue({ conversationId: 'conv-1' });
const result = await TestClient.saveMessageToDatabase(
{ messageId: 'msg-1', conversationId: 'conv-1', isCreatedByUser: true, text: 'hi' },
{ endpoint: 'openAI' },
null,
);
// Should complete without TypeError, using the snapshotted options
expect(result).toHaveProperty('message');
expect(result).toHaveProperty('conversation');
expect(saveMessage).toHaveBeenCalled();
TestClient.options = savedOptions;
saveMessage.mockReset();
saveConvo.mockReset();
});
test('saveMessageToDatabase reuses conversation resolved on the request', async () => {
const existingConvo = {
conversationId: 'cached-convo-id',
endpoint: 'openai',
endpointType: 'openai',
temperature: 0.7,
};
const user = { id: 'user-id' };
const req = { user, resolvedConversation: existingConvo };
getConvo.mockClear();
saveMessage.mockResolvedValue({ messageId: 'msg-1' });
saveConvo.mockResolvedValue(existingConvo);
TestClient = initializeFakeClient(apiKey, { ...options, endpoint: 'openai', req }, []);
await TestClient.saveMessageToDatabase(
{
messageId: 'msg-1',
conversationId: existingConvo.conversationId,
isCreatedByUser: true,
text: 'hi',
},
{ endpoint: 'openai' },
user,
);
expect(getConvo).not.toHaveBeenCalled();
expect(req).not.toHaveProperty('resolvedConversation');
expect(TestClient.fetchedConvo).toBe(true);
expect(saveConvo).toHaveBeenCalledWith(
expect.any(Object),
expect.objectContaining({ conversationId: existingConvo.conversationId }),
expect.objectContaining({
unsetFields: expect.objectContaining({ temperature: 1 }),
}),
);
});
test('userMessagePromise is awaited before saving response message', async () => {
// Mock the saveMessageToDatabase method
TestClient.saveMessageToDatabase = jest.fn().mockImplementation(() => {
return new Promise((resolve) => setTimeout(resolve, 100)); // Simulate a delay
});
// Send a message
const messagePromise = TestClient.sendMessage('Hello, world!');
// Wait a short time to ensure the user message save has started
await new Promise((resolve) => setTimeout(resolve, 50));
// Check that saveMessageToDatabase has been called once (for the user message)
expect(TestClient.saveMessageToDatabase).toHaveBeenCalledTimes(1);
// Wait for the message to be fully processed
await messagePromise;
// Check that saveMessageToDatabase has been called twice (once for user message, once for response)
expect(TestClient.saveMessageToDatabase).toHaveBeenCalledTimes(2);
// Check the order of calls
const calls = TestClient.saveMessageToDatabase.mock.calls;
expect(calls[0][0].isCreatedByUser).toBe(true); // First call should be for user message
expect(calls[1][0].isCreatedByUser).toBe(false); // Second call should be for response message
});
});
describe('recordTokenUsage model assignment', () => {
test('should pass this.model to recordTokenUsage, not the agent ID from responseMessage.model', async () => {
const actualModel = 'claude-opus-4-5';
const agentId = 'agent_p5Z_IU6EIxBoqn1BoqLBp';
TestClient.model = actualModel;
TestClient.options.endpoint = 'agents';
TestClient.options.agent = { id: agentId };
TestClient.getTokenCountForResponse = jest.fn().mockReturnValue(50);
TestClient.recordTokenUsage = jest.fn().mockResolvedValue(undefined);
TestClient.buildMessages.mockReturnValue({
prompt: [],
tokenCountMap: { res: 50 },
});
await TestClient.sendMessage('Hello', {});
expect(TestClient.recordTokenUsage).toHaveBeenCalledWith(
expect.objectContaining({
model: actualModel,
}),
);
const callArgs = TestClient.recordTokenUsage.mock.calls[0][0];
expect(callArgs.model).not.toBe(agentId);
});
test('should pass this.model even when this.model differs from modelOptions.model', async () => {
const instanceModel = 'gpt-4o';
TestClient.model = instanceModel;
TestClient.modelOptions = { model: 'gpt-4o-mini' };
TestClient.getTokenCountForResponse = jest.fn().mockReturnValue(50);
TestClient.recordTokenUsage = jest.fn().mockResolvedValue(undefined);
TestClient.buildMessages.mockReturnValue({
prompt: [],
tokenCountMap: { res: 50 },
});
await TestClient.sendMessage('Hello', {});
expect(TestClient.recordTokenUsage).toHaveBeenCalledWith(
expect.objectContaining({
model: instanceModel,
}),
);
});
});
/**
* The `transactions.enabled` guard lives in `createTransaction`, which reads it off
* the object it is handed. Dropping the config anywhere between here and there
* silently re-enables the writes rather than failing, so pin the wiring itself.
*/
describe('recordTokenUsage transactions config', () => {
let priorEndpoint;
let priorEndpointType;
const arrangeFallbackPath = () => {
TestClient.getTokenCountForResponse = jest.fn().mockReturnValue(50);
TestClient.recordTokenUsage = jest.fn().mockResolvedValue(undefined);
TestClient.buildMessages.mockReturnValue({
prompt: [],
tokenCountMap: { res: 50 },
});
};
/** `options` is shared across this file's tests, so an endpoint left behind by an earlier
* case would route the balance-enabled arrangement into `checkBalance`. */
beforeEach(() => {
priorEndpoint = TestClient.options.endpoint;
priorEndpointType = TestClient.options.endpointType;
delete TestClient.options.endpoint;
delete TestClient.options.endpointType;
});
afterEach(() => {
delete TestClient.options.req;
TestClient.options.endpoint = priorEndpoint;
TestClient.options.endpointType = priorEndpointType;
});
test('should forward the resolved transactions config to recordTokenUsage', async () => {
TestClient.options.req = { config: { transactions: { enabled: false } } };
arrangeFallbackPath();
await TestClient.sendMessage('Hello', {});
expect(TestClient.recordTokenUsage).toHaveBeenCalledWith(
expect.objectContaining({
transactions: { enabled: false },
}),
);
});
test('should default to enabled transactions when no app config is present', async () => {
arrangeFallbackPath();
await TestClient.sendMessage('Hello', {});
expect(TestClient.recordTokenUsage).toHaveBeenCalledWith(
expect.objectContaining({
transactions: { enabled: true },
}),
);
});
test('should forward transactions as enabled when balance tracking overrides the setting', async () => {
TestClient.options.req = {
config: { transactions: { enabled: false }, balance: { enabled: true } },
};
arrangeFallbackPath();
await TestClient.sendMessage('Hello', {});
expect(TestClient.recordTokenUsage).toHaveBeenCalledWith(
expect.objectContaining({
transactions: { enabled: true },
}),
);
});
});
describe('getMessagesWithinTokenLimit with instructions', () => {
test('should always include instructions when present', async () => {
TestClient.maxContextTokens = 50;
const instructions = {
role: 'system',
content: 'System instructions',
tokenCount: 20,
};
const messages = [
instructions,
{ role: 'user', content: 'Hello', tokenCount: 10 },
{ role: 'assistant', content: 'Hi there', tokenCount: 15 },
];
const result = await TestClient.getMessagesWithinTokenLimit({
messages,
instructions,
});
expect(result.context[0]).toBe(instructions);
expect(result.remainingContextTokens).toBe(2);
});
test('should handle case when messages exceed limit but instructions must be preserved', async () => {
TestClient.maxContextTokens = 30;
const instructions = {
role: 'system',
content: 'System instructions',
tokenCount: 20,
};
const messages = [
instructions,
{ role: 'user', content: 'Hello', tokenCount: 10 },
{ role: 'assistant', content: 'Hi there', tokenCount: 15 },
];
const result = await TestClient.getMessagesWithinTokenLimit({
messages,
instructions,
});
// Should only include instructions and the last message that fits
expect(result.context).toHaveLength(1);
expect(result.context[0].content).toBe(instructions.content);
expect(result.messagesToRefine).toHaveLength(2);
expect(result.remainingContextTokens).toBe(7); // 30 - 20 - 3 (assistant label)
});
test('should work correctly without instructions (1/2)', async () => {
TestClient.maxContextTokens = 50;
const messages = [
{ role: 'user', content: 'Hello', tokenCount: 10 },
{ role: 'assistant', content: 'Hi there', tokenCount: 15 },
];
const result = await TestClient.getMessagesWithinTokenLimit({
messages,
});
expect(result.context).toHaveLength(2);
expect(result.remainingContextTokens).toBe(22); // 50 - 10 - 15 - 3(assistant label)
expect(result.messagesToRefine).toHaveLength(0);
});
test('should work correctly without instructions (2/2)', async () => {
TestClient.maxContextTokens = 30;
const messages = [
{ role: 'user', content: 'Hello', tokenCount: 10 },
{ role: 'assistant', content: 'Hi there', tokenCount: 20 },
];
const result = await TestClient.getMessagesWithinTokenLimit({
messages,
});
expect(result.context).toHaveLength(1);
expect(result.remainingContextTokens).toBe(7);
expect(result.messagesToRefine).toHaveLength(1);
});
test('should handle case when only instructions fit within limit', async () => {
TestClient.maxContextTokens = 25;
const instructions = {
role: 'system',
content: 'System instructions',
tokenCount: 20,
};
const messages = [
instructions,
{ role: 'user', content: 'Hello', tokenCount: 10 },
{ role: 'assistant', content: 'Hi there', tokenCount: 15 },
];
const result = await TestClient.getMessagesWithinTokenLimit({
messages,
instructions,
});
expect(result.context).toHaveLength(1);
expect(result.context[0]).toBe(instructions);
expect(result.messagesToRefine).toHaveLength(2);
expect(result.remainingContextTokens).toBe(2); // 25 - 20 - 3(assistant label)
});
});
describe('sendMessage file population', () => {
const attachment = {
file_id: 'file-abc',
filename: 'image.png',
filepath: '/uploads/image.png',
type: 'image/png',
bytes: 1024,
object: 'file',
user: 'user-1',
embedded: false,
usage: 0,
text: 'large ocr blob that should be stripped',
_id: 'mongo-id-1',
};
beforeEach(() => {
TestClient.options.req = { body: { files: [{ file_id: 'file-abc' }] } };
TestClient.options.attachments = [attachment];
});
test('populates userMessage.files before saveMessageToDatabase is called', async () => {
TestClient.saveMessageToDatabase = jest.fn().mockImplementation((msg) => {
return Promise.resolve({ message: msg });
});
await TestClient.sendMessage('Hello');
const userSave = TestClient.saveMessageToDatabase.mock.calls.find(
([msg]) => msg.isCreatedByUser,
);
expect(userSave).toBeDefined();
expect(userSave[0].files).toBeDefined();
expect(userSave[0].files).toHaveLength(1);
expect(userSave[0].files[0].file_id).toBe('file-abc');
});
test('strips text and _id from files before saving', async () => {
TestClient.saveMessageToDatabase = jest.fn().mockResolvedValue({ message: {} });
await TestClient.sendMessage('Hello');
const userSave = TestClient.saveMessageToDatabase.mock.calls.find(
([msg]) => msg.isCreatedByUser,
);
expect(userSave[0].files[0].text).toBeUndefined();
expect(userSave[0].files[0]._id).toBeUndefined();
expect(userSave[0].files[0].filename).toBe('image.png');
});
test('deletes image_urls from userMessage when files are present', async () => {
TestClient.saveMessageToDatabase = jest.fn().mockResolvedValue({ message: {} });
TestClient.options.attachments = [
{ ...attachment, image_urls: ['data:image/png;base64,...'] },
];
await TestClient.sendMessage('Hello');
const userSave = TestClient.saveMessageToDatabase.mock.calls.find(
([msg]) => msg.isCreatedByUser,
);
expect(userSave[0].image_urls).toBeUndefined();
});
test('does not set files when no attachments match request file IDs', async () => {
TestClient.options.req = { body: { files: [{ file_id: 'file-nomatch' }] } };
TestClient.saveMessageToDatabase = jest.fn().mockResolvedValue({ message: {} });
await TestClient.sendMessage('Hello');
const userSave = TestClient.saveMessageToDatabase.mock.calls.find(
([msg]) => msg.isCreatedByUser,
);
expect(userSave[0].files).toBeUndefined();
});
test('skips file population when attachments is not an array (Promise case)', async () => {
TestClient.options.attachments = Promise.resolve([attachment]);
TestClient.saveMessageToDatabase = jest.fn().mockResolvedValue({ message: {} });
await TestClient.sendMessage('Hello');
const userSave = TestClient.saveMessageToDatabase.mock.calls.find(
([msg]) => msg.isCreatedByUser,
);
expect(userSave[0].files).toBeUndefined();
});
test('skips file population when skipSaveUserMessage is true', async () => {
TestClient.skipSaveUserMessage = true;
TestClient.saveMessageToDatabase = jest.fn().mockResolvedValue({ message: {} });
await TestClient.sendMessage('Hello');
const userSave = TestClient.saveMessageToDatabase.mock.calls.find(
([msg]) => msg?.isCreatedByUser,
);
expect(userSave).toBeUndefined();
});
test('ignores file_id: undefined entries in req.body.files (no set poisoning)', async () => {
TestClient.options.req = {
body: { files: [{ file_id: undefined }, { file_id: 'file-abc' }] },
};
TestClient.options.attachments = [
{ ...attachment, file_id: undefined },
{ ...attachment, file_id: 'file-abc' },
];
TestClient.saveMessageToDatabase = jest.fn().mockResolvedValue({ message: {} });
await TestClient.sendMessage('Hello');
const userSave = TestClient.saveMessageToDatabase.mock.calls.find(
([msg]) => msg.isCreatedByUser,
);
expect(userSave[0].files).toHaveLength(1);
expect(userSave[0].files[0].file_id).toBe('file-abc');
});
});
describe('addPreviousAttachments authorization', () => {
const ownerFile = {
file_id: 'owner-file',
filename: 'owner.txt',
filepath: '/uploads/owner.txt',
source: 'local',
type: 'text/plain',
bytes: 100,
object: 'file',
user: 'user-1',
embedded: false,
usage: 0,
text: 'authorized owner text',
_id: 'owner-mongo-id',
metadata: {
codeEnvRef: {
kind: 'user',
id: 'user-1',
storage_session_id: 'owner-session',
file_id: 'owner-code-file',
},
},
};
beforeEach(() => {
getFiles.mockReset();
TestClient.options.resendFiles = true;
TestClient.options.attachments = undefined;
TestClient.options.req = {
user: {
id: 'user-1',
tenantId: 'tenant-a',
},
};
TestClient.addFileContextToMessage = jest.fn(async (message, files) => {
const text = files
.map((file) => file.text)
.filter(Boolean)
.join('\n');
if (text) {
message.fileContext = text;
}
});
TestClient.processAttachments = jest.fn(async (_message, files) => files);
TestClient.checkVisionRequest = jest.fn();
});
test('rehydrates historical file refs from owner-scoped DB rows only', async () => {
getFiles.mockResolvedValueOnce([ownerFile]);
const [message] = await TestClient.addPreviousAttachments([
{
messageId: 'msg-1',
text: 'Use the attachment',
files: [
{
file_id: 'owner-file',
filename: 'attacker-controlled-owner-name.txt',
filepath: '/forged/owner.txt',
text: 'forged owner text',
},
{
file_id: 'victim-file',
filename: 'victim.txt',
filepath: '/victim/private.txt',
text: 'victim private text',
},
],
attachments: [
{
file_id: 'victim-file',
filename: 'victim-output.csv',
text: 'victim output text',
},
],
fileContext: 'stale victim private text',
},
]);
expect(getFiles).toHaveBeenCalledWith(
{
file_id: { $in: ['owner-file', 'victim-file'] },
user: 'user-1',
tenantId: 'tenant-a',
},
{},
{},
);
expect(TestClient.addFileContextToMessage).toHaveBeenCalledWith(message, [ownerFile]);
expect(TestClient.processAttachments).toHaveBeenCalledWith(message, [ownerFile]);
expect(message.fileContext).toBe('authorized owner text');
expect(message.files).toEqual([
expect.objectContaining({
file_id: 'owner-file',
filename: 'owner.txt',
filepath: '/uploads/owner.txt',
source: 'local',
metadata: ownerFile.metadata,
}),
]);
expect(message.files[0].text).toBeUndefined();
expect(message.files[0]._id).toBeUndefined();
expect(message.attachments).toBeUndefined();
expect(JSON.stringify(message)).not.toContain('victim');
expect(JSON.stringify(message)).not.toContain('forged owner text');
});
test('strips historical file context when no authenticated owner scope is available', async () => {
TestClient.options.req = {};
const [message] = await TestClient.addPreviousAttachments([
{
messageId: 'msg-2',
files: [{ file_id: 'victim-file', filename: 'victim.txt' }],
fileContext: 'stale victim private text',
},
]);
expect(getFiles).not.toHaveBeenCalled();
expect(message.files).toBeUndefined();
expect(message.fileContext).toBeUndefined();
});
test('preserves repeated owner-authorized historical file refs after the first context use', async () => {
getFiles.mockResolvedValueOnce([ownerFile]);
const [firstMessage, secondMessage] = await TestClient.addPreviousAttachments([
{
messageId: 'msg-repeat-1',
files: [{ file_id: 'owner-file', filename: 'first-forged.txt' }],
},
{
messageId: 'msg-repeat-2',
files: [{ file_id: 'owner-file', filename: 'second-forged.txt' }],
},
]);
expect(getFiles).toHaveBeenCalledTimes(1);
expect(getFiles).toHaveBeenCalledWith(
{
file_id: { $in: ['owner-file'] },
user: 'user-1',
tenantId: 'tenant-a',
},
{},
{},
);
expect(TestClient.addFileContextToMessage).toHaveBeenCalledTimes(1);
expect(TestClient.addFileContextToMessage).toHaveBeenCalledWith(firstMessage, [ownerFile]);
expect(secondMessage.fileContext).toBeUndefined();
expect(firstMessage.files).toEqual([
expect.objectContaining({ file_id: 'owner-file', filename: 'owner.txt' }),
]);
expect(secondMessage.files).toEqual([
expect.objectContaining({ file_id: 'owner-file', filename: 'owner.txt' }),
]);
expect(JSON.stringify(secondMessage)).not.toContain('second-forged');
});
test('extracts historical file context while encoding provider attachments', async () => {
getFiles.mockResolvedValueOnce([ownerFile]);
const fileContext = deferred();
const providerAttachments = deferred();
let completed = false;
TestClient.addFileContextToMessage.mockImplementation(async (message) => {
await fileContext.promise;
message.fileContext = 'authorized owner text';
});
TestClient.processAttachments.mockImplementation(() => providerAttachments.promise);
const messagesPromise = TestClient.addPreviousAttachments([
{
messageId: 'msg-concurrent-file-work',
files: [{ file_id: 'owner-file', filename: 'owner.txt' }],
},
]).then((messages) => {
completed = true;
return messages;
});
await Promise.resolve();
await Promise.resolve();
expect(TestClient.addFileContextToMessage).toHaveBeenCalledTimes(1);
expect(TestClient.processAttachments).toHaveBeenCalledTimes(1);
providerAttachments.resolve([ownerFile]);
await Promise.resolve();
expect(completed).toBe(false);
fileContext.resolve();
const [message] = await messagesPromise;
expect(message.fileContext).toBe('authorized owner text');
expect(TestClient.message_file_map['msg-concurrent-file-work']).toEqual([ownerFile]);
});
test('preserves download-only historical attachments without trusting file fields', async () => {
const [message] = await TestClient.addPreviousAttachments([
{
messageId: 'msg-download-only',
attachments: [
{
filename: 'report.csv',
filepath: '/api/files/code/download/session/file',
expiresAt: 123456,
conversationId: 'conversation-1',
messageId: 'assistant-message',
toolCallId: 'tool-call-1',
text: 'untrusted text should not survive',
source: 'forged-source',
metadata: { codeEnvRef: { id: 'victim' } },
},
],
fileContext: 'stale context',
},
]);
expect(getFiles).not.toHaveBeenCalled();
expect(message.fileContext).toBeUndefined();
expect(message.attachments).toEqual([
{
filename: 'report.csv',
filepath: '/api/files/code/download/session/file',
expiresAt: 123456,
conversationId: 'conversation-1',
messageId: 'assistant-message',
toolCallId: 'tool-call-1',
},
]);
expect(JSON.stringify(message)).not.toContain('untrusted text');
expect(JSON.stringify(message)).not.toContain('forged-source');
expect(JSON.stringify(message)).not.toContain('victim');
});
test('merges safe per-message metadata onto authorized DB-backed attachments', async () => {
getFiles.mockResolvedValueOnce([ownerFile]);
const [message] = await TestClient.addPreviousAttachments([
{
messageId: 'msg-artifact',
attachments: [
{
file_id: 'owner-file',
filename: 'forged-artifact.csv',
filepath: '/forged/artifact.csv',
source: 'forged-source',
metadata: { codeEnvRef: { id: 'victim' } },
text: 'forged artifact text',
messageId: 'assistant-message',
toolCallId: 'tool-call-2',
},
],
},
]);
expect(message.attachments).toEqual([
expect.objectContaining({
file_id: 'owner-file',
filename: 'owner.txt',
filepath: '/uploads/owner.txt',
source: 'local',
metadata: ownerFile.metadata,
messageId: 'assistant-message',
toolCallId: 'tool-call-2',
}),
]);
expect(message.attachments[0].text).toBeUndefined();
expect(message.attachments[0]._id).toBeUndefined();
expect(JSON.stringify(message)).not.toContain('forged-artifact');
expect(JSON.stringify(message)).not.toContain('forged artifact text');
});
});
describe('sendMessage quote references', () => {
// The blockquote merge itself lives in AgentClient.buildMessages / prependQuotes
// (covered by packages/api specs). BaseClient's job is to attach the normalized
// quotes onto the user message early and keep the stored text clean.
test('attaches normalized quotes before getReqData fires and keeps stored text clean', async () => {
TestClient.options.req = { body: { quotes: [' the selected text ', '', 42] } };
TestClient.saveMessageToDatabase = jest.fn().mockResolvedValue({ message: {} });
let captured;
await TestClient.sendMessage('What does this mean?', {
getReqData: (data) => {
if (data.userMessage) {
captured = { text: data.userMessage.text, quotes: data.userMessage.quotes };
}
},
});
// Quotes are present (trimmed, non-strings dropped) at getReqData time, and
// the user text is never mutated by the merge.
expect(captured).toBeDefined();
expect(captured.quotes).toEqual(['the selected text']);
expect(captured.text).toBe('What does this mean?');
const userSave = TestClient.saveMessageToDatabase.mock.calls.find(
([msg]) => msg.isCreatedByUser,
);
expect(userSave[0].text).toBe('What does this mean?');
expect(userSave[0].quotes).toEqual(['the selected text']);
});
test('persists multiple quotes in order on the saved message', async () => {
TestClient.options.req = { body: { quotes: ['first excerpt', 'second excerpt'] } };
TestClient.saveMessageToDatabase = jest.fn().mockResolvedValue({ message: {} });
await TestClient.sendMessage('Compare these');
const userSave = TestClient.saveMessageToDatabase.mock.calls.find(
([msg]) => msg.isCreatedByUser,
);
expect(userSave[0].text).toBe('Compare these');
expect(userSave[0].quotes).toEqual(['first excerpt', 'second excerpt']);
});
test('leaves the message untouched when no quotes are provided', async () => {
TestClient.options.req = { body: {} };
TestClient.saveMessageToDatabase = jest.fn().mockResolvedValue({ message: {} });
await TestClient.sendMessage('Just a question');
const userSave = TestClient.saveMessageToDatabase.mock.calls.find(
([msg]) => msg.isCreatedByUser,
);
expect(userSave[0].text).toBe('Just a question');
expect(userSave[0].quotes).toBeUndefined();
});
});
describe('mergeEditedContent phase boundaries', () => {
test('does not merge commentary into a final answer', () => {
const existing = [
{ type: ContentTypes.TEXT, text: 'Checked the deployment. ', phase: 'commentary' },
];
const completion = [
{ type: ContentTypes.TEXT, text: 'Everything is healthy.', phase: 'final_answer' },
{
type: ContentTypes.ACTIVITY_LABEL,
activity_label_type: 'phase',
activity_start_index: 0,
activity_end_index: 1,
activity_label: 'Verified deployment health',
},
];
expect(TestClient.mergeEditedContent(existing, completion, ContentTypes.TEXT)).toEqual([
existing[0],
completion[0],
{ ...completion[1], activity_start_index: 1, activity_end_index: 2 },
]);
});
test.each([
[undefined, 'commentary'],
['commentary', undefined],
])('does not merge phased and unphased text (%s → %s)', (existingPhase, completionPhase) => {
const existing = [
{
type: ContentTypes.TEXT,
text: 'Retained text. ',
...(existingPhase != null && { phase: existingPhase }),
},
];
const completion = [
{
type: ContentTypes.TEXT,
text: 'New text.',
...(completionPhase != null && { phase: completionPhase }),
},
];
expect(TestClient.mergeEditedContent(existing, completion, ContentTypes.TEXT)).toEqual([
existing[0],
completion[0],
]);
});
});
});